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#[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
560pub 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
576pub 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 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}