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