use std::collections::{HashMap, VecDeque};
use std::fmt;
use eyre::{Result, bail};
use indexmap::{IndexMap, IndexSet};
use serde::Serialize;
use crate::config::Config;
use crate::system::packages::{PackageRequest, PackageState};
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct ResourceId {
pub kind: String,
pub name: String,
}
impl ResourceId {
pub fn new(kind: impl Into<String>, name: impl Into<String>) -> Self {
Self {
kind: kind.into(),
name: name.into(),
}
}
}
impl fmt::Display for ResourceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.kind, self.name)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceAction {
Create,
Update,
Remove,
Noop,
Unknown,
}
impl fmt::Display for ResourceAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Create => "create",
Self::Update => "update",
Self::Remove => "remove",
Self::Noop => "unchanged",
Self::Unknown => "unknown",
})
}
}
#[derive(Clone, Debug, Serialize)]
pub struct ResourcePlan {
pub id: ResourceId,
pub current: String,
pub desired: String,
pub action: ResourceAction,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<ResourceId>,
}
impl ResourcePlan {
pub fn new(
id: ResourceId,
current: impl Into<String>,
desired: impl Into<String>,
action: ResourceAction,
) -> Self {
Self {
id,
current: current.into(),
desired: desired.into(),
action,
depends_on: vec![],
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
pub struct PlanSummary {
pub create: usize,
pub update: usize,
pub remove: usize,
pub unchanged: usize,
pub unknown: usize,
}
impl PlanSummary {
fn add(&mut self, action: ResourceAction) {
match action {
ResourceAction::Create => self.create += 1,
ResourceAction::Update => self.update += 1,
ResourceAction::Remove => self.remove += 1,
ResourceAction::Noop => self.unchanged += 1,
ResourceAction::Unknown => self.unknown += 1,
}
}
pub fn has_changes(self) -> bool {
self.create + self.update + self.remove > 0
}
pub fn has_unknown(self) -> bool {
self.unknown > 0
}
}
#[derive(Serialize)]
pub struct BootstrapPlanOutput<'a> {
pub resources: Vec<&'a ResourcePlan>,
pub summary: PlanSummary,
}
#[derive(Default)]
pub struct BootstrapPlan {
resources: IndexMap<ResourceId, ResourcePlan>,
}
impl BootstrapPlan {
pub fn insert(&mut self, resource: ResourcePlan) -> Result<()> {
if self.resources.contains_key(&resource.id) {
bail!(
"bootstrap resource '{}' is declared more than once",
resource.id
);
}
self.resources.insert(resource.id.clone(), resource);
Ok(())
}
pub fn add_dependency(&mut self, resource: &ResourceId, dependency: ResourceId) -> Result<()> {
let Some(resource) = self.resources.get_mut(resource) else {
bail!("cannot add dependency to missing bootstrap resource '{resource}'");
};
if !resource.depends_on.contains(&dependency) {
resource.depends_on.push(dependency);
}
Ok(())
}
pub fn output(&self) -> Result<BootstrapPlanOutput<'_>> {
let resources = self.ordered()?;
let mut summary = PlanSummary::default();
for resource in &resources {
summary.add(resource.action);
}
Ok(BootstrapPlanOutput { resources, summary })
}
fn ordered(&self) -> Result<Vec<&ResourcePlan>> {
let mut incoming = self
.resources
.keys()
.cloned()
.map(|id| (id, 0_usize))
.collect::<IndexMap<_, _>>();
let mut outgoing: HashMap<ResourceId, Vec<ResourceId>> = HashMap::new();
for resource in self.resources.values() {
for dependency in &resource.depends_on {
let Some(count) = incoming.get_mut(&resource.id) else {
unreachable!("every resource was added to incoming")
};
if !self.resources.contains_key(dependency) {
bail!(
"bootstrap resource '{}' depends on missing resource '{}'",
resource.id,
dependency
);
}
*count += 1;
outgoing
.entry(dependency.clone())
.or_default()
.push(resource.id.clone());
}
}
let mut ready = incoming
.iter()
.filter_map(|(id, count)| (*count == 0).then_some(id.clone()))
.collect::<VecDeque<_>>();
let mut ordered = Vec::with_capacity(self.resources.len());
while let Some(id) = ready.pop_front() {
ordered.push(&self.resources[&id]);
if let Some(dependents) = outgoing.get(&id) {
for dependent in dependents {
let count = incoming
.get_mut(dependent)
.expect("dependent resource is present");
*count -= 1;
if *count == 0 {
ready.push_back(dependent.clone());
}
}
}
}
if ordered.len() != self.resources.len() {
let cycle = incoming
.into_iter()
.filter_map(|(id, count)| (count > 0).then_some(id.to_string()))
.collect::<Vec<_>>()
.join(", ");
bail!("bootstrap resource dependency cycle: {cycle}");
}
Ok(ordered)
}
}
pub async fn plan(
config: &Config,
secrets: &super::secrets::SecretValues,
) -> Result<BootstrapPlan> {
let mut plan = BootstrapPlan::default();
let accounts = super::accounts::prepare_requests_from_config(config)?;
let group_states = accounts
.groups
.iter()
.map(|group| (group.name.clone(), group.state))
.collect::<HashMap<_, _>>();
let user_states = accounts
.users
.iter()
.map(|user| (user.name.clone(), user.state))
.collect::<HashMap<_, _>>();
for group in &accounts.groups {
plan.insert(group.plan())?;
}
for user in &accounts.users {
plan.insert(user.plan())?;
if user.state == super::accounts::AccountState::Present {
for group in user
.group
.iter()
.chain(user.groups.iter().flat_map(|groups| groups.iter()))
{
if group_states.get(group) == Some(&super::accounts::AccountState::Present) {
plan.add_dependency(
&ResourceId::new("user", &user.name),
ResourceId::new("group", group),
)?;
}
}
}
}
for group in accounts
.groups
.iter()
.filter(|group| group.state == super::accounts::AccountState::Absent)
{
for user in accounts.users.iter().filter(|user| {
user.state == super::accounts::AccountState::Absent
|| user.current_primary_group() == Some(group.name.as_str())
}) {
plan.add_dependency(
&ResourceId::new("group", &group.name),
ResourceId::new("user", &user.name),
)?;
}
}
for manager_packages in super::packages_from_config(config) {
let manager = manager_packages.manager;
let manager_name = manager.name().to_string();
let unavailable = if manager_packages.disabled {
Some("excluded by system_packages.managers".to_string())
} else {
manager.unavailable_reason_async().await
};
if let Some(reason) = unavailable {
for request in manager_packages.requests {
plan.insert(ResourcePlan::new(
ResourceId::new("package", format!("{manager_name}:{}", request.name)),
format!("unavailable ({reason})"),
desired_package(&request),
ResourceAction::Unknown,
))?;
}
continue;
}
let supports_version_pins = manager.supports_version_pins();
for status in manager.installed(&manager_packages.requests).await? {
let id = ResourceId::new("package", format!("{manager_name}:{}", status.request.name));
let desired = desired_package(&status.request);
let (current, action) =
package_resource_state(status.state, &status.request, supports_version_pins);
plan.insert(ResourcePlan::new(id, current, desired, action))?;
}
}
for (manager_name, requests) in
super::pending_plugin_packages_from_config_including_disabled(config)
{
let reason = if super::package_manager_is_enabled(&manager_name) {
"package plugin is not installed"
} else {
"excluded by system_packages.managers"
};
for request in requests {
plan.insert(ResourcePlan::new(
ResourceId::new("package", format!("{manager_name}:{}", request.name)),
format!("unavailable ({reason})"),
desired_package(&request),
ResourceAction::Unknown,
))?;
}
}
let (files, directories, unavailable_files) =
super::managed_files::status_requests_from_config(config, secrets)?;
super::managed_files::validate_principals(
&files,
&directories,
cfg!(target_os = "linux").then_some(&accounts),
cfg!(target_os = "linux"),
)?;
let services = super::services::status_requests_from_config(config)?;
super::services::validate_notifications(&files, &directories, &services)?;
let notified_services = super::managed_files::pending_notifications(&files, &directories)?;
let directory_states = directories
.iter()
.map(|directory| (directory.path.clone(), directory.state))
.collect::<std::collections::HashMap<_, _>>();
for directory in &directories {
let resource = directory.plan()?;
plan.insert(resource)?;
add_account_dependencies(
&mut plan,
&ResourceId::new("directory", directory.path.to_string_lossy()),
directory.state,
directory.owner.as_deref(),
directory.group.as_deref(),
&user_states,
&group_states,
)?;
}
for directory in &directories {
let Some((parent, parent_state)) = directory
.path
.ancestors()
.skip(1)
.find_map(|parent| directory_states.get_key_value(parent))
else {
continue;
};
let id = ResourceId::new("directory", directory.path.to_string_lossy());
let parent_id = ResourceId::new("directory", parent.to_string_lossy());
match (directory.state, parent_state) {
(
super::managed_files::ManagedState::Present,
super::managed_files::ManagedState::Present,
) => {
plan.add_dependency(&id, parent_id)?;
}
(
super::managed_files::ManagedState::Absent,
super::managed_files::ManagedState::Absent,
) => {
plan.add_dependency(&parent_id, id)?;
}
(
super::managed_files::ManagedState::Present,
super::managed_files::ManagedState::Absent,
) => {
bail!(
"directory '{}' cannot be present while managed parent '{}' is absent",
directory.path.display(),
parent.display()
);
}
(
super::managed_files::ManagedState::Absent,
super::managed_files::ManagedState::Present,
) => {}
}
}
for file in files {
let resource = file.plan()?;
let id = resource.id.clone();
plan.insert(resource)?;
add_account_dependencies(
&mut plan,
&id,
file.state,
file.owner.as_deref(),
file.group.as_deref(),
&user_states,
&group_states,
)?;
if let Some((parent, parent_state)) = file
.path
.ancestors()
.skip(1)
.find_map(|parent| directory_states.get_key_value(parent))
{
let parent_id = ResourceId::new("directory", parent.to_string_lossy());
match (file.state, parent_state) {
(
super::managed_files::ManagedState::Present,
super::managed_files::ManagedState::Present,
) => {
plan.add_dependency(&id, parent_id)?;
}
(
super::managed_files::ManagedState::Absent,
super::managed_files::ManagedState::Absent,
) => {
plan.add_dependency(&parent_id, id)?;
}
(
super::managed_files::ManagedState::Present,
super::managed_files::ManagedState::Absent,
) => {
bail!(
"file '{}' cannot be present while managed parent '{}' is absent",
file.path.display(),
parent.display()
);
}
(
super::managed_files::ManagedState::Absent,
super::managed_files::ManagedState::Present,
) => {}
}
}
}
for resource in unavailable_files {
plan.insert(resource)?;
}
let service_dependencies = plan
.resources
.keys()
.filter(|id| matches!(id.kind.as_str(), "package" | "file" | "directory"))
.cloned()
.collect::<Vec<_>>();
for resource in super::services::plans_with_notifications(&services, ¬ified_services) {
let id = resource.id.clone();
plan.insert(resource)?;
for dependency in &service_dependencies {
plan.add_dependency(&id, dependency.clone())?;
}
}
if let Some(mut firewall) = super::firewall::prepare_request_from_config(config)? {
super::firewall::inspect_request(&mut firewall)?;
let dependencies = plan
.resources
.keys()
.filter(|id| {
matches!(
id.kind.as_str(),
"package" | "file" | "directory" | "service"
)
})
.cloned()
.collect::<Vec<_>>();
let firewall_resources = firewall.plans();
for resource in firewall_resources {
plan.insert(resource)?;
}
let policy_id = ResourceId::new("firewall", "linux");
let rule_ids = plan
.resources
.keys()
.filter(|id| id.kind == "firewall-rule")
.cloned()
.collect::<Vec<_>>();
for rule_id in &rule_ids {
for dependency in &dependencies {
plan.add_dependency(rule_id, dependency.clone())?;
}
plan.add_dependency(&policy_id, rule_id.clone())?;
}
for dependency in &dependencies {
plan.add_dependency(&policy_id, dependency.clone())?;
}
}
let mut compose = super::compose::prepare_requests_from_config(config)?;
super::compose::inspect_requests(&mut compose);
for request in &compose {
let mut dependencies = request
.path_dependencies()
.iter()
.filter(|dependency| plan.resources.contains_key(*dependency))
.cloned()
.collect::<IndexSet<_>>();
let firewall = ResourceId::new("firewall", "linux");
if plan.resources.contains_key(&firewall) {
dependencies.insert(firewall);
}
for dependency in request.explicit_dependencies() {
if !plan.resources.contains_key(dependency) {
bail!(
"bootstrap compose project '{}' depends on missing resource '{}'",
request.name,
dependency
);
}
dependencies.insert(dependency.clone());
}
let dependency_changed = dependencies.iter().any(|dependency| {
plan.resources.get(dependency).is_some_and(|resource| {
matches!(
resource.action,
ResourceAction::Create | ResourceAction::Update | ResourceAction::Remove
)
})
});
let resource = request.plan_with_dependency_change(dependency_changed);
let id = resource.id.clone();
plan.insert(resource)?;
for dependency in dependencies {
plan.add_dependency(&id, dependency)?;
}
}
plan.output()?;
Ok(plan)
}
fn add_account_dependencies(
plan: &mut BootstrapPlan,
resource: &ResourceId,
state: super::managed_files::ManagedState,
owner: Option<&str>,
group: Option<&str>,
user_states: &HashMap<String, super::accounts::AccountState>,
group_states: &HashMap<String, super::accounts::AccountState>,
) -> Result<()> {
if state != super::managed_files::ManagedState::Present {
return Ok(());
}
if let Some(owner) = owner {
match user_states.get(owner) {
Some(super::accounts::AccountState::Present) => {
plan.add_dependency(resource, ResourceId::new("user", owner))?;
}
Some(super::accounts::AccountState::Absent) => bail!(
"bootstrap resource '{resource}' requires owner '{owner}', but that user is absent"
),
None => {}
}
}
if let Some(group) = group {
match group_states.get(group) {
Some(super::accounts::AccountState::Present) => {
plan.add_dependency(resource, ResourceId::new("group", group))?;
}
Some(super::accounts::AccountState::Absent) => bail!(
"bootstrap resource '{resource}' requires group '{group}', but that group is absent"
),
None => {}
}
}
Ok(())
}
fn desired_package(request: &super::packages::PackageRequest) -> String {
request
.version
.as_ref()
.map(|version| format!("installed ({version})"))
.unwrap_or_else(|| "installed (any version)".to_string())
}
fn package_resource_state(
state: PackageState,
request: &PackageRequest,
supports_version_pins: bool,
) -> (String, ResourceAction) {
let unsupported_pin = request.version.is_some() && !supports_version_pins;
match state {
PackageState::Installed { version } => {
(format!("installed ({version})"), ResourceAction::Noop)
}
PackageState::Missing if unsupported_pin => (
"missing (manager cannot install pinned versions)".to_string(),
ResourceAction::Unknown,
),
PackageState::Missing => ("missing".to_string(), ResourceAction::Create),
PackageState::NeedsRepair { installed } => (
format!("{installed} (needs repair)"),
ResourceAction::Update,
),
PackageState::VersionMismatch { installed } if unsupported_pin => (
format!("{installed} (manager cannot install pinned versions)"),
ResourceAction::Unknown,
),
PackageState::VersionMismatch { installed } => (installed, ResourceAction::Update),
#[cfg(unix)]
PackageState::Unavailable { reason } => {
(format!("skipped ({reason})"), ResourceAction::Unknown)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn resource(name: &str) -> ResourcePlan {
ResourcePlan::new(
ResourceId::new("test", name),
"missing",
"present",
ResourceAction::Create,
)
}
fn depends_on(
mut resource: ResourcePlan,
dependencies: impl IntoIterator<Item = ResourceId>,
) -> ResourcePlan {
resource.depends_on.extend(dependencies);
resource
}
fn package_request(version: Option<&str>) -> PackageRequest {
PackageRequest {
name: "example".to_string(),
version: version.map(str::to_string),
tap_url: None,
}
}
#[test]
fn orders_dependencies_before_dependents() {
let mut plan = BootstrapPlan::default();
plan.insert(depends_on(
resource("service"),
[ResourceId::new("test", "file")],
))
.unwrap();
plan.insert(resource("file")).unwrap();
let output = plan.output().unwrap();
assert_eq!(output.resources[0].id.name, "file");
assert_eq!(output.resources[1].id.name, "service");
}
#[test]
fn rejects_duplicate_resources() {
let mut plan = BootstrapPlan::default();
plan.insert(resource("file")).unwrap();
let error = plan.insert(resource("file")).unwrap_err();
assert!(error.to_string().contains("declared more than once"));
}
#[test]
fn rejects_missing_dependencies() {
let mut plan = BootstrapPlan::default();
plan.insert(depends_on(
resource("service"),
[ResourceId::new("test", "file")],
))
.unwrap();
let error = plan.output().err().unwrap();
assert!(error.to_string().contains("depends on missing resource"));
}
#[test]
fn rejects_dependency_cycles() {
let mut plan = BootstrapPlan::default();
plan.insert(depends_on(resource("a"), [ResourceId::new("test", "b")]))
.unwrap();
plan.insert(depends_on(resource("b"), [ResourceId::new("test", "a")]))
.unwrap();
let error = plan.output().err().unwrap();
assert!(error.to_string().contains("dependency cycle"));
}
#[test]
fn summarizes_resource_actions() {
let mut plan = BootstrapPlan::default();
for (name, action) in [
("create", ResourceAction::Create),
("update", ResourceAction::Update),
("remove", ResourceAction::Remove),
("noop", ResourceAction::Noop),
("unknown", ResourceAction::Unknown),
] {
plan.insert(ResourcePlan::new(
ResourceId::new("test", name),
"current",
"desired",
action,
))
.unwrap();
}
assert_eq!(
plan.output().unwrap().summary,
PlanSummary {
create: 1,
update: 1,
remove: 1,
unchanged: 1,
unknown: 1,
}
);
}
#[test]
fn unpinnable_missing_and_mismatched_packages_are_unknown() {
let request = package_request(Some("1.2.3"));
for state in [
PackageState::Missing,
PackageState::VersionMismatch {
installed: "1.0.0".to_string(),
},
] {
let (current, action) = package_resource_state(state, &request, false);
assert_eq!(action, ResourceAction::Unknown);
assert!(current.contains("cannot install pinned versions"));
}
}
#[test]
fn unpinnable_package_repair_remains_actionable() {
let request = package_request(Some("1.2.3"));
let (_, action) = package_resource_state(
PackageState::NeedsRepair {
installed: "1.2.3".to_string(),
},
&request,
false,
);
assert_eq!(action, ResourceAction::Update);
}
#[test]
fn managers_with_pin_support_plan_missing_and_mismatched_packages() {
let request = package_request(Some("1.2.3"));
let (_, missing_action) = package_resource_state(PackageState::Missing, &request, true);
let (_, mismatch_action) = package_resource_state(
PackageState::VersionMismatch {
installed: "1.0.0".to_string(),
},
&request,
true,
);
assert_eq!(missing_action, ResourceAction::Create);
assert_eq!(mismatch_action, ResourceAction::Update);
}
}