1use anyhow::{Context, Result, bail};
2use std::collections::BTreeSet;
3
4use crate::colors;
5use crate::config::Config;
6use crate::env::EnvConfig;
7
8use super::commands::current_unix_timestamp;
9use super::detect::detect_os_id;
10use super::execution::{print_item_outcome, run_sys_item_action, sys_init_command};
11use super::manifest::load_sys_preset;
12use super::resources::{self, SystemDriver};
13use super::run_manifest::{SysRunEntry, SysRunManifest};
14use super::{
15 SysDriverKind, SysInstalledRow, SysItem, SysItemMode, SysItemOutcome, SysItemStatus,
16 SysUpdateRow, SysUpgradeReport,
17};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub(super) enum SysAction {
21 Apply,
22 Remove,
23}
24
25impl SysAction {
26 pub(super) fn as_str(self) -> &'static str {
27 match self {
28 Self::Apply => "apply",
29 Self::Remove => "remove",
30 }
31 }
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35enum ManagedOutputMode {
36 Explicit,
37 Upgrade { verbose: bool },
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 let report = run_managed(
52 config,
53 item,
54 SysAction::Apply,
55 dry_run,
56 ManagedOutputMode::Explicit,
57 None,
58 )
59 .await?;
60 if report.failed > 0 {
61 bail!(
62 "{} managed system configuration item(s) failed",
63 report.failed
64 );
65 }
66 Ok(())
67}
68
69pub async fn handle_uninstall(config: &Config, item: &str, dry_run: bool) -> Result<()> {
70 let report = run_managed(
71 config,
72 Some(item),
73 SysAction::Remove,
74 dry_run,
75 ManagedOutputMode::Explicit,
76 None,
77 )
78 .await?;
79 if report.failed > 0 {
80 bail!("failed to remove managed system configuration `{item}`");
81 }
82 Ok(())
83}
84
85pub async fn handle_upgrade_managed(
86 config: &Config,
87 verbose: bool,
88 sep: &mut crate::output::SectionSeparator,
89) -> Result<SysUpgradeReport> {
90 handle_upgrade_managed_target(config, None, verbose, sep).await
91}
92
93pub async fn handle_upgrade_managed_target(
94 config: &Config,
95 item: Option<&str>,
96 verbose: bool,
97 sep: &mut crate::output::SectionSeparator,
98) -> Result<SysUpgradeReport> {
99 run_managed(
100 config,
101 item,
102 SysAction::Apply,
103 false,
104 ManagedOutputMode::Upgrade { verbose },
105 Some(sep),
106 )
107 .await
108}
109
110pub async fn managed_updates(config: &Config) -> Result<Vec<SysUpdateRow>> {
111 let os_id = detect_os_id().await?;
112 managed_updates_for_os(config, &os_id).await
113}
114
115pub(crate) async fn installed_managed(config: &Config) -> Result<Vec<SysInstalledRow>> {
116 let os_id = detect_os_id().await?;
117 installed_managed_for_os(config, &os_id).await
118}
119
120async fn installed_managed_for_os(config: &Config, os_id: &str) -> Result<Vec<SysInstalledRow>> {
121 let run_manifest = SysRunManifest::load(config.shine_dir()).await?;
122 let mut rows = run_manifest
123 .entries
124 .into_iter()
125 .filter(|entry| entry.os_id == os_id && entry.managed)
126 .map(|entry| SysInstalledRow {
127 item_id: entry.item_id,
128 label: entry.label,
129 })
130 .collect::<Vec<_>>();
131 rows.sort_by(|left, right| {
132 left.label
133 .cmp(&right.label)
134 .then_with(|| left.item_id.cmp(&right.item_id))
135 });
136 Ok(rows)
137}
138
139async fn managed_updates_for_os(config: &Config, os_id: &str) -> Result<Vec<SysUpdateRow>> {
140 let run_manifest = SysRunManifest::load(config.shine_dir()).await?;
141 let recorded = run_manifest
142 .entries
143 .iter()
144 .filter(|entry| entry.os_id == os_id && entry.managed)
145 .collect::<Vec<_>>();
146 if recorded.is_empty() {
147 return Ok(Vec::new());
148 }
149
150 let loaded = load_sys_preset(config, os_id).await?;
151 let env = EnvConfig::load_or_init(config).await?;
152 let preset_root = loaded
153 .script_path
154 .parent()
155 .with_context(|| format!("invalid script path: {}", loaded.script_path.display()))?;
156 let mut updates = Vec::new();
157
158 for entry in recorded {
159 let Some(item) = loaded.manifest.items.iter().find(|item| {
160 item.id == entry.item_id
161 && item.mode == SysItemMode::Managed
162 && item.driver != SysDriverKind::Script
163 }) else {
164 continue;
165 };
166 let context = resources::DriverContext {
167 config,
168 os_id,
169 item,
170 preset_root,
171 env: env.as_map(),
172 dry_run: true,
173 };
174 let details = resources::BuiltinDriver::new(item.driver)
175 .update_details(&context, entry.receipt.as_ref())?;
176 if !details.is_empty() {
177 updates.push(SysUpdateRow {
178 item_id: item.id.clone(),
179 label: item.label.clone(),
180 details,
181 });
182 }
183 }
184
185 Ok(updates)
186}
187
188async fn run_managed(
189 config: &Config,
190 requested: Option<&str>,
191 action: SysAction,
192 dry_run: bool,
193 output_mode: ManagedOutputMode,
194 sep: Option<&mut crate::output::SectionSeparator>,
195) -> Result<SysUpgradeReport> {
196 let os_id = detect_os_id().await?;
197 run_managed_for_os(config, &os_id, requested, action, dry_run, output_mode, sep).await
198}
199
200async fn run_managed_for_os(
201 config: &Config,
202 os_id: &str,
203 requested: Option<&str>,
204 action: SysAction,
205 dry_run: bool,
206 output_mode: ManagedOutputMode,
207 mut sep: Option<&mut crate::output::SectionSeparator>,
208) -> Result<SysUpgradeReport> {
209 let mut run_manifest = SysRunManifest::load(config.shine_dir()).await?;
210
211 if action == SysAction::Remove
214 && let Some(item_id) = requested
215 && let Some(entry) = run_manifest
216 .entries
217 .iter()
218 .find(|entry| entry.os_id == os_id && entry.item_id == item_id)
219 .cloned()
220 && let Some(receipt) = entry.receipt.as_ref()
221 && receipt.driver() != SysDriverKind::Script
222 {
223 if let Some(sep) = &mut sep {
224 sep.begin();
225 }
226 println!("{}", colors::bold("Remove Managed System Config"));
227 println!(" {} {}", colors::symbol("•"), entry.label);
228 if receipt.requires_admin() && !dry_run && !authorize_admin(1).await? {
229 return Ok(SysUpgradeReport {
230 failed: 1,
231 ..SysUpgradeReport::default()
232 });
233 }
234 let driver = resources::BuiltinDriver::new(receipt.driver());
235 match driver.remove(None, receipt, dry_run).await {
236 Ok(outcome) => {
237 let status = if dry_run || !outcome.changed {
238 SysItemStatus::Skipped
239 } else {
240 SysItemStatus::Updated
241 };
242 print_item_outcome(
243 &SysItemOutcome {
244 item_id: item_id.to_string(),
245 label: entry.label,
246 status,
247 detail: outcome.detail,
248 logs: Vec::new(),
249 },
250 14,
251 );
252 if !dry_run {
253 run_manifest.entries.retain(|candidate| {
254 !(candidate.os_id == os_id && candidate.item_id == item_id)
255 });
256 run_manifest.save(config.shine_dir()).await?;
257 }
258 return Ok(SysUpgradeReport {
259 updated: usize::from(!dry_run && outcome.changed),
260 skipped: usize::from(dry_run || !outcome.changed),
261 failed: 0,
262 });
263 }
264 Err(error) => {
265 print_item_outcome(
266 &SysItemOutcome {
267 item_id: item_id.to_string(),
268 label: entry.label,
269 status: SysItemStatus::Failed,
270 detail: format!("{error:#}"),
271 logs: Vec::new(),
272 },
273 14,
274 );
275 return Ok(SysUpgradeReport {
276 failed: 1,
277 ..SysUpgradeReport::default()
278 });
279 }
280 }
281 }
282
283 if requested.is_none()
284 && !run_manifest
285 .entries
286 .iter()
287 .any(|entry| entry.os_id == os_id && entry.managed)
288 {
289 return Ok(SysUpgradeReport::default());
290 }
291 let loaded = load_sys_preset(config, os_id).await?;
292 let mut selected: Vec<&SysItem> = Vec::new();
293
294 if let Some(item_id) = requested {
295 let item = loaded
296 .manifest
297 .items
298 .iter()
299 .find(|candidate| candidate.id == item_id)
300 .with_context(|| format!("unknown sys item `{item_id}`"))?;
301 if item.mode != SysItemMode::Managed {
302 bail!("sys item `{item_id}` is not managed and cannot be reapplied");
303 }
304 selected.push(item);
305 } else {
306 let enabled: BTreeSet<&str> = run_manifest
307 .entries
308 .iter()
309 .filter(|entry| entry.os_id == os_id && entry.managed)
310 .map(|entry| entry.item_id.as_str())
311 .collect();
312 selected.extend(loaded.manifest.items.iter().filter(|item| {
313 item.mode == SysItemMode::Managed && enabled.contains(item.id.as_str())
314 }));
315 }
316
317 if selected.is_empty() {
318 if output_mode.is_explicit() {
319 println!(
320 "{}",
321 colors::dim("No managed system configuration items selected.")
322 );
323 }
324 return Ok(SysUpgradeReport::default());
325 }
326
327 let show_all_outcomes = output_mode.show_all_outcomes();
328 let mut section_started = false;
329 if show_all_outcomes {
330 begin_managed_section(&mut sep, action, &selected, true);
331 section_started = true;
332 }
333
334 let env = EnvConfig::load_or_init(config).await?;
335 let script_dir = loaded
336 .script_path
337 .parent()
338 .with_context(|| format!("invalid script path: {}", loaded.script_path.display()))?;
339
340 if dry_run {
341 let mut failed = 0usize;
342 for item in &selected {
343 let missing = item
344 .required_env
345 .iter()
346 .filter(|key| env.get(key).is_none_or(str::is_empty))
347 .cloned()
348 .collect::<Vec<_>>();
349 if !missing.is_empty() {
350 failed += 1;
351 eprintln!(
352 " {} {}: missing environment variable(s): {}",
353 colors::symbol("✗"),
354 item.id,
355 missing.join(", ")
356 );
357 continue;
358 }
359 if item.driver == SysDriverKind::Script {
360 println!(
361 " {} script {} {} {}",
362 colors::dim("[dry-run]"),
363 loaded.script_path.display(),
364 item.id,
365 action.as_str()
366 );
367 } else {
368 let context = resources::DriverContext {
369 config,
370 os_id,
371 item,
372 preset_root: script_dir,
373 env: env.as_map(),
374 dry_run: true,
375 };
376 match resources::BuiltinDriver::new(item.driver)
377 .plan(&context, action == SysAction::Remove)
378 {
379 Ok(plan) => println!(
380 " {} {}{}{}",
381 colors::dim("[dry-run]"),
382 plan.description,
383 if plan.requires_admin { " [admin]" } else { "" },
384 plan.restart_hint
385 .as_deref()
386 .map(|hint| format!(" [restart required: {hint}]"))
387 .unwrap_or_default()
388 ),
389 Err(error) => {
390 failed += 1;
391 eprintln!(" {} {}: {error:#}", colors::symbol_stderr("✗"), item.id);
392 }
393 }
394 }
395 }
396 return Ok(SysUpgradeReport {
397 skipped: selected.len() - failed,
398 failed,
399 ..SysUpgradeReport::default()
400 });
401 }
402
403 let mut needs_admin = false;
404 for item in &selected {
405 if !item.requires_admin {
406 continue;
407 }
408 if action != SysAction::Apply {
409 needs_admin = true;
410 break;
411 }
412 let previous_receipt = run_manifest
413 .entries
414 .iter()
415 .find(|entry| entry.os_id == os_id && entry.item_id == item.id)
416 .and_then(|entry| entry.receipt.as_ref());
417 let context = resources::DriverContext {
418 config,
419 os_id,
420 item,
421 preset_root: script_dir,
422 env: env.as_map(),
423 dry_run: false,
424 };
425 let up_to_date = item.driver != SysDriverKind::Script
426 && resources::BuiltinDriver::new(item.driver)
427 .is_up_to_date(&context, previous_receipt)
428 .await
429 .unwrap_or(false);
430 if !up_to_date {
431 needs_admin = true;
432 break;
433 }
434 }
435 if needs_admin {
436 if !section_started {
437 begin_managed_section(&mut sep, action, &selected, false);
438 section_started = true;
439 }
440 if !authorize_admin(selected.len()).await? {
441 return Ok(SysUpgradeReport {
442 failed: selected.len(),
443 ..SysUpgradeReport::default()
444 });
445 }
446 }
447
448 let command = sys_init_command(os_id);
449 let sys_shell: &'static str = config.shell_type.into();
450 let mut report = SysUpgradeReport::default();
451
452 for item in &selected {
453 let missing = item
454 .required_env
455 .iter()
456 .filter(|key| env.get(key).is_none_or(str::is_empty))
457 .cloned()
458 .collect::<Vec<_>>();
459 if !missing.is_empty() {
460 let outcome = SysItemOutcome {
461 item_id: item.id.clone(),
462 label: item.label.clone(),
463 status: SysItemStatus::Failed,
464 detail: format!("missing environment variable(s): {}", missing.join(", ")),
465 logs: Vec::new(),
466 };
467 if !section_started {
468 begin_managed_section(&mut sep, action, &selected, false);
469 section_started = true;
470 }
471 print_item_outcome(&outcome, item.label.len().max(14));
472 report.failed += 1;
473 continue;
474 }
475
476 let previous_receipt = run_manifest
477 .entries
478 .iter()
479 .find(|entry| entry.os_id == os_id && entry.item_id == item.id)
480 .and_then(|entry| entry.receipt.as_ref());
481
482 let mut next_receipt = None;
483 let mut restart_hint = None;
484 let outcome = if item.driver == SysDriverKind::Script {
485 match run_sys_item_action(
486 &command,
487 script_dir,
488 &loaded.script_path,
489 sys_shell,
490 item,
491 action,
492 env.as_map(),
493 )
494 .await
495 {
496 Ok(outcome) => {
497 if action == SysAction::Apply {
498 next_receipt = Some(resources::SystemReceipt::script());
499 }
500 outcome
501 }
502 Err(error) => SysItemOutcome {
503 item_id: item.id.clone(),
504 label: item.label.clone(),
505 status: SysItemStatus::Failed,
506 detail: format!("{error:#}"),
507 logs: Vec::new(),
508 },
509 }
510 } else {
511 let context = resources::DriverContext {
512 config,
513 os_id,
514 item,
515 preset_root: script_dir,
516 env: env.as_map(),
517 dry_run: false,
518 };
519 let driver = resources::BuiltinDriver::new(item.driver);
520 let result = match action {
521 SysAction::Apply => driver.apply(&context, previous_receipt).await,
522 SysAction::Remove => match previous_receipt {
523 Some(receipt) => driver.remove(Some(&context), receipt, false).await,
524 None => Err(anyhow::anyhow!(
525 "managed item `{}` has no receipt to remove",
526 item.id
527 )),
528 },
529 };
530 match result {
531 Ok(resource) => {
532 next_receipt = resource.receipt;
533 restart_hint = resource.restart_hint;
534 SysItemOutcome {
535 item_id: item.id.clone(),
536 label: item.label.clone(),
537 status: if resource.changed {
538 SysItemStatus::Updated
539 } else {
540 SysItemStatus::AlreadyInstalled
541 },
542 detail: resource.detail,
543 logs: Vec::new(),
544 }
545 }
546 Err(error) => SysItemOutcome {
547 item_id: item.id.clone(),
548 label: item.label.clone(),
549 status: SysItemStatus::Failed,
550 detail: format!("{error:#}"),
551 logs: Vec::new(),
552 },
553 }
554 };
555 if should_print_managed_outcome(show_all_outcomes, outcome.status) {
556 if !section_started {
557 begin_managed_section(&mut sep, action, &selected, false);
558 section_started = true;
559 }
560 print_item_outcome(&outcome, item.label.len().max(14));
561 if let Some(hint) = restart_hint
562 && outcome.status != SysItemStatus::Failed
563 {
564 println!(" {} {}", colors::symbol("!"), colors::yellow(&hint));
565 }
566 }
567 if outcome.status == SysItemStatus::Failed {
568 report.failed += 1;
569 continue;
570 }
571
572 match outcome.status {
573 SysItemStatus::Updated | SysItemStatus::Installed | SysItemStatus::Completed => {
574 report.updated += 1;
575 }
576 _ => report.skipped += 1,
577 }
578
579 if action == SysAction::Remove {
580 run_manifest
581 .entries
582 .retain(|entry| !(entry.os_id == os_id && entry.item_id == item.id));
583 } else {
584 run_manifest.upsert(SysRunEntry {
585 os_id: os_id.to_string(),
586 item_id: item.id.clone(),
587 label: item.label.clone(),
588 status: outcome.status,
589 detail: outcome.detail.clone(),
590 updated_at: current_unix_timestamp().to_string(),
591 managed: true,
592 receipt: next_receipt,
593 });
594 }
595 }
596
597 run_manifest.save(config.shine_dir()).await?;
598 Ok(report)
599}
600
601async fn authorize_admin(item_count: usize) -> Result<bool> {
602 crate::privilege::ensure_admin(item_count).await
603}
604
605fn begin_managed_section(
606 sep: &mut Option<&mut crate::output::SectionSeparator>,
607 action: SysAction,
608 selected: &[&SysItem],
609 show_selection: bool,
610) {
611 if let Some(sep) = sep {
612 sep.begin();
613 }
614 println!(
615 "{}",
616 colors::bold(match action {
617 SysAction::Apply => "Managed System Configs",
618 SysAction::Remove => "Remove Managed System Config",
619 })
620 );
621 if show_selection {
622 for item in selected {
623 println!(" {} {}", colors::symbol("•"), item.label);
624 }
625 }
626}
627
628fn should_print_managed_outcome(show_all: bool, status: SysItemStatus) -> bool {
629 show_all
630 || !matches!(
631 status,
632 SysItemStatus::AlreadyInstalled | SysItemStatus::Skipped
633 )
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::config::Config;
640 use crate::sys::run_manifest::SYS_MANIFEST_FILE;
641 use std::path::PathBuf;
642 use tokio::fs;
643
644 async fn make_temp_dir() -> PathBuf {
645 crate::test_support::make_temp_dir("shine-sys").await
646 }
647
648 #[cfg(unix)]
649 #[tokio::test]
650 async fn managed_item_apply_upgrade_and_uninstall_lifecycle() {
651 let dir = make_temp_dir().await;
652 let os_dir = dir.join("presets/sys/fakeos");
653 fs::create_dir_all(&os_dir).await.unwrap();
654 fs::write(
655 os_dir.join("shine.toml"),
656 r#"
657description = "Managed test"
658
659[[items]]
660id = "managed-test"
661label = "Managed test"
662mode = "managed"
663required_env = ["ACTION_LOG"]
664"#,
665 )
666 .await
667 .unwrap();
668 fs::write(
669 os_dir.join("init.sh"),
670 r#"#!/bin/bash
671set -eu
672printf '%s\n' "$2" >> "$ACTION_LOG"
673printf 'SHINE_SYS_STATUS\t%s\t%s\n' "updated" "$2"
674"#,
675 )
676 .await
677 .unwrap();
678
679 let action_log = dir.join("actions");
680 let mut config = Config::new_for_test(&dir);
681 config.is_external_presets = true;
682 config
683 .env
684 .insert("ACTION_LOG".to_string(), action_log.display().to_string());
685
686 run_managed_for_os(
687 &config,
688 "fakeos",
689 Some("managed-test"),
690 SysAction::Apply,
691 false,
692 ManagedOutputMode::Explicit,
693 None,
694 )
695 .await
696 .unwrap();
697 let first_manifest = SysRunManifest::load(config.shine_dir()).await.unwrap();
698 assert!(
699 first_manifest
700 .entries
701 .iter()
702 .any(|entry| { entry.item_id == "managed-test" && entry.managed })
703 );
704
705 let report = run_managed_for_os(
706 &config,
707 "fakeos",
708 None,
709 SysAction::Apply,
710 false,
711 ManagedOutputMode::Upgrade { verbose: false },
712 None,
713 )
714 .await
715 .unwrap();
716 assert_eq!(report.updated, 1);
717 run_managed_for_os(
718 &config,
719 "fakeos",
720 Some("managed-test"),
721 SysAction::Remove,
722 false,
723 ManagedOutputMode::Explicit,
724 None,
725 )
726 .await
727 .unwrap();
728
729 let actions = fs::read_to_string(&action_log).await.unwrap();
730 assert_eq!(
731 actions.lines().collect::<Vec<_>>(),
732 ["apply", "apply", "remove"]
733 );
734 let final_manifest = SysRunManifest::load(config.shine_dir()).await.unwrap();
735 assert!(final_manifest.entries.is_empty());
736
737 fs::remove_dir_all(&dir).await.unwrap();
738 }
739
740 #[tokio::test]
741 async fn managed_updates_reports_split_dns_env_change() {
742 let dir = make_temp_dir().await;
743 let os_dir = dir.join("presets/sys/macos");
744 fs::create_dir_all(&os_dir).await.unwrap();
745 fs::write(
746 os_dir.join("shine.toml"),
747 r#"
748description = "Managed test"
749
750[[items]]
751id = "split-dns"
752label = "Private split DNS"
753mode = "managed"
754driver = "split-dns"
755requires_admin = true
756required_env = ["PRIVATE_DNS_DOMAIN", "PRIVATE_DNS_SERVERS"]
757
758[items.config]
759domain_env = "PRIVATE_DNS_DOMAIN"
760servers_env = "PRIVATE_DNS_SERVERS"
761"#,
762 )
763 .await
764 .unwrap();
765 fs::write(os_dir.join("init.sh"), "#!/bin/bash\n")
766 .await
767 .unwrap();
768 fs::write(
769 dir.join(SYS_MANIFEST_FILE),
770 r#"
771[[entries]]
772os_id = "macos"
773item_id = "split-dns"
774label = "Private split DNS"
775status = "updated"
776updated_at = "1"
777managed = true
778
779[entries.receipt]
780driver = "split-dns"
781version = 1
782os_id = "macos"
783item_id = "split-dns"
784domain = "private.example"
785servers = ["10.0.0.2"]
786resource = "/etc/resolver/private.example"
787"#,
788 )
789 .await
790 .unwrap();
791
792 let mut config = Config::new_for_test(&dir);
793 config.is_external_presets = true;
794 config.env.insert(
795 "PRIVATE_DNS_DOMAIN".to_string(),
796 "private.example".to_string(),
797 );
798 config
799 .env
800 .insert("PRIVATE_DNS_SERVERS".to_string(), "10.0.0.3".to_string());
801
802 let updates = managed_updates_for_os(&config, "macos").await.unwrap();
803
804 assert_eq!(updates.len(), 1);
805 assert_eq!(updates[0].item_id, "split-dns");
806 assert_eq!(updates[0].details, ["Servers: 10.0.0.2 -> 10.0.0.3"]);
807
808 fs::remove_dir_all(&dir).await.unwrap();
809 }
810
811 #[tokio::test]
812 async fn installed_managed_lists_only_current_os_managed_entries() {
813 let dir = make_temp_dir().await;
814 fs::write(
815 dir.join(SYS_MANIFEST_FILE),
816 r#"
817[[entries]]
818os_id = "macos"
819item_id = "split-dns"
820label = "Private split DNS"
821status = "already-installed"
822updated_at = "1"
823managed = true
824
825[[entries]]
826os_id = "macos"
827item_id = "homebrew"
828label = "Homebrew"
829status = "installed"
830updated_at = "1"
831
832[[entries]]
833os_id = "ubuntu"
834item_id = "split-dns"
835label = "Ubuntu split DNS"
836status = "installed"
837updated_at = "1"
838managed = true
839"#,
840 )
841 .await
842 .unwrap();
843 let config = Config::new_for_test(&dir);
844
845 let rows = installed_managed_for_os(&config, "macos").await.unwrap();
846
847 assert_eq!(
848 rows,
849 [SysInstalledRow {
850 item_id: "split-dns".to_string(),
851 label: "Private split DNS".to_string(),
852 }]
853 );
854 fs::remove_dir_all(&dir).await.unwrap();
855 }
856
857 #[tokio::test]
858 async fn builtin_receipt_uninstalls_after_preset_is_deleted() {
859 let dir = make_temp_dir().await;
860 let os_dir = dir.join("presets/sys/fakeos");
861 fs::create_dir_all(&os_dir).await.unwrap();
862 let destination = dir.join("managed-output.txt");
863 fs::write(os_dir.join("desired.txt"), "managed")
864 .await
865 .unwrap();
866 fs::write(os_dir.join("init.sh"), "#!/bin/bash\n")
867 .await
868 .unwrap();
869 fs::write(
870 os_dir.join("shine.toml"),
871 format!(
872 r#"
873description = "Managed resource test"
874
875[[items]]
876id = "managed-file-test"
877label = "Managed file test"
878mode = "managed"
879driver = "managed-file"
880
881[items.config]
882source = "desired.txt"
883target = {:?}
884"#,
885 destination.display().to_string()
886 ),
887 )
888 .await
889 .unwrap();
890
891 let mut config = Config::new_for_test(&dir);
892 config.is_external_presets = true;
893 let applied = run_managed_for_os(
894 &config,
895 "fakeos",
896 Some("managed-file-test"),
897 SysAction::Apply,
898 false,
899 ManagedOutputMode::Explicit,
900 None,
901 )
902 .await
903 .unwrap();
904 assert_eq!(applied.updated, 1);
905 assert_eq!(fs::read_to_string(&destination).await.unwrap(), "managed");
906
907 let unchanged = run_managed_for_os(
908 &config,
909 "fakeos",
910 None,
911 SysAction::Apply,
912 false,
913 ManagedOutputMode::Upgrade { verbose: false },
914 None,
915 )
916 .await
917 .unwrap();
918 assert_eq!(unchanged.updated, 0);
919 assert_eq!(unchanged.skipped, 1);
920
921 fs::remove_dir_all(&os_dir).await.unwrap();
922 let removed = run_managed_for_os(
923 &config,
924 "fakeos",
925 Some("managed-file-test"),
926 SysAction::Remove,
927 false,
928 ManagedOutputMode::Explicit,
929 None,
930 )
931 .await
932 .unwrap();
933 assert_eq!(removed.updated, 1);
934 assert!(!destination.exists());
935 assert!(
936 SysRunManifest::load(config.shine_dir())
937 .await
938 .unwrap()
939 .entries
940 .is_empty()
941 );
942
943 fs::remove_dir_all(&dir).await.unwrap();
944 }
945
946 #[test]
947 fn managed_upgrade_output_hides_only_no_op_rows_by_default() {
948 assert!(!should_print_managed_outcome(
949 false,
950 SysItemStatus::AlreadyInstalled
951 ));
952 assert!(!should_print_managed_outcome(false, SysItemStatus::Skipped));
953
954 for status in [
955 SysItemStatus::Installed,
956 SysItemStatus::Updated,
957 SysItemStatus::NeedsAction,
958 SysItemStatus::Completed,
959 SysItemStatus::Failed,
960 ] {
961 assert!(should_print_managed_outcome(false, status), "{status:?}");
962 }
963 }
964
965 #[test]
966 fn managed_upgrade_verbose_output_includes_no_op_rows() {
967 assert!(should_print_managed_outcome(
968 true,
969 SysItemStatus::AlreadyInstalled
970 ));
971 assert!(should_print_managed_outcome(true, SysItemStatus::Skipped));
972 }
973}