Skip to main content

greentic_deployer/
single_vm.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::OutputFormat;
8use crate::error::{DeployerError, Result};
9use crate::spec::{
10    AdminEndpointSpec, BundleFormat, BundleSpec, DEPLOYMENT_SPEC_API_VERSION_V1ALPHA1,
11    DEPLOYMENT_SPEC_KIND, DeploymentMetadata, DeploymentSpecBody, DeploymentSpecV1,
12    DeploymentTarget, HealthSpec, LinuxArch, MtlsSpec, RolloutSpec, RolloutStrategy, RuntimeSpec,
13    ServiceManager, ServiceSpec, StorageSpec,
14};
15
16const DEFAULT_RUNTIME_SERVICE_NAME: &str = "greentic-runtime";
17const DEFAULT_BUNDLE_MOUNT_ROOT: &str = "/mnt/greentic/bundles";
18const DEFAULT_ENV_FILE_ROOT: &str = "/etc/greentic";
19const DEFAULT_STATE_FILE_NAME: &str = "single-vm-state.json";
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct SingleVmPlan {
23    pub deployment_name: String,
24    pub service_name: String,
25    pub arch: LinuxArch,
26    pub runtime: SingleVmRuntimePlan,
27    pub bundle: SingleVmBundlePlan,
28    pub storage: SingleVmStoragePlan,
29    pub admin: SingleVmAdminPlan,
30    pub service: SingleVmServicePlan,
31    pub health: SingleVmHealthPlan,
32    pub rollout: SingleVmRolloutPlan,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct SingleVmRuntimePlan {
37    pub image: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct SingleVmBundlePlan {
42    pub source: String,
43    pub format: BundleFormat,
44    pub read_only: bool,
45    pub mount_path: PathBuf,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct SingleVmStoragePlan {
50    pub state_dir: PathBuf,
51    pub cache_dir: PathBuf,
52    pub log_dir: PathBuf,
53    pub temp_dir: PathBuf,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct SingleVmAdminPlan {
58    pub bind: String,
59    pub ca_file: PathBuf,
60    pub cert_file: PathBuf,
61    pub key_file: PathBuf,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct SingleVmServicePlan {
66    pub manager: ServiceManager,
67    pub user: String,
68    pub group: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct SingleVmHealthPlan {
73    pub readiness_path: String,
74    pub liveness_path: String,
75    pub startup_timeout_seconds: u64,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct SingleVmRolloutPlan {
80    pub strategy: RolloutStrategy,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84pub struct SingleVmPlanOutput {
85    pub plan: SingleVmPlan,
86    pub service_unit_name: String,
87    pub env_file_path: PathBuf,
88    pub directories: Vec<PathBuf>,
89    pub files: Vec<SingleVmPlannedFile>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct SingleVmPlannedFile {
94    pub path: PathBuf,
95    pub kind: SingleVmPlannedFileKind,
96    pub contents: String,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(rename_all = "snake_case")]
101pub enum SingleVmPlannedFileKind {
102    SystemdUnit,
103    EnvironmentFile,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct SingleVmApplyReport {
108    pub directories_created: Vec<PathBuf>,
109    pub files_written: Vec<PathBuf>,
110    pub commands_run: Vec<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct SingleVmDestroyReport {
115    pub files_removed: Vec<PathBuf>,
116    pub directories_removed: Vec<PathBuf>,
117    pub commands_run: Vec<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct SingleVmPersistedState {
122    pub deployment_name: String,
123    pub service_unit_name: String,
124    pub runtime_image: String,
125    pub bundle_source: String,
126    pub admin_bind: String,
127    pub last_action: SingleVmLastAction,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "snake_case")]
132pub enum SingleVmLastAction {
133    Apply,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct SingleVmStatusReport {
138    pub state_path: PathBuf,
139    pub status: SingleVmDeploymentStatus,
140    pub service_unit_name: String,
141    pub service_unit_path: PathBuf,
142    pub env_file_path: PathBuf,
143    pub state_exists: bool,
144    pub bundle_mount_exists: bool,
145    pub present_directories: Vec<PathBuf>,
146    pub missing_directories: Vec<PathBuf>,
147    pub present_files: Vec<PathBuf>,
148    pub missing_files: Vec<PathBuf>,
149    pub state: Option<SingleVmPersistedState>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum SingleVmDeploymentStatus {
155    NotInstalled,
156    Partial,
157    Applied,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
161#[serde(rename_all = "camelCase")]
162pub struct SingleVmApplyOptions {
163    pub pull_image: bool,
164    pub daemon_reload: bool,
165    pub enable_service: bool,
166    pub restart_service: bool,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
170#[serde(rename_all = "camelCase")]
171pub struct SingleVmDestroyOptions {
172    pub stop_service: bool,
173    pub disable_service: bool,
174}
175
176/// Extension-contributed config shape for single-vm apply/destroy via
177/// `ext::backend_adapter`. Extensions declare a matching JSON schema in their
178/// `config-schema`; this struct is the Rust-side view.
179#[derive(Debug, Clone, Deserialize)]
180#[serde(rename_all = "camelCase")]
181pub struct SingleVmExtConfig {
182    pub spec_path: PathBuf,
183    #[serde(default)]
184    pub apply_options: SingleVmApplyOptions,
185    #[serde(default)]
186    pub destroy_options: SingleVmDestroyOptions,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct SingleVmRenderSpecRequest {
191    pub out: PathBuf,
192    pub name: String,
193    pub bundle_source: String,
194    pub state_dir: PathBuf,
195    pub cache_dir: PathBuf,
196    pub log_dir: PathBuf,
197    pub temp_dir: PathBuf,
198    pub admin_bind: String,
199    pub admin_ca_file: PathBuf,
200    pub admin_cert_file: PathBuf,
201    pub admin_key_file: PathBuf,
202    pub image: String,
203}
204
205pub fn write_single_vm_spec(args: &SingleVmRenderSpecRequest) -> Result<()> {
206    let spec = DeploymentSpecV1 {
207        api_version: DEPLOYMENT_SPEC_API_VERSION_V1ALPHA1.to_string(),
208        kind: DEPLOYMENT_SPEC_KIND.to_string(),
209        metadata: DeploymentMetadata {
210            name: args.name.clone(),
211        },
212        spec: DeploymentSpecBody {
213            target: DeploymentTarget::SingleVm,
214            bundle: BundleSpec {
215                source: args.bundle_source.clone(),
216                format: BundleFormat::Squashfs,
217            },
218            runtime: RuntimeSpec {
219                image: args.image.clone(),
220                arch: LinuxArch::X86_64,
221                admin: AdminEndpointSpec {
222                    bind: args.admin_bind.clone(),
223                    mtls: MtlsSpec {
224                        ca_file: args.admin_ca_file.clone(),
225                        cert_file: args.admin_cert_file.clone(),
226                        key_file: args.admin_key_file.clone(),
227                    },
228                },
229            },
230            storage: StorageSpec {
231                state_dir: args.state_dir.clone(),
232                cache_dir: args.cache_dir.clone(),
233                log_dir: args.log_dir.clone(),
234                temp_dir: args.temp_dir.clone(),
235            },
236            service: ServiceSpec {
237                manager: ServiceManager::Systemd,
238                user: "greentic".to_string(),
239                group: "greentic".to_string(),
240            },
241            health: HealthSpec {
242                readiness_path: "/ready".to_string(),
243                liveness_path: "/health".to_string(),
244                startup_timeout_seconds: 120,
245            },
246            rollout: RolloutSpec {
247                strategy: RolloutStrategy::Recreate,
248            },
249        },
250    };
251    spec.validate()?;
252    if let Some(parent) = args.out.parent()
253        && !parent.as_os_str().is_empty()
254    {
255        fs::create_dir_all(parent)?;
256    }
257    let spec_yaml = serde_yaml_bw::to_string(&spec).map_err(|err| {
258        DeployerError::Other(format!("failed to serialize single-vm spec: {err}"))
259    })?;
260    fs::write(&args.out, spec_yaml)?;
261    Ok(())
262}
263
264pub fn build_single_vm_plan(spec: &DeploymentSpecV1) -> Result<SingleVmPlan> {
265    spec.validate()?;
266
267    if spec.spec.target != DeploymentTarget::SingleVm {
268        return Err(DeployerError::Config(format!(
269            "single-vm planner does not support target {:?}",
270            spec.spec.target
271        )));
272    }
273
274    if spec.metadata.name.contains('/') || spec.metadata.name.contains('\\') {
275        return Err(DeployerError::Config(
276            "deployment metadata.name must not contain path separators".to_string(),
277        ));
278    }
279
280    let service_name = sanitize_service_name(&spec.metadata.name);
281    let mount_path = PathBuf::from(DEFAULT_BUNDLE_MOUNT_ROOT).join(&spec.metadata.name);
282
283    Ok(SingleVmPlan {
284        deployment_name: spec.metadata.name.clone(),
285        service_name,
286        arch: spec.spec.runtime.arch.clone(),
287        runtime: SingleVmRuntimePlan {
288            image: spec.spec.runtime.image.clone(),
289        },
290        bundle: SingleVmBundlePlan {
291            source: spec.spec.bundle.source.clone(),
292            format: spec.spec.bundle.format.clone(),
293            read_only: true,
294            mount_path,
295        },
296        storage: SingleVmStoragePlan {
297            state_dir: spec.spec.storage.state_dir.clone(),
298            cache_dir: spec.spec.storage.cache_dir.clone(),
299            log_dir: spec.spec.storage.log_dir.clone(),
300            temp_dir: spec.spec.storage.temp_dir.clone(),
301        },
302        admin: SingleVmAdminPlan {
303            bind: spec.spec.runtime.admin.bind.clone(),
304            ca_file: spec.spec.runtime.admin.mtls.ca_file.clone(),
305            cert_file: spec.spec.runtime.admin.mtls.cert_file.clone(),
306            key_file: spec.spec.runtime.admin.mtls.key_file.clone(),
307        },
308        service: SingleVmServicePlan {
309            manager: spec.spec.service.manager.clone(),
310            user: spec.spec.service.user.clone(),
311            group: spec.spec.service.group.clone(),
312        },
313        health: SingleVmHealthPlan {
314            readiness_path: spec.spec.health.readiness_path.clone(),
315            liveness_path: spec.spec.health.liveness_path.clone(),
316            startup_timeout_seconds: spec.spec.health.startup_timeout_seconds,
317        },
318        rollout: SingleVmRolloutPlan {
319            strategy: spec.spec.rollout.strategy.clone(),
320        },
321    })
322}
323
324pub fn plan_single_vm_spec(spec: &DeploymentSpecV1) -> Result<SingleVmPlanOutput> {
325    let plan = build_single_vm_plan(spec)?;
326    Ok(render_single_vm_plan(&plan))
327}
328
329pub fn plan_single_vm_spec_path(path: impl AsRef<std::path::Path>) -> Result<SingleVmPlanOutput> {
330    let spec = DeploymentSpecV1::from_path(path)?;
331    plan_single_vm_spec(&spec)
332}
333
334pub fn render_single_vm_plan(plan: &SingleVmPlan) -> SingleVmPlanOutput {
335    let service_unit_name = format!("{}.service", plan.service_name);
336    let env_file_path =
337        PathBuf::from(DEFAULT_ENV_FILE_ROOT).join(format!("{}.env", plan.service_name));
338    let service_unit_path = PathBuf::from("/etc/systemd/system").join(&service_unit_name);
339
340    let directories = vec![
341        plan.storage.state_dir.clone(),
342        plan.storage.cache_dir.clone(),
343        plan.storage.log_dir.clone(),
344        plan.storage.temp_dir.clone(),
345        plan.bundle.mount_path.clone(),
346        env_file_path
347            .parent()
348            .unwrap_or_else(|| std::path::Path::new(DEFAULT_ENV_FILE_ROOT))
349            .to_path_buf(),
350    ];
351
352    let files = vec![
353        SingleVmPlannedFile {
354            path: service_unit_path,
355            kind: SingleVmPlannedFileKind::SystemdUnit,
356            contents: render_systemd_unit(plan, &env_file_path),
357        },
358        SingleVmPlannedFile {
359            path: env_file_path.clone(),
360            kind: SingleVmPlannedFileKind::EnvironmentFile,
361            contents: render_env_file(plan),
362        },
363    ];
364
365    SingleVmPlanOutput {
366        plan: plan.clone(),
367        service_unit_name,
368        env_file_path,
369        directories,
370        files,
371    }
372}
373
374pub fn apply_single_vm_plan_output(output: &SingleVmPlanOutput) -> Result<SingleVmApplyReport> {
375    apply_single_vm_plan_output_with_options(output, &SingleVmApplyOptions::default())
376}
377
378pub fn preview_single_vm_apply_plan_output(output: &SingleVmPlanOutput) -> SingleVmApplyReport {
379    SingleVmApplyReport {
380        directories_created: output.directories.clone(),
381        files_written: output.files.iter().map(|file| file.path.clone()).collect(),
382        commands_run: Vec::new(),
383    }
384}
385
386pub fn apply_single_vm_plan_output_with_options(
387    output: &SingleVmPlanOutput,
388    options: &SingleVmApplyOptions,
389) -> Result<SingleVmApplyReport> {
390    let mut directories_created = Vec::new();
391    let mut files_written = Vec::new();
392    let mut commands_run = Vec::new();
393
394    for dir in &output.directories {
395        fs::create_dir_all(dir).map_err(|err| {
396            DeployerError::Io(std::io::Error::new(
397                err.kind(),
398                format!("failed to create directory {}: {err}", dir.display()),
399            ))
400        })?;
401        directories_created.push(dir.clone());
402    }
403
404    for file in &output.files {
405        if let Some(parent) = file.path.parent() {
406            fs::create_dir_all(parent).map_err(|err| {
407                DeployerError::Io(std::io::Error::new(
408                    err.kind(),
409                    format!(
410                        "failed to create parent directory {} for {}: {err}",
411                        parent.display(),
412                        file.path.display()
413                    ),
414                ))
415            })?;
416        }
417        fs::write(&file.path, &file.contents).map_err(|err| {
418            DeployerError::Io(std::io::Error::new(
419                err.kind(),
420                format!("failed to write {}: {err}", file.path.display()),
421            ))
422        })?;
423        files_written.push(file.path.clone());
424    }
425
426    if options.pull_image {
427        run_command(
428            &mut commands_run,
429            "docker",
430            &["pull", output.plan.runtime.image.as_str()],
431        )?;
432    }
433    if options.daemon_reload {
434        run_command(&mut commands_run, "systemctl", &["daemon-reload"])?;
435    }
436    if options.enable_service {
437        run_command(
438            &mut commands_run,
439            "systemctl",
440            &["enable", output.service_unit_name.as_str()],
441        )?;
442    }
443    if options.restart_service {
444        run_command(
445            &mut commands_run,
446            "systemctl",
447            &["restart", output.service_unit_name.as_str()],
448        )?;
449    }
450
451    write_single_vm_state(output)?;
452
453    Ok(SingleVmApplyReport {
454        directories_created,
455        files_written,
456        commands_run,
457    })
458}
459
460pub fn apply_single_vm_spec(spec: &DeploymentSpecV1) -> Result<SingleVmApplyReport> {
461    let output = plan_single_vm_spec(spec)?;
462    apply_single_vm_plan_output(&output)
463}
464
465pub fn apply_single_vm_spec_path(path: impl AsRef<Path>) -> Result<SingleVmApplyReport> {
466    let output = plan_single_vm_spec_path(path)?;
467    apply_single_vm_plan_output(&output)
468}
469
470pub fn destroy_single_vm_plan_output(output: &SingleVmPlanOutput) -> Result<SingleVmDestroyReport> {
471    destroy_single_vm_plan_output_with_options(output, &SingleVmDestroyOptions::default())
472}
473
474pub fn preview_single_vm_destroy_plan_output(output: &SingleVmPlanOutput) -> SingleVmDestroyReport {
475    SingleVmDestroyReport {
476        files_removed: output.files.iter().map(|file| file.path.clone()).collect(),
477        directories_removed: output.directories.clone(),
478        commands_run: Vec::new(),
479    }
480}
481
482pub fn destroy_single_vm_plan_output_with_options(
483    output: &SingleVmPlanOutput,
484    options: &SingleVmDestroyOptions,
485) -> Result<SingleVmDestroyReport> {
486    let mut files_removed = Vec::new();
487    let mut directories_removed = Vec::new();
488    let mut commands_run = Vec::new();
489
490    if options.stop_service {
491        run_command(
492            &mut commands_run,
493            "systemctl",
494            &["stop", output.service_unit_name.as_str()],
495        )?;
496    }
497    if options.disable_service {
498        run_command(
499            &mut commands_run,
500            "systemctl",
501            &["disable", output.service_unit_name.as_str()],
502        )?;
503    }
504
505    let state_path = single_vm_state_path(&output.plan);
506    if state_path.exists() {
507        fs::remove_file(&state_path).map_err(|err| {
508            DeployerError::Io(std::io::Error::new(
509                err.kind(),
510                format!("failed to remove {}: {err}", state_path.display()),
511            ))
512        })?;
513        files_removed.push(state_path);
514    }
515
516    for file in &output.files {
517        if file.path.exists() {
518            fs::remove_file(&file.path).map_err(|err| {
519                DeployerError::Io(std::io::Error::new(
520                    err.kind(),
521                    format!("failed to remove {}: {err}", file.path.display()),
522                ))
523            })?;
524            files_removed.push(file.path.clone());
525        }
526    }
527
528    let mut dirs = output.directories.clone();
529    dirs.sort_by_key(|path| std::cmp::Reverse(path.components().count()));
530    dirs.dedup();
531    for dir in dirs {
532        if dir.exists() && is_directory_empty(&dir)? {
533            fs::remove_dir(&dir).map_err(|err| {
534                DeployerError::Io(std::io::Error::new(
535                    err.kind(),
536                    format!("failed to remove directory {}: {err}", dir.display()),
537                ))
538            })?;
539            directories_removed.push(dir);
540        }
541    }
542
543    Ok(SingleVmDestroyReport {
544        files_removed,
545        directories_removed,
546        commands_run,
547    })
548}
549
550pub fn destroy_single_vm_spec(spec: &DeploymentSpecV1) -> Result<SingleVmDestroyReport> {
551    let output = plan_single_vm_spec(spec)?;
552    destroy_single_vm_plan_output(&output)
553}
554
555pub fn destroy_single_vm_spec_path(path: impl AsRef<Path>) -> Result<SingleVmDestroyReport> {
556    let output = plan_single_vm_spec_path(path)?;
557    destroy_single_vm_plan_output(&output)
558}
559
560/// Extension-driven apply: parse JSON config, load spec path, call existing
561/// `apply_single_vm_plan_output_with_options`. `pack_path` reserved for future
562/// use (cloud refs); ignored by single-vm.
563pub fn apply_from_ext(
564    config_json: &str,
565    _creds_json: &str,
566    _pack_path: Option<&std::path::Path>,
567) -> Result<()> {
568    let cfg: SingleVmExtConfig = serde_json::from_str(config_json)
569        .map_err(|e| DeployerError::Other(format!("parse single-vm config JSON: {e}")))?;
570    let plan = plan_single_vm_spec_path(&cfg.spec_path)
571        .map_err(|e| DeployerError::Other(format!("plan single-vm: {e}")))?;
572    let _report = apply_single_vm_plan_output_with_options(&plan, &cfg.apply_options)?;
573    Ok(())
574}
575
576/// Extension-driven destroy: parse JSON config, load spec path, call existing
577/// `destroy_single_vm_plan_output_with_options`.
578pub fn destroy_from_ext(config_json: &str, _creds_json: &str) -> Result<()> {
579    let cfg: SingleVmExtConfig = serde_json::from_str(config_json)
580        .map_err(|e| DeployerError::Other(format!("parse single-vm config JSON: {e}")))?;
581    let plan = plan_single_vm_spec_path(&cfg.spec_path)
582        .map_err(|e| DeployerError::Other(format!("plan single-vm: {e}")))?;
583    let _report = destroy_single_vm_plan_output_with_options(&plan, &cfg.destroy_options)?;
584    Ok(())
585}
586
587pub fn status_single_vm_plan_output(output: &SingleVmPlanOutput) -> Result<SingleVmStatusReport> {
588    let state_path = single_vm_state_path(&output.plan);
589    let state = load_single_vm_state(&state_path)?;
590
591    let mut present_directories = Vec::new();
592    let mut missing_directories = Vec::new();
593    for dir in &output.directories {
594        if dir.exists() {
595            present_directories.push(dir.clone());
596        } else {
597            missing_directories.push(dir.clone());
598        }
599    }
600
601    let mut present_files = Vec::new();
602    let mut missing_files = Vec::new();
603    for file in &output.files {
604        if file.path.exists() {
605            present_files.push(file.path.clone());
606        } else {
607            missing_files.push(file.path.clone());
608        }
609    }
610
611    let bundle_mount_exists = output.plan.bundle.mount_path.exists();
612    let status = if state.is_some() && missing_files.is_empty() {
613        SingleVmDeploymentStatus::Applied
614    } else if state.is_none()
615        && missing_files.len() == output.files.len()
616        && missing_directories.len() == output.directories.len()
617        && !bundle_mount_exists
618    {
619        SingleVmDeploymentStatus::NotInstalled
620    } else {
621        SingleVmDeploymentStatus::Partial
622    };
623
624    Ok(SingleVmStatusReport {
625        state_path,
626        status,
627        service_unit_name: output.service_unit_name.clone(),
628        service_unit_path: output
629            .files
630            .iter()
631            .find(|file| matches!(file.kind, SingleVmPlannedFileKind::SystemdUnit))
632            .map(|file| file.path.clone())
633            .unwrap_or_else(|| {
634                PathBuf::from("/etc/systemd/system").join(&output.service_unit_name)
635            }),
636        env_file_path: output.env_file_path.clone(),
637        state_exists: state.is_some(),
638        bundle_mount_exists,
639        present_directories,
640        missing_directories,
641        present_files,
642        missing_files,
643        state,
644    })
645}
646
647pub fn status_single_vm_spec(spec: &DeploymentSpecV1) -> Result<SingleVmStatusReport> {
648    let output = plan_single_vm_spec(spec)?;
649    status_single_vm_plan_output(&output)
650}
651
652pub fn status_single_vm_spec_path(path: impl AsRef<Path>) -> Result<SingleVmStatusReport> {
653    let output = plan_single_vm_spec_path(path)?;
654    status_single_vm_plan_output(&output)
655}
656
657pub fn render_single_vm_plan_output(
658    output: &SingleVmPlanOutput,
659    format: OutputFormat,
660) -> Result<String> {
661    match format {
662        OutputFormat::Json => serde_json::to_string_pretty(output).map_err(|err| {
663            DeployerError::Other(format!("failed to render single-vm plan as JSON: {err}"))
664        }),
665        OutputFormat::Yaml => serde_yaml_bw::to_string(output).map_err(|err| {
666            DeployerError::Other(format!("failed to render single-vm plan as YAML: {err}"))
667        }),
668        OutputFormat::Text => Ok(render_single_vm_plan_output_text(output)),
669    }
670}
671
672pub fn render_single_vm_apply_report(
673    report: &SingleVmApplyReport,
674    format: OutputFormat,
675) -> Result<String> {
676    match format {
677        OutputFormat::Json => serde_json::to_string_pretty(report).map_err(|err| {
678            DeployerError::Other(format!(
679                "failed to render single-vm apply report as JSON: {err}"
680            ))
681        }),
682        OutputFormat::Yaml => serde_yaml_bw::to_string(report).map_err(|err| {
683            DeployerError::Other(format!(
684                "failed to render single-vm apply report as YAML: {err}"
685            ))
686        }),
687        OutputFormat::Text => Ok(render_single_vm_apply_report_text(report)),
688    }
689}
690
691pub fn render_single_vm_destroy_report(
692    report: &SingleVmDestroyReport,
693    format: OutputFormat,
694) -> Result<String> {
695    match format {
696        OutputFormat::Json => serde_json::to_string_pretty(report).map_err(|err| {
697            DeployerError::Other(format!(
698                "failed to render single-vm destroy report as JSON: {err}"
699            ))
700        }),
701        OutputFormat::Yaml => serde_yaml_bw::to_string(report).map_err(|err| {
702            DeployerError::Other(format!(
703                "failed to render single-vm destroy report as YAML: {err}"
704            ))
705        }),
706        OutputFormat::Text => Ok(render_single_vm_destroy_report_text(report)),
707    }
708}
709
710pub fn render_single_vm_status_report(
711    report: &SingleVmStatusReport,
712    format: OutputFormat,
713) -> Result<String> {
714    match format {
715        OutputFormat::Json => serde_json::to_string_pretty(report).map_err(|err| {
716            DeployerError::Other(format!(
717                "failed to render single-vm status report as JSON: {err}"
718            ))
719        }),
720        OutputFormat::Yaml => serde_yaml_bw::to_string(report).map_err(|err| {
721            DeployerError::Other(format!(
722                "failed to render single-vm status report as YAML: {err}"
723            ))
724        }),
725        OutputFormat::Text => Ok(render_single_vm_status_report_text(report)),
726    }
727}
728
729fn render_single_vm_plan_output_text(output: &SingleVmPlanOutput) -> String {
730    let mut lines = vec![
731        format!("deployment: {}", output.plan.deployment_name),
732        format!("service: {}", output.service_unit_name),
733        format!("image: {}", output.plan.runtime.image),
734        format!("arch: {:?}", output.plan.arch),
735        format!("bundle source: {}", output.plan.bundle.source),
736        format!("bundle mount: {}", output.plan.bundle.mount_path.display()),
737        format!("admin bind: {}", output.plan.admin.bind),
738        "directories:".to_string(),
739    ];
740    for dir in &output.directories {
741        lines.push(format!("  - {}", dir.display()));
742    }
743    lines.push("files:".to_string());
744    for file in &output.files {
745        lines.push(format!("  - {:?}: {}", file.kind, file.path.display()));
746    }
747    lines.join("\n")
748}
749
750fn render_single_vm_apply_report_text(report: &SingleVmApplyReport) -> String {
751    let mut lines = vec![
752        "apply report:".to_string(),
753        "directories created:".to_string(),
754    ];
755    for dir in &report.directories_created {
756        lines.push(format!("  - {}", dir.display()));
757    }
758    lines.push("files written:".to_string());
759    for file in &report.files_written {
760        lines.push(format!("  - {}", file.display()));
761    }
762    lines.push("commands run:".to_string());
763    if report.commands_run.is_empty() {
764        lines.push("  - none".to_string());
765    } else {
766        for cmd in &report.commands_run {
767            lines.push(format!("  - {cmd}"));
768        }
769    }
770    lines.join("\n")
771}
772
773fn render_single_vm_destroy_report_text(report: &SingleVmDestroyReport) -> String {
774    let mut lines = vec!["destroy report:".to_string(), "files removed:".to_string()];
775    if report.files_removed.is_empty() {
776        lines.push("  - none".to_string());
777    } else {
778        for file in &report.files_removed {
779            lines.push(format!("  - {}", file.display()));
780        }
781    }
782    lines.push("directories removed:".to_string());
783    if report.directories_removed.is_empty() {
784        lines.push("  - none".to_string());
785    } else {
786        for dir in &report.directories_removed {
787            lines.push(format!("  - {}", dir.display()));
788        }
789    }
790    lines.push("commands run:".to_string());
791    if report.commands_run.is_empty() {
792        lines.push("  - none".to_string());
793    } else {
794        for cmd in &report.commands_run {
795            lines.push(format!("  - {cmd}"));
796        }
797    }
798    lines.join("\n")
799}
800
801fn render_single_vm_status_report_text(report: &SingleVmStatusReport) -> String {
802    let mut lines = vec![
803        "status report:".to_string(),
804        format!("status: {:?}", report.status),
805        format!("service: {}", report.service_unit_name),
806        format!("state path: {}", report.state_path.display()),
807        format!("bundle mount exists: {}", report.bundle_mount_exists),
808        "present files:".to_string(),
809    ];
810    if report.present_files.is_empty() {
811        lines.push("  - none".to_string());
812    } else {
813        for path in &report.present_files {
814            lines.push(format!("  - {}", path.display()));
815        }
816    }
817    lines.push("missing files:".to_string());
818    if report.missing_files.is_empty() {
819        lines.push("  - none".to_string());
820    } else {
821        for path in &report.missing_files {
822            lines.push(format!("  - {}", path.display()));
823        }
824    }
825    lines.join("\n")
826}
827
828pub fn render_systemd_unit(plan: &SingleVmPlan, env_file_path: &std::path::Path) -> String {
829    let bundle_mounts = render_bundle_source_mounts(&plan.bundle.source);
830    let admin_mounts = render_admin_cert_mounts(&plan.admin);
831    format!(
832        "[Unit]
833Description=Greentic runtime for deployment {deployment_name}
834After=network-online.target
835Wants=network-online.target
836
837[Service]
838Type=simple
839User={user}
840Group={group}
841EnvironmentFile={env_file}
842ExecStart=/usr/bin/docker run --rm \\
843  --name {service_name} \\
844  --read-only \\
845  --env-file {env_file} \\
846  -p 127.0.0.1:8433:8433 \\
847  -v {bundle_mount}:{bundle_mount}:ro \\
848  -v {state_dir}:{state_dir} \\
849  -v {cache_dir}:{cache_dir} \\
850  -v {log_dir}:{log_dir} \\
851  -v {temp_dir}:{temp_dir} \\
852{bundle_mounts}\
853{admin_mounts}\
854  {image}
855ExecStop=/usr/bin/docker stop {service_name}
856Restart=always
857RestartSec=5
858
859[Install]
860WantedBy=multi-user.target
861",
862        deployment_name = plan.deployment_name,
863        user = plan.service.user,
864        group = plan.service.group,
865        env_file = env_file_path.display(),
866        service_name = plan.service_name,
867        bundle_mount = plan.bundle.mount_path.display(),
868        state_dir = plan.storage.state_dir.display(),
869        cache_dir = plan.storage.cache_dir.display(),
870        log_dir = plan.storage.log_dir.display(),
871        temp_dir = plan.storage.temp_dir.display(),
872        bundle_mounts = bundle_mounts,
873        admin_mounts = admin_mounts,
874        image = plan.runtime.image,
875    )
876}
877
878pub fn render_env_file(plan: &SingleVmPlan) -> String {
879    format!(
880        "GREENTIC_BUNDLE_SOURCE={bundle_source}
881GREENTIC_BUNDLE_FORMAT={bundle_format}
882GREENTIC_BUNDLE_MOUNT={bundle_mount}
883GREENTIC_STATE_DIR={state_dir}
884GREENTIC_CACHE_DIR={cache_dir}
885GREENTIC_LOG_DIR={log_dir}
886GREENTIC_TEMP_DIR={temp_dir}
887GREENTIC_ADMIN_BIND={admin_bind}
888GREENTIC_ADMIN_LISTEN={admin_bind}
889GREENTIC_ADMIN_CA_FILE={ca_file}
890GREENTIC_ADMIN_CERT_FILE={cert_file}
891GREENTIC_ADMIN_KEY_FILE={key_file}
892GREENTIC_HEALTH_READINESS_PATH={readiness_path}
893GREENTIC_HEALTH_LIVENESS_PATH={liveness_path}
894GREENTIC_HEALTH_STARTUP_TIMEOUT_SECONDS={startup_timeout_seconds}
895",
896        bundle_source = plan.bundle.source,
897        bundle_format = match plan.bundle.format {
898            BundleFormat::Squashfs => "squashfs",
899        },
900        bundle_mount = plan.bundle.mount_path.display(),
901        state_dir = plan.storage.state_dir.display(),
902        cache_dir = plan.storage.cache_dir.display(),
903        log_dir = plan.storage.log_dir.display(),
904        temp_dir = plan.storage.temp_dir.display(),
905        admin_bind = plan.admin.bind,
906        ca_file = plan.admin.ca_file.display(),
907        cert_file = plan.admin.cert_file.display(),
908        key_file = plan.admin.key_file.display(),
909        readiness_path = plan.health.readiness_path,
910        liveness_path = plan.health.liveness_path,
911        startup_timeout_seconds = plan.health.startup_timeout_seconds,
912    )
913}
914
915fn render_bundle_source_mounts(source: &str) -> String {
916    local_bundle_source_path(source)
917        .map(|path| format!("  -v {}:{}:ro \\\n", path.display(), path.display()))
918        .unwrap_or_default()
919}
920
921fn render_admin_cert_mounts(admin: &SingleVmAdminPlan) -> String {
922    let mut mounts = String::new();
923    for path in [&admin.ca_file, &admin.cert_file, &admin.key_file] {
924        mounts.push_str(&format!(
925            "  -v {}:{}:ro \\\n",
926            path.display(),
927            path.display()
928        ));
929    }
930    mounts
931}
932
933fn local_bundle_source_path(source: &str) -> Option<PathBuf> {
934    source
935        .strip_prefix("file://")
936        .map(PathBuf::from)
937        .or_else(|| {
938            let path = PathBuf::from(source);
939            path.is_absolute().then_some(path)
940        })
941}
942
943fn sanitize_service_name(name: &str) -> String {
944    let mut out = String::with_capacity(name.len() + DEFAULT_RUNTIME_SERVICE_NAME.len() + 1);
945    for ch in name.chars() {
946        if ch.is_ascii_alphanumeric() {
947            out.push(ch.to_ascii_lowercase());
948        } else {
949            out.push('-');
950        }
951    }
952    while out.contains("--") {
953        out = out.replace("--", "-");
954    }
955    let trimmed = out.trim_matches('-');
956    if trimmed.is_empty() {
957        DEFAULT_RUNTIME_SERVICE_NAME.to_string()
958    } else {
959        format!("{trimmed}-{DEFAULT_RUNTIME_SERVICE_NAME}")
960    }
961}
962
963fn is_directory_empty(path: &Path) -> Result<bool> {
964    let mut entries = fs::read_dir(path).map_err(|err| {
965        DeployerError::Io(std::io::Error::new(
966            err.kind(),
967            format!("failed to read directory {}: {err}", path.display()),
968        ))
969    })?;
970    Ok(entries.next().is_none())
971}
972
973fn run_command(commands_run: &mut Vec<String>, program: &str, args: &[&str]) -> Result<()> {
974    commands_run.push(format!("{program} {}", args.join(" ")));
975    // Accepted risk: callers pass fixed system tool names and argument arrays; no shell is used.
976    // foxguard: ignore[rs/no-command-injection]
977    let status = Command::new(program).args(args).status().map_err(|err| {
978        DeployerError::Io(std::io::Error::new(
979            err.kind(),
980            format!("failed to execute {program}: {err}"),
981        ))
982    })?;
983    if !status.success() {
984        return Err(DeployerError::Other(format!(
985            "command failed: {program} {} (exit={})",
986            args.join(" "),
987            status.code().unwrap_or(1)
988        )));
989    }
990    Ok(())
991}
992
993fn single_vm_state_path(plan: &SingleVmPlan) -> PathBuf {
994    plan.storage.state_dir.join(DEFAULT_STATE_FILE_NAME)
995}
996
997fn write_single_vm_state(output: &SingleVmPlanOutput) -> Result<()> {
998    let state_path = single_vm_state_path(&output.plan);
999    if let Some(parent) = state_path.parent() {
1000        fs::create_dir_all(parent).map_err(|err| {
1001            DeployerError::Io(std::io::Error::new(
1002                err.kind(),
1003                format!(
1004                    "failed to create parent directory {} for {}: {err}",
1005                    parent.display(),
1006                    state_path.display()
1007                ),
1008            ))
1009        })?;
1010    }
1011    let state = SingleVmPersistedState {
1012        deployment_name: output.plan.deployment_name.clone(),
1013        service_unit_name: output.service_unit_name.clone(),
1014        runtime_image: output.plan.runtime.image.clone(),
1015        bundle_source: output.plan.bundle.source.clone(),
1016        admin_bind: output.plan.admin.bind.clone(),
1017        last_action: SingleVmLastAction::Apply,
1018    };
1019    let bytes = serde_json::to_vec_pretty(&state)?;
1020    fs::write(&state_path, bytes).map_err(|err| {
1021        DeployerError::Io(std::io::Error::new(
1022            err.kind(),
1023            format!("failed to write {}: {err}", state_path.display()),
1024        ))
1025    })?;
1026    Ok(())
1027}
1028
1029fn load_single_vm_state(path: &Path) -> Result<Option<SingleVmPersistedState>> {
1030    if !path.exists() {
1031        return Ok(None);
1032    }
1033    let bytes = fs::read(path).map_err(|err| {
1034        DeployerError::Io(std::io::Error::new(
1035            err.kind(),
1036            format!("failed to read {}: {err}", path.display()),
1037        ))
1038    })?;
1039    let state = serde_json::from_slice(&bytes)?;
1040    Ok(Some(state))
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046    use crate::spec::DeploymentSpecV1;
1047
1048    fn sample_spec() -> DeploymentSpecV1 {
1049        DeploymentSpecV1::from_yaml_str(
1050            r#"
1051apiVersion: greentic.ai/v1alpha1
1052kind: Deployment
1053metadata:
1054  name: acme-prod
1055spec:
1056  target: single-vm
1057  bundle:
1058    source: file:///opt/greentic/bundles/acme.squashfs
1059    format: squashfs
1060  runtime:
1061    image: ghcr.io/greentic-ai/operator-distroless:0.1.0-distroless
1062    arch: x86_64
1063    admin:
1064      bind: 127.0.0.1:8433
1065      mtls:
1066        caFile: /etc/greentic/admin/ca.crt
1067        certFile: /etc/greentic/admin/server.crt
1068        keyFile: /etc/greentic/admin/server.key
1069  storage:
1070    stateDir: /var/lib/greentic/state
1071    cacheDir: /var/lib/greentic/cache
1072    logDir: /var/log/greentic
1073    tempDir: /var/lib/greentic/tmp
1074  service:
1075    manager: systemd
1076    user: greentic
1077    group: greentic
1078  health:
1079    readinessPath: /ready
1080    livenessPath: /health
1081    startupTimeoutSeconds: 120
1082  rollout:
1083    strategy: recreate
1084"#,
1085        )
1086        .expect("sample spec")
1087    }
1088
1089    #[test]
1090    fn build_single_vm_plan_normalizes_runtime_layout() {
1091        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1092        assert_eq!(plan.service_name, "acme-prod-greentic-runtime");
1093        assert_eq!(
1094            plan.bundle.mount_path,
1095            PathBuf::from("/mnt/greentic/bundles/acme-prod")
1096        );
1097        assert!(plan.bundle.read_only);
1098    }
1099
1100    #[test]
1101    fn build_single_vm_plan_rejects_path_like_names() {
1102        let mut spec = sample_spec();
1103        spec.metadata.name = "prod/blue".to_string();
1104        let err = build_single_vm_plan(&spec).expect_err("must reject path separators");
1105        assert!(err.to_string().contains("path separators"));
1106    }
1107
1108    #[test]
1109    fn render_single_vm_plan_emits_systemd_unit_and_env_file() {
1110        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1111        let output = render_single_vm_plan(&plan);
1112        assert_eq!(
1113            output.service_unit_name,
1114            "acme-prod-greentic-runtime.service"
1115        );
1116        assert_eq!(output.files.len(), 2);
1117        assert!(
1118            output
1119                .files
1120                .iter()
1121                .any(|file| matches!(file.kind, SingleVmPlannedFileKind::SystemdUnit))
1122        );
1123        assert!(
1124            output
1125                .files
1126                .iter()
1127                .any(|file| matches!(file.kind, SingleVmPlannedFileKind::EnvironmentFile))
1128        );
1129    }
1130
1131    #[test]
1132    fn render_env_file_contains_admin_and_storage_layout() {
1133        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1134        let rendered = render_env_file(&plan);
1135        assert!(rendered.contains("GREENTIC_ADMIN_BIND=127.0.0.1:8433"));
1136        assert!(rendered.contains("GREENTIC_ADMIN_LISTEN=127.0.0.1:8433"));
1137        assert!(rendered.contains("GREENTIC_STATE_DIR=/var/lib/greentic/state"));
1138        assert!(rendered.contains("GREENTIC_BUNDLE_FORMAT=squashfs"));
1139    }
1140
1141    #[test]
1142    fn render_systemd_unit_uses_env_file_and_mounts_local_inputs() {
1143        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1144        let rendered = render_systemd_unit(&plan, Path::new("/etc/greentic/acme.env"));
1145        assert!(rendered.contains("EnvironmentFile=/etc/greentic/acme.env"));
1146        assert!(rendered.contains("--env-file /etc/greentic/acme.env"));
1147        assert!(rendered.contains(
1148            "-v /opt/greentic/bundles/acme.squashfs:/opt/greentic/bundles/acme.squashfs:ro"
1149        ));
1150        assert!(rendered.contains("-v /etc/greentic/admin/ca.crt:/etc/greentic/admin/ca.crt:ro"));
1151        assert!(
1152            rendered
1153                .contains("-v /etc/greentic/admin/server.crt:/etc/greentic/admin/server.crt:ro")
1154        );
1155        assert!(
1156            rendered
1157                .contains("-v /etc/greentic/admin/server.key:/etc/greentic/admin/server.key:ro")
1158        );
1159    }
1160
1161    #[test]
1162    fn plan_single_vm_spec_renders_yaml_output() {
1163        let output = plan_single_vm_spec(&sample_spec()).expect("planned");
1164        let rendered =
1165            render_single_vm_plan_output(&output, OutputFormat::Yaml).expect("yaml render");
1166        assert!(rendered.contains("service_unit_name: acme-prod-greentic-runtime.service"));
1167    }
1168
1169    #[test]
1170    fn apply_single_vm_plan_output_writes_directories_and_files() {
1171        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1172        let mut output = render_single_vm_plan(&plan);
1173        let dir = tempfile::tempdir().expect("tempdir");
1174
1175        output.plan.storage.state_dir = dir.path().join("state");
1176        output.plan.storage.cache_dir = dir.path().join("cache");
1177        output.plan.storage.log_dir = dir.path().join("logs");
1178        output.plan.storage.temp_dir = dir.path().join("tmp");
1179        output.directories = vec![
1180            output.plan.storage.state_dir.clone(),
1181            output.plan.storage.cache_dir.clone(),
1182            output.plan.storage.log_dir.clone(),
1183            output.plan.storage.temp_dir.clone(),
1184        ];
1185        output.files = vec![
1186            SingleVmPlannedFile {
1187                path: dir.path().join("systemd").join("greentic-runtime.service"),
1188                kind: SingleVmPlannedFileKind::SystemdUnit,
1189                contents: "unit".to_string(),
1190            },
1191            SingleVmPlannedFile {
1192                path: dir.path().join("env").join("greentic-runtime.env"),
1193                kind: SingleVmPlannedFileKind::EnvironmentFile,
1194                contents: "ENV=1\n".to_string(),
1195            },
1196        ];
1197
1198        let report = apply_single_vm_plan_output(&output).expect("apply");
1199        assert_eq!(report.directories_created.len(), 4);
1200        assert_eq!(report.files_written.len(), 2);
1201        assert!(report.commands_run.is_empty());
1202        assert_eq!(
1203            std::fs::read_to_string(dir.path().join("systemd").join("greentic-runtime.service"))
1204                .expect("read unit"),
1205            "unit"
1206        );
1207        assert_eq!(
1208            std::fs::read_to_string(dir.path().join("env").join("greentic-runtime.env"))
1209                .expect("read env"),
1210            "ENV=1\n"
1211        );
1212    }
1213
1214    #[test]
1215    fn preview_single_vm_apply_plan_output_reports_paths_without_writing() {
1216        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1217        let output = render_single_vm_plan(&plan);
1218
1219        let report = preview_single_vm_apply_plan_output(&output);
1220        assert_eq!(report.directories_created, output.directories);
1221        assert_eq!(
1222            report.files_written,
1223            output
1224                .files
1225                .iter()
1226                .map(|file| file.path.clone())
1227                .collect::<Vec<_>>()
1228        );
1229        assert!(report.commands_run.is_empty());
1230    }
1231
1232    #[test]
1233    fn destroy_single_vm_plan_output_removes_written_files_and_empty_dirs() {
1234        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1235        let mut output = render_single_vm_plan(&plan);
1236        let dir = tempfile::tempdir().expect("tempdir");
1237
1238        output.plan.storage.state_dir = dir.path().join("state");
1239        let systemd_dir = dir.path().join("systemd");
1240        let env_dir = dir.path().join("env");
1241        output.directories = vec![
1242            output.plan.storage.state_dir.clone(),
1243            systemd_dir.clone(),
1244            env_dir.clone(),
1245        ];
1246        output.files = vec![
1247            SingleVmPlannedFile {
1248                path: systemd_dir.join("greentic-runtime.service"),
1249                kind: SingleVmPlannedFileKind::SystemdUnit,
1250                contents: "unit".to_string(),
1251            },
1252            SingleVmPlannedFile {
1253                path: env_dir.join("greentic-runtime.env"),
1254                kind: SingleVmPlannedFileKind::EnvironmentFile,
1255                contents: "ENV=1\n".to_string(),
1256            },
1257        ];
1258
1259        apply_single_vm_plan_output(&output).expect("apply");
1260        let report = destroy_single_vm_plan_output(&output).expect("destroy");
1261        assert_eq!(report.files_removed.len(), 3);
1262        assert_eq!(report.directories_removed.len(), 3);
1263        assert!(report.commands_run.is_empty());
1264        assert!(!output.plan.storage.state_dir.exists());
1265        assert!(!systemd_dir.exists());
1266        assert!(!env_dir.exists());
1267    }
1268
1269    #[test]
1270    fn preview_single_vm_destroy_plan_output_reports_paths_without_removing() {
1271        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1272        let output = render_single_vm_plan(&plan);
1273
1274        let report = preview_single_vm_destroy_plan_output(&output);
1275        assert_eq!(
1276            report.files_removed,
1277            output
1278                .files
1279                .iter()
1280                .map(|file| file.path.clone())
1281                .collect::<Vec<_>>()
1282        );
1283        assert_eq!(report.directories_removed, output.directories);
1284        assert!(report.commands_run.is_empty());
1285    }
1286
1287    #[test]
1288    fn apply_single_vm_plan_output_writes_persisted_state() {
1289        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1290        let mut output = render_single_vm_plan(&plan);
1291        let dir = tempfile::tempdir().expect("tempdir");
1292
1293        output.directories = vec![
1294            dir.path().join("state"),
1295            dir.path().join("cache"),
1296            dir.path().join("logs"),
1297            dir.path().join("tmp"),
1298        ];
1299        output.plan.storage.state_dir = dir.path().join("state");
1300        output.files = vec![];
1301
1302        apply_single_vm_plan_output(&output).expect("apply");
1303        let state = load_single_vm_state(&dir.path().join("state").join(DEFAULT_STATE_FILE_NAME))
1304            .expect("load state")
1305            .expect("state exists");
1306        assert_eq!(state.last_action, SingleVmLastAction::Apply);
1307        assert_eq!(state.service_unit_name, output.service_unit_name);
1308    }
1309
1310    #[test]
1311    fn status_single_vm_plan_output_reports_applied_installation() {
1312        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1313        let mut output = render_single_vm_plan(&plan);
1314        let dir = tempfile::tempdir().expect("tempdir");
1315
1316        output.plan.storage.state_dir = dir.path().join("state");
1317        output.directories = vec![
1318            output.plan.storage.state_dir.clone(),
1319            dir.path().join("cache"),
1320            dir.path().join("logs"),
1321        ];
1322        output.files = vec![SingleVmPlannedFile {
1323            path: dir.path().join("systemd").join("greentic-runtime.service"),
1324            kind: SingleVmPlannedFileKind::SystemdUnit,
1325            contents: "unit".to_string(),
1326        }];
1327
1328        apply_single_vm_plan_output(&output).expect("apply");
1329        let status = status_single_vm_plan_output(&output).expect("status");
1330        assert_eq!(status.status, SingleVmDeploymentStatus::Applied);
1331        assert!(status.state_exists);
1332        assert!(status.missing_files.is_empty());
1333    }
1334
1335    #[test]
1336    fn status_single_vm_plan_output_reports_not_installed_when_artifacts_missing() {
1337        let plan = build_single_vm_plan(&sample_spec()).expect("plan");
1338        let mut output = render_single_vm_plan(&plan);
1339        let dir = tempfile::tempdir().expect("tempdir");
1340
1341        output.plan.storage.state_dir = dir.path().join("state");
1342        output.directories = vec![dir.path().join("state"), dir.path().join("cache")];
1343        output.files = vec![SingleVmPlannedFile {
1344            path: dir.path().join("systemd").join("greentic-runtime.service"),
1345            kind: SingleVmPlannedFileKind::SystemdUnit,
1346            contents: "unit".to_string(),
1347        }];
1348
1349        let status = status_single_vm_plan_output(&output).expect("status");
1350        assert_eq!(status.status, SingleVmDeploymentStatus::NotInstalled);
1351        assert!(!status.state_exists);
1352    }
1353
1354    #[test]
1355    fn render_single_vm_apply_report_text_mentions_no_commands() {
1356        let report = SingleVmApplyReport {
1357            directories_created: vec!["/tmp/state".into()],
1358            files_written: vec!["/tmp/greentic.env".into()],
1359            commands_run: Vec::new(),
1360        };
1361
1362        let rendered =
1363            render_single_vm_apply_report(&report, OutputFormat::Text).expect("render text");
1364        assert!(rendered.contains("apply report:"));
1365        assert!(rendered.contains("/tmp/state"));
1366        assert!(rendered.contains("  - none"));
1367    }
1368
1369    #[test]
1370    fn render_single_vm_destroy_report_text_mentions_removed_files() {
1371        let report = SingleVmDestroyReport {
1372            files_removed: vec!["/tmp/greentic.env".into()],
1373            directories_removed: vec!["/tmp/state".into()],
1374            commands_run: vec!["systemctl stop acme.service".to_string()],
1375        };
1376
1377        let rendered =
1378            render_single_vm_destroy_report(&report, OutputFormat::Text).expect("render text");
1379        assert!(rendered.contains("destroy report:"));
1380        assert!(rendered.contains("/tmp/greentic.env"));
1381        assert!(rendered.contains("systemctl stop acme.service"));
1382    }
1383
1384    #[test]
1385    fn render_single_vm_status_report_text_mentions_status() {
1386        let report = SingleVmStatusReport {
1387            state_path: "/tmp/state/single-vm-state.json".into(),
1388            status: SingleVmDeploymentStatus::Applied,
1389            service_unit_name: "acme.service".to_string(),
1390            service_unit_path: "/etc/systemd/system/acme.service".into(),
1391            env_file_path: "/etc/greentic/acme.env".into(),
1392            state_exists: true,
1393            bundle_mount_exists: true,
1394            present_directories: vec!["/tmp/state".into()],
1395            missing_directories: Vec::new(),
1396            present_files: vec!["/etc/systemd/system/acme.service".into()],
1397            missing_files: Vec::new(),
1398            state: None,
1399        };
1400
1401        let rendered =
1402            render_single_vm_status_report(&report, OutputFormat::Text).expect("render text");
1403        assert!(rendered.contains("status report:"));
1404        assert!(rendered.contains("Applied"));
1405        assert!(rendered.contains("acme.service"));
1406    }
1407
1408    #[test]
1409    fn ext_config_parses_minimum_fields() {
1410        let json = r#"{"specPath": "/tmp/spec.yaml"}"#;
1411        let cfg: SingleVmExtConfig = serde_json::from_str(json).unwrap();
1412        assert_eq!(cfg.spec_path, PathBuf::from("/tmp/spec.yaml"));
1413        assert!(!cfg.apply_options.pull_image);
1414        assert!(!cfg.destroy_options.stop_service);
1415    }
1416
1417    #[test]
1418    fn ext_config_accepts_options() {
1419        let json = r#"{
1420            "specPath": "/tmp/spec.yaml",
1421            "applyOptions": {
1422                "pullImage": true,
1423                "daemonReload": true,
1424                "enableService": false,
1425                "restartService": true
1426            },
1427            "destroyOptions": {
1428                "stopService": true,
1429                "disableService": false
1430            }
1431        }"#;
1432        let cfg: SingleVmExtConfig = serde_json::from_str(json).unwrap();
1433        assert!(cfg.apply_options.pull_image);
1434        assert!(cfg.apply_options.daemon_reload);
1435        assert!(!cfg.apply_options.enable_service);
1436        assert!(cfg.apply_options.restart_service);
1437        assert!(cfg.destroy_options.stop_service);
1438        assert!(!cfg.destroy_options.disable_service);
1439    }
1440
1441    #[test]
1442    fn apply_from_ext_rejects_invalid_json() {
1443        let err = apply_from_ext("not json", "{}", None).unwrap_err();
1444        assert!(format!("{err}").contains("parse"));
1445    }
1446
1447    #[test]
1448    fn apply_from_ext_rejects_missing_spec_path() {
1449        let err = apply_from_ext(r#"{"applyOptions": {}}"#, "{}", None).unwrap_err();
1450        assert!(
1451            format!("{err}").contains("specPath") || format!("{err}").contains("missing field"),
1452            "got: {err}"
1453        );
1454    }
1455
1456    #[test]
1457    fn destroy_from_ext_rejects_invalid_json() {
1458        let err = destroy_from_ext("not json", "{}").unwrap_err();
1459        assert!(format!("{err}").contains("parse"));
1460    }
1461}