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