Skip to main content

greentic_deployer/
spec.rs

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}