shine-cli 1.7.0

Give personal automation a reviewable lifecycle
Documentation
use super::{SysDriverKind, SysItem};
use crate::config::Config;
use crate::sys::drivers::{managed_file, split_dns};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

pub(super) const RECEIPT_VERSION: u32 = 1;

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "driver", rename_all = "kebab-case")]
pub(super) enum SystemReceipt {
    Script { version: u32 },
    SplitDns(SplitDnsReceipt),
    ManagedFile(ManagedFileReceipt),
}

impl SystemReceipt {
    pub(super) fn script() -> Self {
        Self::Script {
            version: RECEIPT_VERSION,
        }
    }

    pub(super) fn driver(&self) -> SysDriverKind {
        match self {
            Self::Script { .. } => SysDriverKind::Script,
            Self::SplitDns(_) => SysDriverKind::SplitDns,
            Self::ManagedFile(_) => SysDriverKind::ManagedFile,
        }
    }

    pub(super) fn requires_admin(&self) -> bool {
        match self {
            Self::Script { .. } => false,
            Self::SplitDns(_) => true,
            Self::ManagedFile(receipt) => receipt.privileged,
        }
    }

    fn ensure_supported(&self) -> Result<()> {
        let version = match self {
            Self::Script { version } => *version,
            Self::SplitDns(receipt) => receipt.version,
            Self::ManagedFile(receipt) => receipt.version,
        };
        if version != RECEIPT_VERSION {
            bail!("unsupported system resource receipt version {version}");
        }
        Ok(())
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub(super) struct SplitDnsReceipt {
    pub(super) version: u32,
    pub(super) os_id: String,
    pub(super) item_id: String,
    pub(super) domain: String,
    pub(super) servers: Vec<String>,
    pub(super) resource: String,
    pub(super) content_hash: Option<u64>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub(super) struct ManagedFileReceipt {
    pub(super) version: u32,
    pub(super) destination: PathBuf,
    pub(super) backup: Option<PathBuf>,
    pub(super) content_hash: u64,
    pub(super) privileged: bool,
    pub(super) restart_hint: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ResourcePlan {
    pub description: String,
    pub requires_admin: bool,
    pub restart_hint: Option<String>,
}

#[derive(Clone, Debug)]
pub(super) struct ResourceOutcome {
    pub changed: bool,
    pub detail: String,
    pub receipt: Option<SystemReceipt>,
    pub restart_hint: Option<String>,
}

pub(super) struct DriverContext<'a> {
    pub config: &'a Config,
    pub os_id: &'a str,
    pub item: &'a SysItem,
    pub preset_root: &'a Path,
    pub env: &'a BTreeMap<String, String>,
    pub dry_run: bool,
}

pub(super) trait SystemDriver {
    fn plan(&self, context: &DriverContext<'_>, removing: bool) -> Result<ResourcePlan>;
    async fn apply(
        &self,
        context: &DriverContext<'_>,
        previous: Option<&SystemReceipt>,
    ) -> Result<ResourceOutcome>;
    async fn remove(
        &self,
        context: Option<&DriverContext<'_>>,
        receipt: &SystemReceipt,
        dry_run: bool,
    ) -> Result<ResourceOutcome>;
    /// Cheap, read-only check for whether `apply` would actually change anything.
    /// Used to avoid prompting for admin privileges when a resource is already converged.
    async fn is_up_to_date(
        &self,
        context: &DriverContext<'_>,
        previous: Option<&SystemReceipt>,
    ) -> Result<bool>;
}

pub(super) struct BuiltinDriver {
    kind: SysDriverKind,
}

impl BuiltinDriver {
    pub(super) fn new(kind: SysDriverKind) -> Self {
        Self { kind }
    }

    pub(super) fn update_details(
        &self,
        context: &DriverContext<'_>,
        previous: Option<&SystemReceipt>,
    ) -> Result<Vec<String>> {
        match self.kind {
            SysDriverKind::SplitDns => {
                let desired = split_dns::split_dns_desired(context)?;
                let Some(SystemReceipt::SplitDns(previous)) = previous else {
                    return Ok(vec![
                        "Receipt: missing or incompatible -> desired split DNS state".to_string(),
                    ]);
                };
                let mut details = Vec::new();
                if previous.domain != desired.domain {
                    details.push(format!("Domain: {} -> {}", previous.domain, desired.domain));
                }
                if previous.servers != desired.servers {
                    details.push(format!(
                        "Servers: {} -> {}",
                        previous.servers.join(", "),
                        desired.servers.join(", ")
                    ));
                }
                if previous.resource != desired.resource || previous.os_id != desired.os_id {
                    details.push(format!(
                        "Resource: {} -> {}",
                        previous.resource, desired.resource
                    ));
                }
                if previous.item_id != desired.item_id {
                    details.push(format!("Item: {} -> {}", previous.item_id, desired.item_id));
                }
                Ok(details)
            }
            SysDriverKind::ManagedFile | SysDriverKind::Script => Ok(Vec::new()),
        }
    }
}

impl SystemDriver for BuiltinDriver {
    fn plan(&self, context: &DriverContext<'_>, removing: bool) -> Result<ResourcePlan> {
        match self.kind {
            SysDriverKind::SplitDns => {
                let domain = config_env(context, "domain_env")?;
                let action = if removing { "remove" } else { "converge" };
                let mut description = format!("{action} split DNS for {domain}");
                if context.os_id == "ubuntu"
                    && !removing
                    && !split_dns::systemd_resolved_stub_active()
                {
                    description.push_str(
                        " [WARNING: systemd-resolved's DNS stub (127.0.0.53) looks disabled on \
                         this host, likely because another service holds port 53 (e.g. a \
                         coredns/dnsmasq container) -- the resolved.conf.d drop-in this writes \
                         will have no effect on real lookups until the stub is re-enabled or \
                         /etc/resolv.conf points at whatever is actually resolving DNS here]",
                    );
                }
                Ok(ResourcePlan {
                    description,
                    requires_admin: context.item.requires_admin,
                    restart_hint: None,
                })
            }
            SysDriverKind::ManagedFile => {
                let target = config_string(&context.item.config, "target")?;
                Ok(ResourcePlan {
                    description: format!(
                        "{} managed file {target}",
                        if removing { "remove" } else { "converge" }
                    ),
                    requires_admin: context.item.requires_admin,
                    restart_hint: optional_config_string(&context.item.config, "restart_hint"),
                })
            }
            SysDriverKind::Script => bail!("script is not a built-in system resource driver"),
        }
    }

    async fn apply(
        &self,
        context: &DriverContext<'_>,
        previous: Option<&SystemReceipt>,
    ) -> Result<ResourceOutcome> {
        if let Some(previous) = previous {
            previous.ensure_supported()?;
        }
        match self.kind {
            SysDriverKind::SplitDns => split_dns::apply_split_dns(context, previous).await,
            SysDriverKind::ManagedFile => managed_file::apply_managed_file(context, previous).await,
            SysDriverKind::Script => bail!("script is not a built-in system resource driver"),
        }
    }

    async fn remove(
        &self,
        context: Option<&DriverContext<'_>>,
        receipt: &SystemReceipt,
        dry_run: bool,
    ) -> Result<ResourceOutcome> {
        receipt.ensure_supported()?;
        match (self.kind, receipt) {
            (SysDriverKind::SplitDns, SystemReceipt::SplitDns(receipt)) => {
                split_dns::remove_split_dns(receipt, dry_run).await
            }
            (SysDriverKind::ManagedFile, SystemReceipt::ManagedFile(receipt)) => {
                managed_file::remove_managed_file(receipt, dry_run).await
            }
            (kind, receipt) => bail!(
                "receipt driver mismatch: requested {kind:?}, found {:?}",
                receipt.driver()
            ),
        }
        .with_context(|| {
            context
                .map(|context| format!("processing sys item `{}`", context.item.id))
                .unwrap_or_else(|| "processing recorded system resource".to_string())
        })
    }

    async fn is_up_to_date(
        &self,
        context: &DriverContext<'_>,
        previous: Option<&SystemReceipt>,
    ) -> Result<bool> {
        match self.kind {
            SysDriverKind::SplitDns => split_dns::split_dns_up_to_date(context).await,
            SysDriverKind::ManagedFile => {
                managed_file::managed_file_up_to_date(context, previous).await
            }
            SysDriverKind::Script => Ok(false),
        }
    }
}

pub(super) fn config_string(config: &toml::Table, key: &str) -> Result<String> {
    config
        .get(key)
        .and_then(toml::Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .map(str::to_string)
        .with_context(|| format!("driver config requires non-empty `{key}`"))
}

pub(super) fn optional_config_string(config: &toml::Table, key: &str) -> Option<String> {
    config
        .get(key)
        .and_then(toml::Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .map(str::to_string)
}

pub(super) fn config_env(context: &DriverContext<'_>, config_key: &str) -> Result<String> {
    let env_key = config_string(&context.item.config, config_key)?;
    context
        .env
        .get(&env_key)
        .filter(|value| !value.trim().is_empty())
        .cloned()
        .with_context(|| format!("missing environment variable `{env_key}`"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn receipt_roundtrips_with_version_and_driver() {
        #[derive(Serialize, Deserialize)]
        struct Wrapper {
            receipt: SystemReceipt,
        }
        let receipt = SystemReceipt::ManagedFile(ManagedFileReceipt {
            version: RECEIPT_VERSION,
            destination: PathBuf::from("/tmp/example"),
            backup: None,
            content_hash: 42,
            privileged: false,
            restart_hint: None,
        });
        let encoded = toml::to_string(&Wrapper {
            receipt: receipt.clone(),
        })
        .unwrap();
        assert!(encoded.contains("driver = \"managed-file\""));
        assert!(encoded.contains("version = 1"));
        let decoded: Wrapper = toml::from_str(&encoded).unwrap();
        assert_eq!(decoded.receipt, receipt);
    }
}