1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3use std::path::PathBuf;
4use xbp_oci::OciRef;
5
6#[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#[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 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 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
71 pub environment: std::collections::BTreeMap<String, String>,
72 #[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 #[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 pub provider: String,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub worker: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub rollout: Option<String>,
104 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
112 pub destinations: HashMap<String, ServiceDeployDestinationView>,
113 pub envs: HashMap<String, ServiceDeployEnvView>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct ServiceDeployDestinationView {
119 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub expose: Option<ServiceExposeView>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, Default)]
154pub struct ServiceExposeView {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub service_type: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub node_port: Option<u16>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub host_port: Option<u16>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub local_port: Option<u16>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub port_forward: Option<bool>,
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
172 pub dns_hosts: Vec<String>,
173 #[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 pub order: Vec<String>,
199 pub oci_plan: OciPlan,
200 pub k8s_plan: K8sPlanView,
201 pub hash: String,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct ServicePlan {
207 pub name: String,
208 pub provider: String,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub destination: Option<String>,
212 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub dockerfile: Option<String>,
222 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub worker_app: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub rollout: Option<String>,
233 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
235 pub runtime_env: BTreeMap<String, String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub container_port: Option<u16>,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub config_mounts: Vec<ConfigFileMount>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub expose: Option<ServiceExposeView>,
245 pub deploy: ServiceDeployPlan,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ConfigFileMount {
251 pub source: PathBuf,
253 pub mount_path: String,
255 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 #[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 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}