1use crate::config::Config;
2use crate::env::EnvConfig;
3use crate::presentation::{
4 LifecycleReporter, PresentationEvent, TerminalInteraction, TerminalRenderer,
5};
6use anyhow::{Result, bail};
7
8#[cfg(test)]
9use super::SysInstalledRow;
10use super::detect::detect_os_id;
11use super::execution::{
12 item_outcome_lines, presentation_bold, presentation_dim, presentation_symbol,
13 presentation_symbol_stderr,
14};
15use super::{SysItemOutcome, SysItemStatus, SysUpdateRow, SysUpgradeReport};
16use shine_core::lifecycle::{LifecycleOperation, LifecycleResultV1};
17use shine_core::runtime::{PlanningInputVersions, SysManagedPlanRequest};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub(super) enum SysAction {
21 Apply,
22 Remove,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26enum ManagedOutputMode {
27 Explicit,
28 Upgrade { verbose: bool },
29}
30
31#[derive(Clone, Copy)]
32struct ManagedRunRequest<'a> {
33 config: &'a Config,
34 os_id: &'a str,
35 requested: Option<&'a str>,
36 action: SysAction,
37 dry_run: bool,
38 yes: bool,
39 output_mode: ManagedOutputMode,
40}
41
42impl ManagedOutputMode {
43 fn is_explicit(self) -> bool {
44 matches!(self, Self::Explicit)
45 }
46
47 fn show_all_outcomes(self) -> bool {
48 matches!(self, Self::Explicit | Self::Upgrade { verbose: true })
49 }
50}
51
52pub async fn handle_apply(config: &Config, item: Option<&str>, dry_run: bool) -> Result<()> {
53 handle_apply_approved(config, item, dry_run, true).await
54}
55
56pub async fn handle_apply_approved(
57 config: &Config,
58 item: Option<&str>,
59 dry_run: bool,
60 yes: bool,
61) -> Result<()> {
62 let (report, _) = handle_apply_with_result_approved(config, item, dry_run, yes).await?;
63 if report.failed > 0 {
64 bail!(
65 "{} managed system configuration item(s) failed",
66 report.failed
67 );
68 }
69 Ok(())
70}
71
72pub(crate) async fn handle_apply_with_result_approved(
73 config: &Config,
74 item: Option<&str>,
75 dry_run: bool,
76 yes: bool,
77) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
78 run_managed_with_result(
79 config,
80 item,
81 SysAction::Apply,
82 dry_run,
83 yes,
84 ManagedOutputMode::Explicit,
85 None,
86 )
87 .await
88}
89
90pub async fn handle_uninstall(config: &Config, item: &str, dry_run: bool) -> Result<()> {
91 handle_uninstall_approved(config, item, dry_run, true).await
92}
93
94pub async fn handle_uninstall_approved(
95 config: &Config,
96 item: &str,
97 dry_run: bool,
98 yes: bool,
99) -> Result<()> {
100 let (report, _) = handle_uninstall_with_result_approved(config, item, dry_run, yes).await?;
101 if report.failed > 0 {
102 bail!("failed to remove managed system configuration `{item}`");
103 }
104 Ok(())
105}
106
107pub(crate) async fn handle_uninstall_with_result_approved(
108 config: &Config,
109 item: &str,
110 dry_run: bool,
111 yes: bool,
112) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
113 run_managed_with_result(
114 config,
115 Some(item),
116 SysAction::Remove,
117 dry_run,
118 yes,
119 ManagedOutputMode::Explicit,
120 None,
121 )
122 .await
123}
124
125pub async fn handle_upgrade_managed(
126 config: &Config,
127 verbose: bool,
128 sep: &mut crate::output::SectionSeparator,
129) -> Result<SysUpgradeReport> {
130 handle_upgrade_managed_with_result_approved(config, verbose, true, sep)
131 .await
132 .map(|(report, _)| report)
133}
134
135pub(crate) async fn handle_upgrade_managed_with_result_approved(
136 config: &Config,
137 verbose: bool,
138 yes: bool,
139 sep: &mut crate::output::SectionSeparator,
140) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
141 let mut renderer = TerminalRenderer::stdio_with_separator(sep);
142 let mut interaction = TerminalInteraction;
143 handle_upgrade_managed_with_reporter(config, verbose, yes, &mut renderer, &mut interaction)
144 .await
145}
146
147pub(crate) async fn handle_upgrade_managed_with_result_prepared(
148 config: &Config,
149 verbose: bool,
150 prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
151 sep: &mut crate::output::SectionSeparator,
152) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
153 let os_id = detect_os_id().await?;
154 let mut renderer = TerminalRenderer::stdio_with_separator(sep);
155 let mut interaction = TerminalInteraction;
156 run_managed_for_os_with_prepared_reporter(
157 ManagedRunRequest {
158 config,
159 os_id: &os_id,
160 requested: None,
161 action: SysAction::Apply,
162 dry_run: false,
163 yes: true,
164 output_mode: ManagedOutputMode::Upgrade { verbose },
165 },
166 prepared,
167 &mut renderer,
168 &mut interaction,
169 )
170 .await
171}
172
173async fn handle_upgrade_managed_with_reporter(
174 config: &Config,
175 verbose: bool,
176 yes: bool,
177 reporter: &mut dyn LifecycleReporter,
178 interaction: &mut TerminalInteraction,
179) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
180 let os_id = detect_os_id().await?;
181 let (report, lifecycle_result) = run_managed_for_os_with_reporter(
182 ManagedRunRequest {
183 config,
184 os_id: &os_id,
185 requested: None,
186 action: SysAction::Apply,
187 dry_run: false,
188 yes,
189 output_mode: ManagedOutputMode::Upgrade { verbose },
190 },
191 reporter,
192 interaction,
193 )
194 .await?;
195 Ok((report, lifecycle_result))
196}
197
198pub async fn handle_upgrade_managed_target(
199 config: &Config,
200 item: Option<&str>,
201 verbose: bool,
202 sep: &mut crate::output::SectionSeparator,
203) -> Result<SysUpgradeReport> {
204 handle_upgrade_managed_target_with_result_approved(config, item, verbose, true, sep)
205 .await
206 .map(|(report, _)| report)
207}
208
209pub(crate) async fn handle_upgrade_managed_target_with_result_approved(
210 config: &Config,
211 item: Option<&str>,
212 verbose: bool,
213 yes: bool,
214 sep: &mut crate::output::SectionSeparator,
215) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
216 let mut renderer = TerminalRenderer::stdio_with_separator(sep);
217 let mut interaction = TerminalInteraction;
218 let os_id = detect_os_id().await?;
219 run_managed_for_os_with_reporter(
220 ManagedRunRequest {
221 config,
222 os_id: &os_id,
223 requested: item,
224 action: SysAction::Apply,
225 dry_run: false,
226 yes,
227 output_mode: ManagedOutputMode::Upgrade { verbose },
228 },
229 &mut renderer,
230 &mut interaction,
231 )
232 .await
233}
234
235pub async fn managed_updates(config: &Config) -> Result<Vec<SysUpdateRow>> {
236 managed_updates_with_result(config)
237 .await
238 .map(|(rows, _)| rows)
239}
240
241pub(crate) async fn managed_updates_with_result(
242 config: &Config,
243) -> Result<(Vec<SysUpdateRow>, LifecycleResultV1)> {
244 let os_id = detect_os_id().await?;
245 managed_updates_for_os_with_result(config, &os_id).await
246}
247
248#[cfg(test)]
249async fn installed_managed_for_os(config: &Config, os_id: &str) -> Result<Vec<SysInstalledRow>> {
250 crate::core_runtime::from_config(config)
251 .await?
252 .installed_managed_sys(os_id)
253 .await
254}
255
256async fn managed_updates_for_os_with_result(
257 config: &Config,
258 os_id: &str,
259) -> Result<(Vec<SysUpdateRow>, LifecycleResultV1)> {
260 let mut runtime = crate::core_runtime::from_config(config).await?;
261 let env = EnvConfig::load_or_init(config).await?;
262 runtime.context_mut_for_cli().env = env.as_map().clone();
263 let inspection = shine_core::frontend::FrontendService::new(runtime)
264 .inspect_sys(os_id)
265 .await
266 .map_err(shine_core::frontend::FrontendServiceError::into_source)?;
267 Ok((inspection.updates, inspection.lifecycle))
268}
269
270async fn run_managed_with_result(
271 config: &Config,
272 requested: Option<&str>,
273 action: SysAction,
274 dry_run: bool,
275 yes: bool,
276 output_mode: ManagedOutputMode,
277 sep: Option<&mut crate::output::SectionSeparator>,
278) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
279 let os_id = detect_os_id().await?;
280 run_managed_for_os_with_result_approved(
281 ManagedRunRequest {
282 config,
283 os_id: &os_id,
284 requested,
285 action,
286 dry_run,
287 yes,
288 output_mode,
289 },
290 sep,
291 )
292 .await
293}
294
295#[cfg(test)]
296async fn run_managed_for_os_with_result(
297 config: &Config,
298 os_id: &str,
299 requested: Option<&str>,
300 action: SysAction,
301 dry_run: bool,
302 output_mode: ManagedOutputMode,
303 sep: Option<&mut crate::output::SectionSeparator>,
304) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
305 run_managed_for_os_with_result_approved(
306 ManagedRunRequest {
307 config,
308 os_id,
309 requested,
310 action,
311 dry_run,
312 yes: true,
313 output_mode,
314 },
315 sep,
316 )
317 .await
318}
319
320async fn run_managed_for_os_with_result_approved(
321 request: ManagedRunRequest<'_>,
322 sep: Option<&mut crate::output::SectionSeparator>,
323) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
324 if let Some(sep) = sep {
325 let mut renderer = TerminalRenderer::stdio_with_separator(sep);
326 let mut interaction = TerminalInteraction;
327 return run_managed_for_os_with_reporter(request, &mut renderer, &mut interaction).await;
328 }
329 let mut renderer = TerminalRenderer::stdio();
330 let mut interaction = TerminalInteraction;
331 run_managed_for_os_with_reporter(request, &mut renderer, &mut interaction).await
332}
333
334async fn run_managed_for_os_with_reporter(
335 request: ManagedRunRequest<'_>,
336 reporter: &mut dyn LifecycleReporter,
337 interaction: &mut TerminalInteraction,
338) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
339 let ManagedRunRequest {
340 config,
341 os_id,
342 requested,
343 action,
344 dry_run,
345 yes,
346 output_mode,
347 } = request;
348 let operation = match (action, output_mode) {
349 (SysAction::Remove, _) => LifecycleOperation::Uninstall,
350 (SysAction::Apply, ManagedOutputMode::Upgrade { .. }) => LifecycleOperation::Upgrade,
351 (SysAction::Apply, ManagedOutputMode::Explicit) => LifecycleOperation::Install,
352 };
353 let plan_request = SysManagedPlanRequest {
354 operation,
355 os_id: os_id.to_string(),
356 target: requested.map(str::to_string),
357 input_versions: PlanningInputVersions::default(),
358 };
359 if !dry_run {
360 let plan = crate::lifecycle_plan::LifecyclePlanRequest::sys(plan_request, config);
361 let reviewed = if operation == LifecycleOperation::Upgrade {
362 crate::lifecycle_plan::review_upgrade_plans(
363 config,
364 [plan],
365 yes,
366 matches!(output_mode, ManagedOutputMode::Upgrade { verbose: true }),
367 )
368 .await?
369 } else {
370 crate::lifecycle_plan::review_plans(config, [plan], yes).await?
371 }
372 .into_iter()
373 .next()
374 .expect("one reviewed Sys Plan");
375 let runtime = crate::lifecycle_plan::prepare_runtime(config, &reviewed).await?;
376 return run_managed_for_os_with_prepared_reporter(
377 request,
378 crate::lifecycle_plan::PreparedLifecyclePlan { reviewed, runtime },
379 reporter,
380 interaction,
381 )
382 .await;
383 }
384
385 let mut runtime = crate::core_runtime::from_config(config).await?;
386 let env = EnvConfig::load_or_init(config).await?;
387 runtime.context_mut_for_cli().env = env.as_map().clone();
388 let mut observer = ManagedObserver {
389 reporter,
390 action,
391 started: false,
392 };
393 let core = runtime
394 .preview_managed_sys(
395 shine_core::runtime::SysManagedRequest {
396 os_id: os_id.to_string(),
397 target: requested.map(str::to_string),
398 action: match action {
399 SysAction::Apply => shine_core::runtime::SysManagedAction::Apply,
400 SysAction::Remove => shine_core::runtime::SysManagedAction::Remove,
401 },
402 dry_run,
403 operation,
404 },
405 interaction,
406 &mut observer,
407 )
408 .await?;
409
410 finish_managed_report(core, output_mode, &mut observer)
411}
412
413async fn run_managed_for_os_with_prepared_reporter(
414 request: ManagedRunRequest<'_>,
415 prepared: crate::lifecycle_plan::PreparedLifecyclePlan,
416 reporter: &mut dyn LifecycleReporter,
417 interaction: &mut TerminalInteraction,
418) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
419 let ManagedRunRequest {
420 config,
421 action,
422 output_mode,
423 ..
424 } = request;
425 let crate::lifecycle_plan::PreparedLifecyclePlan {
426 reviewed,
427 mut runtime,
428 } = prepared;
429 let env = EnvConfig::load_or_init(config).await?;
430 runtime.context_mut_for_cli().env = env.as_map().clone();
431 let mut observer = ManagedObserver {
432 reporter,
433 action,
434 started: false,
435 };
436 let core = match crate::lifecycle_plan::execute_reviewed(
437 config,
438 runtime,
439 reviewed,
440 shine_core::frontend::ExecutionOptions::default(),
441 &mut observer,
442 interaction,
443 )
444 .await?
445 {
446 shine_core::frontend::OperationDetails::SysManaged(report) => *report,
447 _ => unreachable!("reviewed operation result type"),
448 };
449 finish_managed_report(core, output_mode, &mut observer)
450}
451
452fn finish_managed_report(
453 core: shine_core::runtime::SysManagedReport,
454 output_mode: ManagedOutputMode,
455 observer: &mut ManagedObserver<'_>,
456) -> Result<(SysUpgradeReport, LifecycleResultV1)> {
457 if core.items.is_empty() && output_mode.is_explicit() {
458 observer
459 .reporter
460 .emit(PresentationEvent::stdout(presentation_dim(
461 "No managed system configuration items selected.",
462 )));
463 }
464 let show_all = output_mode.show_all_outcomes();
465 for outcome in &core.items {
466 if should_print_managed_outcome(show_all, outcome.status) {
467 observer.begin();
468 emit_item_outcome(observer.reporter, outcome, outcome.label.len().max(14));
469 }
470 }
471 Ok((core.summary, core.lifecycle))
472}
473
474struct ManagedObserver<'a> {
475 reporter: &'a mut dyn LifecycleReporter,
476 action: SysAction,
477 started: bool,
478}
479
480impl ManagedObserver<'_> {
481 fn begin(&mut self) {
482 if self.started {
483 return;
484 }
485 self.reporter.emit(PresentationEvent::SectionStart);
486 self.reporter
487 .emit(PresentationEvent::stdout(presentation_bold(
488 match self.action {
489 SysAction::Apply => "Managed System Configs",
490 SysAction::Remove => "Remove Managed System Config",
491 },
492 )));
493 self.started = true;
494 }
495}
496
497impl shine_core::runtime::RuntimeObserver for ManagedObserver<'_> {
498 fn emit(&mut self, event: shine_core::runtime::RuntimeEvent) {
499 match event {
500 shine_core::runtime::RuntimeEvent::Progress {
501 code: "sys_managed_item",
502 target,
503 } => {
504 self.begin();
505 self.reporter.emit(PresentationEvent::stdout(format!(
506 " {} {target}",
507 presentation_symbol("•")
508 )));
509 }
510 shine_core::runtime::RuntimeEvent::Warning { detail, .. } => {
511 self.begin();
512 self.reporter.emit(PresentationEvent::stderr(format!(
513 " {} {detail}",
514 presentation_symbol_stderr("✗")
515 )));
516 }
517 _ => {}
518 }
519 }
520}
521fn emit_item_outcome(
522 reporter: &mut dyn LifecycleReporter,
523 outcome: &SysItemOutcome,
524 label_width: usize,
525) {
526 for line in item_outcome_lines(outcome, label_width) {
527 reporter.emit(PresentationEvent::stdout(line));
528 }
529}
530
531fn should_print_managed_outcome(show_all: bool, status: SysItemStatus) -> bool {
532 show_all
533 || !matches!(
534 status,
535 SysItemStatus::AlreadyInstalled | SysItemStatus::Skipped
536 )
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use crate::config::Config;
543 use crate::sys::run_manifest::SYS_MANIFEST_FILE;
544 use crate::sys::run_manifest::SysRunManifest;
545 use shine_core::lifecycle::{LifecycleEffect, LifecycleStatus};
546 use std::path::PathBuf;
547 use tokio::fs;
548
549 async fn make_temp_dir() -> PathBuf {
550 crate::test_support::make_temp_dir("shine-sys").await
551 }
552
553 #[tokio::test]
554 async fn managed_item_apply_upgrade_and_uninstall_lifecycle() {
555 let dir = make_temp_dir().await;
556 let os_dir = dir.join("presets/sys/fakeos");
557 fs::create_dir_all(&os_dir).await.unwrap();
558 let first_destination = dir.join("first-managed.txt");
559 let second_destination = dir.join("second-managed.txt");
560 fs::write(os_dir.join("first.txt"), "first v1")
561 .await
562 .unwrap();
563 fs::write(os_dir.join("second.txt"), "second v1")
564 .await
565 .unwrap();
566 fs::write(&first_destination, "first original")
567 .await
568 .unwrap();
569 fs::write(
570 os_dir.join("shine.toml"),
571 format!(
572 r#"
573version = 2
574
575description = "Managed resource test"
576
577[[items]]
578id = "first"
579label = "First managed file"
580mode = "managed"
581driver = "managed-file"
582permissions = {{ schema_version = 1 }}
583
584[items.config]
585source = "first.txt"
586target = {:?}
587
588[[items]]
589id = "second"
590label = "Second managed file"
591mode = "managed"
592driver = "managed-file"
593permissions = {{ schema_version = 1 }}
594
595[items.config]
596source = "second.txt"
597target = {:?}
598"#,
599 first_destination.display().to_string(),
600 second_destination.display().to_string(),
601 ),
602 )
603 .await
604 .unwrap();
605 fs::write(os_dir.join("init.sh"), "#!/bin/sh\n")
606 .await
607 .unwrap();
608
609 let mut config = Config::new_for_test(&dir);
610 config.is_external_presets = true;
611
612 let (first_install, first_lifecycle) = run_managed_for_os_with_result(
613 &config,
614 "fakeos",
615 Some("first"),
616 SysAction::Apply,
617 false,
618 ManagedOutputMode::Explicit,
619 None,
620 )
621 .await
622 .unwrap();
623 assert_eq!(first_install.updated, 1);
624 assert!(first_lifecycle.outcomes.iter().any(|outcome| {
625 outcome.target == "sys/first"
626 && outcome.status == LifecycleStatus::Changed
627 && outcome.effects.contains(&LifecycleEffect::BackupCreated)
628 }));
629 let (second_install, second_lifecycle) = run_managed_for_os_with_result(
630 &config,
631 "fakeos",
632 Some("second"),
633 SysAction::Apply,
634 false,
635 ManagedOutputMode::Explicit,
636 None,
637 )
638 .await
639 .unwrap();
640 assert_eq!(second_install.updated, 1);
641 assert_eq!(second_lifecycle.summary().changed, 1);
642 let second_manifest_before =
643 SysRunManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
644 .await
645 .unwrap()
646 .entries
647 .into_iter()
648 .find(|entry| entry.item_id == "second")
649 .unwrap();
650
651 fs::write(os_dir.join("first.txt"), "first v2")
652 .await
653 .unwrap();
654 let (updates, update_lifecycle) = managed_updates_for_os_with_result(&config, "fakeos")
655 .await
656 .unwrap();
657 assert_eq!(updates.len(), 1);
658 assert_eq!(updates[0].item_id, "first");
659 assert_eq!(updates[0].details, ["Content: changed"]);
660 assert!(update_lifecycle.outcomes.iter().any(|outcome| {
661 outcome.target == "sys/first" && outcome.status == LifecycleStatus::Pending
662 }));
663 assert!(update_lifecycle.outcomes.iter().any(|outcome| {
664 outcome.target == "sys/second" && outcome.status == LifecycleStatus::Unchanged
665 }));
666
667 let (upgrade, upgrade_lifecycle) = run_managed_for_os_with_result(
668 &config,
669 "fakeos",
670 Some("first"),
671 SysAction::Apply,
672 false,
673 ManagedOutputMode::Upgrade { verbose: false },
674 None,
675 )
676 .await
677 .unwrap();
678 assert_eq!(upgrade.updated, 1);
679 assert!(upgrade_lifecycle.outcomes.iter().any(|outcome| {
680 outcome.target == "sys/first" && outcome.status == LifecycleStatus::Changed
681 }));
682 assert_eq!(
683 fs::read_to_string(&first_destination).await.unwrap(),
684 "first v2"
685 );
686 assert_eq!(
687 fs::read_to_string(&second_destination).await.unwrap(),
688 "second v1"
689 );
690 let manifest_after_upgrade =
691 SysRunManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
692 .await
693 .unwrap();
694 assert_eq!(
695 manifest_after_upgrade
696 .entries
697 .iter()
698 .find(|entry| entry.item_id == "second")
699 .unwrap(),
700 &second_manifest_before
701 );
702
703 let (current_updates, current_lifecycle) =
704 managed_updates_for_os_with_result(&config, "fakeos")
705 .await
706 .unwrap();
707 assert!(current_updates.is_empty());
708 assert!(
709 current_lifecycle
710 .outcomes
711 .iter()
712 .all(|outcome| { matches!(outcome.status, LifecycleStatus::Unchanged) })
713 );
714
715 let (uninstall, uninstall_lifecycle) = run_managed_for_os_with_result(
716 &config,
717 "fakeos",
718 Some("first"),
719 SysAction::Remove,
720 false,
721 ManagedOutputMode::Explicit,
722 None,
723 )
724 .await
725 .unwrap();
726 assert_eq!(uninstall.updated, 1);
727 assert!(uninstall_lifecycle.outcomes.iter().any(|outcome| {
728 outcome.target == "sys/first"
729 && outcome.status == LifecycleStatus::Changed
730 && outcome.effects.contains(&LifecycleEffect::BackupRestored)
731 && outcome.effects.contains(&LifecycleEffect::ReceiptRemoved)
732 }));
733 assert_eq!(
734 fs::read_to_string(&first_destination).await.unwrap(),
735 "first original"
736 );
737 assert_eq!(
738 fs::read_to_string(&second_destination).await.unwrap(),
739 "second v1"
740 );
741 let final_manifest =
742 SysRunManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
743 .await
744 .unwrap();
745 assert_eq!(final_manifest.entries.len(), 1);
746 assert_eq!(final_manifest.entries[0], second_manifest_before);
747
748 fs::remove_dir_all(&dir).await.unwrap();
749 }
750
751 #[tokio::test]
752 async fn managed_updates_reports_split_dns_env_change() {
753 let dir = make_temp_dir().await;
754 let os_dir = dir.join("presets/sys/macos");
755 fs::create_dir_all(&os_dir).await.unwrap();
756 fs::write(
757 os_dir.join("shine.toml"),
758 r#"
759version = 2
760
761description = "Managed test"
762
763[[items]]
764id = "split-dns"
765label = "Private split DNS"
766mode = "managed"
767driver = "split-dns"
768requires_admin = true
769required_env = ["PRIVATE_DNS_DOMAIN", "PRIVATE_DNS_SERVERS"]
770
771[items.config]
772domain_env = "PRIVATE_DNS_DOMAIN"
773servers_env = "PRIVATE_DNS_SERVERS"
774"#,
775 )
776 .await
777 .unwrap();
778 fs::write(os_dir.join("init.sh"), "#!/bin/bash\n")
779 .await
780 .unwrap();
781 fs::write(
782 dir.join(SYS_MANIFEST_FILE),
783 r#"
784[[entries]]
785os_id = "macos"
786item_id = "split-dns"
787label = "Private split DNS"
788status = "updated"
789updated_at = "1"
790managed = true
791
792[entries.receipt]
793driver = "split-dns"
794version = 1
795os_id = "macos"
796item_id = "split-dns"
797domain = "private.example"
798servers = ["10.0.0.2"]
799resource = "/etc/resolver/private.example"
800"#,
801 )
802 .await
803 .unwrap();
804
805 let mut config = Config::new_for_test(&dir);
806 config.is_external_presets = true;
807 config.env.insert(
808 "PRIVATE_DNS_DOMAIN".to_string(),
809 "private.example".to_string(),
810 );
811 config
812 .env
813 .insert("PRIVATE_DNS_SERVERS".to_string(), "10.0.0.3".to_string());
814
815 let (updates, lifecycle) = managed_updates_for_os_with_result(&config, "macos")
816 .await
817 .unwrap();
818
819 assert_eq!(updates.len(), 1);
820 assert_eq!(updates[0].item_id, "split-dns");
821 assert_eq!(updates[0].details, ["Servers: 10.0.0.2 -> 10.0.0.3"]);
822 assert_eq!(lifecycle.summary().pending, 1);
823 assert_eq!(
824 lifecycle.outcomes[0].effects,
825 [
826 LifecycleEffect::ResourceWritePreviewed,
827 LifecycleEffect::ReceiptWritePreviewed,
828 ]
829 );
830
831 fs::remove_dir_all(&dir).await.unwrap();
832 }
833
834 #[tokio::test]
835 async fn installed_managed_lists_only_current_os_managed_entries() {
836 let dir = make_temp_dir().await;
837 fs::write(
838 dir.join(SYS_MANIFEST_FILE),
839 r#"
840[[entries]]
841os_id = "macos"
842item_id = "split-dns"
843label = "Private split DNS"
844status = "already-installed"
845updated_at = "1"
846managed = true
847
848[[entries]]
849os_id = "macos"
850item_id = "homebrew"
851label = "Homebrew"
852status = "installed"
853updated_at = "1"
854
855[[entries]]
856os_id = "ubuntu"
857item_id = "split-dns"
858label = "Ubuntu split DNS"
859status = "installed"
860updated_at = "1"
861managed = true
862"#,
863 )
864 .await
865 .unwrap();
866 let config = Config::new_for_test(&dir);
867
868 let rows = installed_managed_for_os(&config, "macos").await.unwrap();
869
870 assert_eq!(
871 rows,
872 [SysInstalledRow {
873 item_id: "split-dns".to_string(),
874 label: "Private split DNS".to_string(),
875 }]
876 );
877 fs::remove_dir_all(&dir).await.unwrap();
878 }
879
880 #[tokio::test]
881 async fn builtin_receipt_uninstalls_after_preset_is_deleted() {
882 let dir = make_temp_dir().await;
883 let os_dir = dir.join("presets/sys/fakeos");
884 fs::create_dir_all(&os_dir).await.unwrap();
885 let destination = dir.join("managed-output.txt");
886 fs::write(os_dir.join("desired.txt"), "managed")
887 .await
888 .unwrap();
889 fs::write(os_dir.join("init.sh"), "#!/bin/bash\n")
890 .await
891 .unwrap();
892 fs::write(
893 os_dir.join("shine.toml"),
894 format!(
895 r#"
896version = 2
897
898description = "Managed resource test"
899
900[[items]]
901id = "managed-file-test"
902label = "Managed file test"
903mode = "managed"
904driver = "managed-file"
905permissions = {{ schema_version = 1 }}
906
907[items.config]
908source = "desired.txt"
909target = {:?}
910"#,
911 destination.display().to_string()
912 ),
913 )
914 .await
915 .unwrap();
916
917 let mut config = Config::new_for_test(&dir);
918 config.is_external_presets = true;
919 let (applied, applied_lifecycle) = run_managed_for_os_with_result(
920 &config,
921 "fakeos",
922 Some("managed-file-test"),
923 SysAction::Apply,
924 false,
925 ManagedOutputMode::Explicit,
926 None,
927 )
928 .await
929 .unwrap();
930 assert_eq!(applied.updated, 1);
931 assert_eq!(applied_lifecycle.summary().changed, 1);
932 assert!(
933 applied_lifecycle.outcomes[0]
934 .effects
935 .contains(&LifecycleEffect::ReceiptWritten)
936 );
937 assert_eq!(fs::read_to_string(&destination).await.unwrap(), "managed");
938
939 let (unchanged, unchanged_lifecycle) = run_managed_for_os_with_result(
940 &config,
941 "fakeos",
942 None,
943 SysAction::Apply,
944 false,
945 ManagedOutputMode::Upgrade { verbose: false },
946 None,
947 )
948 .await
949 .unwrap();
950 assert_eq!(unchanged.updated, 0);
951 assert_eq!(unchanged.skipped, 1);
952 assert_eq!(unchanged_lifecycle.summary().unchanged, 1);
953
954 fs::write(&destination, "user edit").await.unwrap();
955 let (preserved, preserved_lifecycle) = run_managed_for_os_with_result(
956 &config,
957 "fakeos",
958 Some("managed-file-test"),
959 SysAction::Remove,
960 false,
961 ManagedOutputMode::Explicit,
962 None,
963 )
964 .await
965 .unwrap();
966 assert_eq!(preserved.failed, 1);
967 assert_eq!(preserved_lifecycle.summary().preserved, 1);
968 assert_eq!(
969 preserved_lifecycle.outcomes[0].diagnostic_codes,
970 ["sys_resource_user_modified"]
971 );
972 assert_eq!(fs::read_to_string(&destination).await.unwrap(), "user edit");
973 fs::write(&destination, "managed").await.unwrap();
974
975 fs::remove_dir_all(&os_dir).await.unwrap();
976 let (removed, removed_lifecycle) = run_managed_for_os_with_result(
977 &config,
978 "fakeos",
979 Some("managed-file-test"),
980 SysAction::Remove,
981 false,
982 ManagedOutputMode::Explicit,
983 None,
984 )
985 .await
986 .unwrap();
987 assert_eq!(removed.updated, 1);
988 assert_eq!(removed_lifecycle.summary().changed, 1);
989 assert!(
990 removed_lifecycle.outcomes[0]
991 .effects
992 .contains(&LifecycleEffect::ReceiptRemoved)
993 );
994 assert!(!destination.exists());
995 assert!(
996 SysRunManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
997 .await
998 .unwrap()
999 .entries
1000 .is_empty()
1001 );
1002
1003 fs::remove_dir_all(&dir).await.unwrap();
1004 }
1005
1006 #[tokio::test]
1007 async fn missing_env_maps_to_safe_structured_failure() {
1008 let dir = make_temp_dir().await;
1009 let os_dir = dir.join("presets/sys/fakeos");
1010 fs::create_dir_all(&os_dir).await.unwrap();
1011 fs::write(
1012 os_dir.join("shine.toml"),
1013 format!(
1014 r#"
1015version = 2
1016description = "Managed resource test"
1017
1018[[items]]
1019id = "managed-file-test"
1020label = "Managed file test"
1021mode = "managed"
1022driver = "managed-file"
1023required_env = ["REQUIRED_TOKEN"]
1024
1025[items.config]
1026source = "desired.txt"
1027target = {:?}
1028"#,
1029 dir.join("managed-output.txt").display().to_string()
1030 ),
1031 )
1032 .await
1033 .unwrap();
1034 let mut config = Config::new_for_test(&dir);
1035 config.is_external_presets = true;
1036
1037 let (report, lifecycle) = run_managed_for_os_with_result(
1038 &config,
1039 "fakeos",
1040 Some("managed-file-test"),
1041 SysAction::Apply,
1042 true,
1043 ManagedOutputMode::Explicit,
1044 None,
1045 )
1046 .await
1047 .unwrap();
1048
1049 assert_eq!(report.failed, 1);
1050 assert_eq!(lifecycle.summary().failed, 1);
1051 assert_eq!(
1052 lifecycle.outcomes[0].diagnostic_codes,
1053 ["sys_missing_required_env"]
1054 );
1055 assert!(
1056 !serde_json::to_string(&lifecycle)
1057 .unwrap()
1058 .contains("REQUIRED_TOKEN")
1059 );
1060 fs::remove_dir_all(&dir).await.unwrap();
1061 }
1062
1063 #[tokio::test]
1064 async fn future_manifest_rejects_apply_before_resource_mutation() {
1065 let dir = make_temp_dir().await;
1066 let os_dir = dir.join("presets/sys/fakeos");
1067 fs::create_dir_all(&os_dir).await.unwrap();
1068 let destination = dir.join("managed-output.txt");
1069 fs::write(os_dir.join("desired.txt"), "managed")
1070 .await
1071 .unwrap();
1072 fs::write(
1073 os_dir.join("shine.toml"),
1074 format!(
1075 r#"
1076version = 2
1077
1078description = "Managed resource test"
1079
1080[[items]]
1081id = "managed-file-test"
1082label = "Managed file test"
1083mode = "managed"
1084driver = "managed-file"
1085
1086[items.config]
1087source = "desired.txt"
1088target = {:?}
1089"#,
1090 destination.display().to_string()
1091 ),
1092 )
1093 .await
1094 .unwrap();
1095 fs::write(
1096 dir.join(SYS_MANIFEST_FILE),
1097 "schema_version = 2\nentries = []\n",
1098 )
1099 .await
1100 .unwrap();
1101 let mut config = Config::new_for_test(&dir);
1102 config.is_external_presets = true;
1103
1104 let error = run_managed_for_os_with_result(
1105 &config,
1106 "fakeos",
1107 Some("managed-file-test"),
1108 SysAction::Apply,
1109 false,
1110 ManagedOutputMode::Explicit,
1111 None,
1112 )
1113 .await
1114 .unwrap_err();
1115
1116 assert!(error.to_string().contains("newer than this Shine supports"));
1117 assert!(!destination.exists());
1118 fs::remove_dir_all(&dir).await.unwrap();
1119 }
1120
1121 #[test]
1122 fn managed_upgrade_output_hides_only_no_op_rows_by_default() {
1123 assert!(!should_print_managed_outcome(
1124 false,
1125 SysItemStatus::AlreadyInstalled
1126 ));
1127 assert!(!should_print_managed_outcome(false, SysItemStatus::Skipped));
1128
1129 for status in [
1130 SysItemStatus::Installed,
1131 SysItemStatus::Updated,
1132 SysItemStatus::NeedsAction,
1133 SysItemStatus::Completed,
1134 SysItemStatus::Failed,
1135 ] {
1136 assert!(should_print_managed_outcome(false, status), "{status:?}");
1137 }
1138 }
1139
1140 #[test]
1141 fn managed_upgrade_verbose_output_includes_no_op_rows() {
1142 assert!(should_print_managed_outcome(
1143 true,
1144 SysItemStatus::AlreadyInstalled
1145 ));
1146 assert!(should_print_managed_outcome(true, SysItemStatus::Skipped));
1147 }
1148}