Skip to main content

cli/sys/
managed.rs

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}