Skip to main content

xbp_deploy/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3use std::path::PathBuf;
4use xbp_oci::OciRef;
5
6/// Resolved deploy target kind.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum DeployTarget {
10    Service(String),
11    Group(String),
12    All,
13}
14
15impl DeployTarget {
16    pub fn label(&self) -> String {
17        match self {
18            Self::Service(s) | Self::Group(s) => s.clone(),
19            Self::All => "all".into(),
20        }
21    }
22
23    pub fn kind_str(&self) -> &'static str {
24        match self {
25            Self::Service(_) => "service",
26            Self::Group(_) => "group",
27            Self::All => "all",
28        }
29    }
30}
31
32/// Project config view (CLI maps XbpConfig → this). Pure data, no clap.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ProjectConfig {
35    pub project_name: String,
36    pub version: String,
37    pub project_root: PathBuf,
38    pub services: Vec<ServiceConfigView>,
39    pub groups: HashMap<String, DeployGroupView>,
40    pub default_env: Option<String>,
41    pub kubernetes: Option<KubernetesDefaults>,
42    pub history_dir: PathBuf,
43    pub lock_file: PathBuf,
44    pub git_sha: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DeployGroupView {
49    pub description: Option<String>,
50    pub services: Vec<String>,
51    /// Authoritative order when non-empty.
52    pub order: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct KubernetesDefaults {
57    pub default_context: Option<String>,
58    pub default_namespace: Option<String>,
59    pub require_context_confirmation_for_apply: bool,
60    pub kubeconfig: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ServiceConfigView {
65    pub name: String,
66    pub root_directory: Option<String>,
67    pub version: Option<String>,
68    pub depends_on: Vec<String>,
69    /// Service-level env map from `services[].environment` (placeholders allowed).
70    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
71    pub environment: std::collections::BTreeMap<String, String>,
72    /// Default process port when deploy.envs has no container_port.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub port: Option<u16>,
75    pub oci: Option<ServiceOciView>,
76    pub deploy: Option<ServiceDeployView>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ServiceOciView {
81    pub image: String,
82    pub tag: Option<String>,
83    pub tag_from: Option<String>,
84    pub dockerfile: Option<String>,
85    /// Docker build context (relative to service root or project root).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub context: Option<String>,
88    pub platforms: Vec<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ServiceDeployView {
93    /// Default provider when `--to` is omitted **and** `destinations` is empty.
94    /// When `destinations` lists multiple targets, the default is every enabled
95    /// destination (multi-target), not this single provider alone.
96    pub provider: String,
97    /// Optional Cloudflare worker app name (`workers[].name`). When unset,
98    /// Cloudflare deploy uses the service name.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub worker: Option<String>,
101    /// Optional containers rollout for Cloudflare (`immediate` | `gradual`).
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub rollout: Option<String>,
104    /// Named deploy destinations for this service (cloudflare, kubernetes, railway, …).
105    ///
106    /// When non-empty, `xbp deploy <svc> --run` (no `--to`) deploys **all enabled**
107    /// destinations. Narrow with `--to cloudflare` / `--to kubernetes`, or force
108    /// expansion with `--to all`.
109    ///
110    /// Keys are destination ids; values override provider/worker/rollout.
111    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
112    pub destinations: HashMap<String, ServiceDeployDestinationView>,
113    pub envs: HashMap<String, ServiceDeployEnvView>,
114}
115
116/// One named deploy destination under `services[].deploy.destinations.<name>`.
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct ServiceDeployDestinationView {
119    /// Engine provider (defaults to destination name mapping, e.g. cloudflare → cloudflare-containers).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub provider: Option<String>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub worker: Option<String>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub rollout: Option<String>,
126    /// When false, destination is ignored unless explicitly selected with `--to`.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub enabled: Option<bool>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ServiceDeployEnvView {
133    pub namespace: Option<String>,
134    pub replicas: Option<u32>,
135    pub health: Vec<String>,
136    pub kubernetes: Option<ServiceK8sView>,
137    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
138    pub env: std::collections::BTreeMap<String, String>,
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub env_files: Vec<String>,
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub config_files: Vec<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub container_port: Option<u16>,
145    #[serde(default, skip_serializing_if = "Vec::is_empty")]
146    pub required_env: Vec<String>,
147    /// How the Service is exposed (bootstrap + local routing).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub expose: Option<ServiceExposeView>,
150}
151
152/// Public / local routing for a bootstrap Service + post-deploy access.
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154pub struct ServiceExposeView {
155    /// Kubernetes Service type: `ClusterIP` (default), `NodePort`, or `LoadBalancer`.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub service_type: Option<String>,
158    /// Fixed NodePort (30000–32767) when `service_type = NodePort`.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub node_port: Option<u16>,
161    /// Optional hostPort on the Pod (useful for docker-desktop single-node).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub host_port: Option<u16>,
164    /// Local port for `kubectl port-forward` (default: container_port).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub local_port: Option<u16>,
167    /// Start `kubectl port-forward` after successful rollout (default true when local_port set, else false).
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub port_forward: Option<bool>,
170    /// Hostnames for local DNS (/etc/hosts or Windows hosts) → 127.0.0.1 after port-forward.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub dns_hosts: Vec<String>,
173    /// DNS write mode: `hosts` (default when dns_hosts set), `none`, or `cloudflare` (A record, best-effort).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub dns_mode: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct ServiceK8sView {
180    pub manifests_base: Option<String>,
181    pub manifests_overlay: Option<String>,
182    pub workload: Option<String>,
183    pub service: Option<String>,
184    pub crds_path: Option<String>,
185    pub install_path: Option<String>,
186    pub selector: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct DeployPlan {
191    pub target: DeployTarget,
192    pub env: String,
193    pub project: String,
194    pub project_version: String,
195    pub git_sha: Option<String>,
196    pub services: Vec<ServicePlan>,
197    /// Final apply order (service names).
198    pub order: Vec<String>,
199    pub oci_plan: OciPlan,
200    pub k8s_plan: K8sPlanView,
201    /// Deterministic hash of the plan content (excluding generated_at).
202    pub hash: String,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ServicePlan {
207    pub name: String,
208    pub provider: String,
209    /// User-facing destination (`cloudflare`, `kubernetes`, `railway`, …).
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub destination: Option<String>,
212    /// Service root relative to project (for docker build context).
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub root_directory: Option<String>,
215    pub version: String,
216    pub image: Option<OciRef>,
217    pub image_ref: Option<String>,
218    pub digest: Option<String>,
219    /// When set, runner builds/pushes this Dockerfile before apply (unless skip_build).
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub dockerfile: Option<String>,
222    /// Docker build context path (relative).
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub build_context: Option<String>,
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub platforms: Vec<String>,
227    /// Cloudflare worker app when provider is CF-backed.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub worker_app: Option<String>,
230    /// Cloudflare containers rollout hint.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub rollout: Option<String>,
233    /// Resolved runtime env for bootstrap / apply (secrets redacted in plan print).
234    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
235    pub runtime_env: BTreeMap<String, String>,
236    /// Container listen port used by bootstrap Service/Deployment.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub container_port: Option<u16>,
239    /// Config files to mount (absolute source path + container mount path).
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub config_mounts: Vec<ConfigFileMount>,
242    /// Public port / local port-forward / DNS after apply.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub expose: Option<ServiceExposeView>,
245    pub deploy: ServiceDeployPlan,
246}
247
248/// One file composed into the workload (ConfigMap mount for k8s bootstrap).
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ConfigFileMount {
251    /// Absolute path on the operator machine.
252    pub source: PathBuf,
253    /// Path inside the container (e.g. `/app/config.yaml`).
254    pub mount_path: String,
255    /// ConfigMap key (basename-safe).
256    pub key: String,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct ServiceDeployPlan {
261    pub namespace: Option<String>,
262    pub workload: Option<String>,
263    /// Kubernetes Service name (defaults to workload name when unset).
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub service: Option<String>,
266    pub health: Vec<String>,
267    pub manifest_paths: Vec<PathBuf>,
268    pub crds_path: Option<PathBuf>,
269    pub install_path: Option<PathBuf>,
270    pub selector: Option<String>,
271    pub actions: Vec<String>,
272}
273
274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct OciPlan {
276    pub images: BTreeMap<String, OciImagePlan>,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct OciImagePlan {
281    pub ref_str: String,
282    pub digest: Option<String>,
283}
284
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct K8sPlanView {
287    pub context: Option<String>,
288    pub default_namespace: Option<String>,
289    pub services: Vec<xbp_k8s::K8sServicePlan>,
290}
291
292impl DeployPlan {
293    pub fn render_human(&self) -> String {
294        let mut lines = vec![
295            format!(
296                "project={} version={} env={} target={} ({})",
297                self.project,
298                self.project_version,
299                self.env,
300                self.target.label(),
301                self.target.kind_str()
302            ),
303            format!("hash={}", self.hash),
304            format!("order: {}", self.order.join(" → ")),
305        ];
306        if let Some(sha) = &self.git_sha {
307            lines.push(format!("git_sha={sha}"));
308        }
309        for (idx, svc) in self.services.iter().enumerate() {
310            let dest = svc
311                .destination
312                .clone()
313                .unwrap_or_else(|| crate::providers::provider_to_destination(&svc.provider));
314            lines.push(format!(
315                "{}. {} → {} [{}] v{}",
316                idx + 1,
317                svc.name,
318                dest,
319                svc.provider,
320                svc.version
321            ));
322            if let Some(ns) = &svc.deploy.namespace {
323                lines.push(format!("   namespace: {ns}"));
324            }
325            if let Some(image) = &svc.image_ref {
326                if crate::providers::is_cloudflare_provider(&svc.provider) {
327                    // OCI image_ref is for k8s/GHCR; CF workers use OpenNext/Wrangler (or
328                    // CF container registry) — do not imply a GHCR build/push.
329                    lines.push(format!(
330                        "   image: {image} (config only — Cloudflare path does not GHCR-push)"
331                    ));
332                } else {
333                    lines.push(format!("   image: {image}"));
334                }
335            }
336            if let Some(d) = &svc.digest {
337                lines.push(format!("   digest: {d}"));
338            }
339            for a in &svc.deploy.actions {
340                lines.push(format!("   • {a}"));
341            }
342        }
343        lines.join("\n")
344    }
345
346    pub fn to_k8s_plan(&self) -> xbp_k8s::K8sPlan {
347        xbp_k8s::K8sPlan {
348            services: self.k8s_plan.services.clone(),
349        }
350    }
351}