1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{DeployerError, Result};
7
8pub const DEPLOYMENT_SPEC_API_VERSION_V1ALPHA1: &str = "greentic.ai/v1alpha1";
9pub const DEPLOYMENT_SPEC_KIND: &str = "Deployment";
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct DeploymentSpecV1 {
13 #[serde(rename = "apiVersion")]
14 pub api_version: String,
15 pub kind: String,
16 pub metadata: DeploymentMetadata,
17 pub spec: DeploymentSpecBody,
18}
19
20impl DeploymentSpecV1 {
21 pub fn from_yaml_str(input: &str) -> Result<Self> {
22 let spec = serde_yaml_bw::from_str::<Self>(input).map_err(|err| {
23 DeployerError::Config(format!("failed to parse deployment spec as YAML: {err}"))
24 })?;
25 spec.validate()?;
26 Ok(spec)
27 }
28
29 pub fn from_json_str(input: &str) -> Result<Self> {
30 let spec = serde_json::from_str::<Self>(input).map_err(|err| {
31 DeployerError::Config(format!("failed to parse deployment spec as JSON: {err}"))
32 })?;
33 spec.validate()?;
34 Ok(spec)
35 }
36
37 pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
38 let path = path.as_ref();
39 let contents = fs::read_to_string(path).map_err(|err| {
40 DeployerError::Config(format!(
41 "failed to read deployment spec {}: {err}",
42 path.display()
43 ))
44 })?;
45
46 match path.extension().and_then(|ext| ext.to_str()) {
47 Some("json") => Self::from_json_str(&contents),
48 Some("yaml") | Some("yml") => Self::from_yaml_str(&contents),
49 _ => match Self::from_yaml_str(&contents) {
50 Ok(spec) => Ok(spec),
51 Err(yaml_err) => Self::from_json_str(&contents).map_err(|json_err| {
52 let yaml_err = yaml_err.to_string();
53 let json_err = json_err.to_string();
54 DeployerError::Config(format!(
55 "failed to parse deployment spec {} as YAML ({}) or JSON ({})",
56 path.display(),
57 yaml_err,
58 json_err
59 ))
60 }),
61 },
62 }
63 }
64
65 pub fn validate(&self) -> Result<()> {
66 if self.api_version != DEPLOYMENT_SPEC_API_VERSION_V1ALPHA1 {
67 return Err(DeployerError::Config(format!(
68 "unsupported deployment spec apiVersion {}; expected {}",
69 self.api_version, DEPLOYMENT_SPEC_API_VERSION_V1ALPHA1
70 )));
71 }
72
73 if self.kind != DEPLOYMENT_SPEC_KIND {
74 return Err(DeployerError::Config(format!(
75 "unsupported deployment spec kind {}; expected {}",
76 self.kind, DEPLOYMENT_SPEC_KIND
77 )));
78 }
79
80 if self.metadata.name.trim().is_empty() {
81 return Err(DeployerError::Config(
82 "deployment metadata.name must not be empty".to_string(),
83 ));
84 }
85
86 if self.spec.runtime.arch != LinuxArch::X86_64 {
87 return Err(DeployerError::Config(format!(
88 "runtime.arch must be x86_64 for OSS single-vm v1; got {:?}",
89 self.spec.runtime.arch
90 )));
91 }
92
93 if self.spec.runtime.admin.mtls.ca_file.as_os_str().is_empty()
94 || self
95 .spec
96 .runtime
97 .admin
98 .mtls
99 .cert_file
100 .as_os_str()
101 .is_empty()
102 || self.spec.runtime.admin.mtls.key_file.as_os_str().is_empty()
103 {
104 return Err(DeployerError::Config(
105 "runtime.admin.mtls caFile/certFile/keyFile must all be set".to_string(),
106 ));
107 }
108
109 let bind = self.spec.runtime.admin.bind.trim();
110 if bind.is_empty() {
111 return Err(DeployerError::Config(
112 "runtime.admin.bind must not be empty".to_string(),
113 ));
114 }
115 if bind == "0.0.0.0:8433" {
116 return Err(DeployerError::Config(
117 "runtime.admin.bind must stay on localhost, never 0.0.0.0:8433".to_string(),
118 ));
119 }
120 if !(bind == "127.0.0.1:8433" || bind == "localhost:8433" || bind == "[::1]:8433") {
121 return Err(DeployerError::Config(format!(
122 "runtime.admin.bind must be localhost on port 8433; got {bind}"
123 )));
124 }
125
126 Ok(())
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct DeploymentMetadata {
132 pub name: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
136pub struct DeploymentSpecBody {
137 pub target: DeploymentTarget,
138 pub bundle: BundleSpec,
139 pub runtime: RuntimeSpec,
140 pub storage: StorageSpec,
141 pub service: ServiceSpec,
142 pub health: HealthSpec,
143 pub rollout: RolloutSpec,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "kebab-case")]
148pub enum DeploymentTarget {
149 SingleVm,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153pub struct BundleSpec {
154 pub source: String,
155 pub format: BundleFormat,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "lowercase")]
160pub enum BundleFormat {
161 Squashfs,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct RuntimeSpec {
166 pub image: String,
167 pub arch: LinuxArch,
168 pub admin: AdminEndpointSpec,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(rename_all = "snake_case")]
173pub enum LinuxArch {
174 X86_64,
175 Aarch64,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
179pub struct AdminEndpointSpec {
180 pub bind: String,
181 pub mtls: MtlsSpec,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct MtlsSpec {
186 #[serde(rename = "caFile")]
187 pub ca_file: PathBuf,
188 #[serde(rename = "certFile")]
189 pub cert_file: PathBuf,
190 #[serde(rename = "keyFile")]
191 pub key_file: PathBuf,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
195pub struct StorageSpec {
196 #[serde(rename = "stateDir")]
197 pub state_dir: PathBuf,
198 #[serde(rename = "cacheDir")]
199 pub cache_dir: PathBuf,
200 #[serde(rename = "logDir")]
201 pub log_dir: PathBuf,
202 #[serde(rename = "tempDir")]
203 pub temp_dir: PathBuf,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
207pub struct ServiceSpec {
208 pub manager: ServiceManager,
209 pub user: String,
210 pub group: String,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214#[serde(rename_all = "lowercase")]
215pub enum ServiceManager {
216 Systemd,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct HealthSpec {
221 #[serde(rename = "readinessPath")]
222 pub readiness_path: String,
223 #[serde(rename = "livenessPath")]
224 pub liveness_path: String,
225 #[serde(rename = "startupTimeoutSeconds")]
226 pub startup_timeout_seconds: u64,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
230pub struct RolloutSpec {
231 pub strategy: RolloutStrategy,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235#[serde(rename_all = "lowercase")]
236pub enum RolloutStrategy {
237 Recreate,
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn deployment_spec_v1_accepts_single_vm_linux_localhost_mtls() {
246 let spec = DeploymentSpecV1::from_yaml_str(
247 r#"
248apiVersion: greentic.ai/v1alpha1
249kind: Deployment
250metadata:
251 name: acme-prod
252spec:
253 target: single-vm
254 bundle:
255 source: file:///opt/greentic/bundles/acme.squashfs
256 format: squashfs
257 runtime:
258 image: "ghcr.io/greentic-ai/operator-distroless:0.1.0-distroless"
259 arch: x86_64
260 admin:
261 bind: 127.0.0.1:8433
262 mtls:
263 caFile: /etc/greentic/admin/ca.crt
264 certFile: /etc/greentic/admin/server.crt
265 keyFile: /etc/greentic/admin/server.key
266 storage:
267 stateDir: /var/lib/greentic/state
268 cacheDir: /var/lib/greentic/cache
269 logDir: /var/log/greentic
270 tempDir: /var/lib/greentic/tmp
271 service:
272 manager: systemd
273 user: greentic
274 group: greentic
275 health:
276 readinessPath: /ready
277 livenessPath: /health
278 startupTimeoutSeconds: 120
279 rollout:
280 strategy: recreate
281"#,
282 )
283 .expect("parse spec");
284
285 assert_eq!(spec.spec.target, DeploymentTarget::SingleVm);
286 assert_eq!(spec.spec.runtime.arch, LinuxArch::X86_64);
287 }
288
289 #[test]
290 fn deployment_spec_v1_loads_json_from_path() {
291 let dir = tempfile::tempdir().expect("tempdir");
292 let path = dir.path().join("deployment.json");
293 std::fs::write(
294 &path,
295 r#"{
296 "apiVersion": "greentic.ai/v1alpha1",
297 "kind": "Deployment",
298 "metadata": { "name": "acme-prod" },
299 "spec": {
300 "target": "single-vm",
301 "bundle": {
302 "source": "file:///opt/greentic/bundles/acme.squashfs",
303 "format": "squashfs"
304 },
305 "runtime": {
306 "image": "ghcr.io/greentic-ai/operator-distroless:0.1.0-distroless",
307 "arch": "x86_64",
308 "admin": {
309 "bind": "127.0.0.1:8433",
310 "mtls": {
311 "caFile": "/etc/greentic/admin/ca.crt",
312 "certFile": "/etc/greentic/admin/server.crt",
313 "keyFile": "/etc/greentic/admin/server.key"
314 }
315 }
316 },
317 "storage": {
318 "stateDir": "/var/lib/greentic/state",
319 "cacheDir": "/var/lib/greentic/cache",
320 "logDir": "/var/log/greentic",
321 "tempDir": "/var/lib/greentic/tmp"
322 },
323 "service": {
324 "manager": "systemd",
325 "user": "greentic",
326 "group": "greentic"
327 },
328 "health": {
329 "readinessPath": "/ready",
330 "livenessPath": "/health",
331 "startupTimeoutSeconds": 120
332 },
333 "rollout": {
334 "strategy": "recreate"
335 }
336 }
337}"#,
338 )
339 .expect("write json");
340
341 let spec = DeploymentSpecV1::from_path(&path).expect("load spec");
342 assert_eq!(spec.spec.runtime.arch, LinuxArch::X86_64);
343 }
344
345 #[test]
346 fn deployment_spec_v1_rejects_non_localhost_admin_bind() {
347 let spec = DeploymentSpecV1 {
348 api_version: DEPLOYMENT_SPEC_API_VERSION_V1ALPHA1.to_string(),
349 kind: DEPLOYMENT_SPEC_KIND.to_string(),
350 metadata: DeploymentMetadata {
351 name: "acme-prod".to_string(),
352 },
353 spec: DeploymentSpecBody {
354 target: DeploymentTarget::SingleVm,
355 bundle: BundleSpec {
356 source: "file:///tmp/demo.squashfs".to_string(),
357 format: BundleFormat::Squashfs,
358 },
359 runtime: RuntimeSpec {
360 image: "ghcr.io/greentic-ai/operator-distroless:0.1.0-distroless".to_string(),
361 arch: LinuxArch::X86_64,
362 admin: AdminEndpointSpec {
363 bind: "0.0.0.0:8433".to_string(),
364 mtls: MtlsSpec {
365 ca_file: "/etc/greentic/admin/ca.crt".into(),
366 cert_file: "/etc/greentic/admin/server.crt".into(),
367 key_file: "/etc/greentic/admin/server.key".into(),
368 },
369 },
370 },
371 storage: StorageSpec {
372 state_dir: "/var/lib/greentic/state".into(),
373 cache_dir: "/var/lib/greentic/cache".into(),
374 log_dir: "/var/log/greentic".into(),
375 temp_dir: "/var/lib/greentic/tmp".into(),
376 },
377 service: ServiceSpec {
378 manager: ServiceManager::Systemd,
379 user: "greentic".to_string(),
380 group: "greentic".to_string(),
381 },
382 health: HealthSpec {
383 readiness_path: "/ready".to_string(),
384 liveness_path: "/health".to_string(),
385 startup_timeout_seconds: 120,
386 },
387 rollout: RolloutSpec {
388 strategy: RolloutStrategy::Recreate,
389 },
390 },
391 };
392
393 let err = spec.validate().expect_err("bind must be rejected");
394 assert!(err.to_string().contains("localhost"));
395 }
396
397 #[test]
398 fn deployment_spec_v1_rejects_non_x86_64_arch() {
399 let mut spec = DeploymentSpecV1::from_yaml_str(
400 r#"
401apiVersion: greentic.ai/v1alpha1
402kind: Deployment
403metadata:
404 name: acme-prod
405spec:
406 target: single-vm
407 bundle:
408 source: file:///opt/greentic/bundles/acme.squashfs
409 format: squashfs
410 runtime:
411 image: "ghcr.io/greentic-ai/operator-distroless:0.1.0-distroless"
412 arch: x86_64
413 admin:
414 bind: 127.0.0.1:8433
415 mtls:
416 caFile: /etc/greentic/admin/ca.crt
417 certFile: /etc/greentic/admin/server.crt
418 keyFile: /etc/greentic/admin/server.key
419 storage:
420 stateDir: /var/lib/greentic/state
421 cacheDir: /var/lib/greentic/cache
422 logDir: /var/log/greentic
423 tempDir: /var/lib/greentic/tmp
424 service:
425 manager: systemd
426 user: greentic
427 group: greentic
428 health:
429 readinessPath: /ready
430 livenessPath: /health
431 startupTimeoutSeconds: 120
432 rollout:
433 strategy: recreate
434"#,
435 )
436 .expect("parse spec");
437 spec.spec.runtime.arch = LinuxArch::Aarch64;
438
439 let err = spec.validate().expect_err("arch must be rejected");
440 assert!(err.to_string().contains("x86_64"));
441 }
442}