pub mod declared;
pub mod identity;
pub mod replay;
pub mod retention;
mod rename;
#[cfg(test)]
mod tests_file_identity;
use crate::core::types::{GlobalLock, MachineSummary};
use crate::tripwire::eventlog::now_iso8601;
pub use identity::stamped_config_file;
use identity::{records_config_file, same_config_file};
use indexmap::IndexMap;
use rename::{next_stamp, retire_renamed};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StackStamp {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
pub last_apply: String,
pub generator: String,
#[serde(default)]
pub machines: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub outputs: Vec<String>,
}
pub const SCHEMA_CURRENT: &str = "1.1";
pub const SCHEMA_KNOWN: [&str; 3] = ["1", "1.0", SCHEMA_CURRENT];
impl GlobalLock {
#[must_use]
pub fn stamp_for(&self, name: &str) -> Option<&StackStamp> {
self.stacks.get(name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StackConflict {
OtherFile {
old: String,
},
MachineOwnedBy {
machine: String,
stack: String,
},
}
impl fmt::Display for StackConflict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OtherFile { old } => write!(
f,
"was last applied from {old}; this apply re-stamps it from a different -f. \
If that is not a rename, one of -f/--state-dir points at the wrong stack \
(`forjar undo` refuses this combination)."
),
Self::MachineOwnedBy { machine, stack } => write!(
f,
"would write machine '{machine}', which stack '{stack}' owns in this state dir; \
generations and machine locks are keyed by machine name alone, so the two \
stacks would overwrite each other. If that is not intended, one of \
-f/--state-dir points at the wrong stack (`forjar undo` refuses this \
combination)."
),
}
}
}
#[must_use]
pub fn stack_names(lock: &GlobalLock) -> Vec<String> {
lock.stacks.keys().cloned().collect()
}
#[must_use]
pub fn multi_stack_restore_refusal(
lock: &GlobalLock,
state_dir: &str,
target: &str,
) -> Option<String> {
let names = stack_names(lock);
if names.len() <= 1 {
return None;
}
let quoted: Vec<String> = names.iter().map(|n| format!("'{n}'")).collect();
Some(format!(
"refusing to restore {target}: state dir {state_dir} holds {} stacks ({}); \
a restore is whole-dir, so it would revert every one of them. Stack-scoped \
restore is PMAT-162; until it lands, give this stack a --state-dir of its own.",
names.len(),
quoted.join(", "),
))
}
pub fn check_schema(schema: &str, path: &Path) -> Result<(), String> {
if SCHEMA_KNOWN.contains(&schema) {
return Ok(());
}
Err(format!(
"global lock {} has schema '{}', which forjar {} does not understand \
(it reads {}). Upgrade forjar; do not apply against this state dir.",
path.display(),
schema,
env!("CARGO_PKG_VERSION"),
SCHEMA_KNOWN.join(" / "),
))
}
pub fn migrate(lock: &mut GlobalLock) {
if lock.stacks.is_empty() && !lock.name.is_empty() {
let stamp = StackStamp {
file: None,
last_apply: lock.last_apply.clone(),
generator: lock.generator.clone(),
machines: lock.machines.keys().cloned().collect(),
outputs: lock.outputs.keys().cloned().collect(),
};
lock.stacks.insert(lock.name.clone(), stamp);
}
lock.schema = SCHEMA_CURRENT.to_string();
}
fn machine_owner(
lock: &GlobalLock,
state_dir: &Path,
name: &str,
machine: &str,
config_file: Option<&Path>,
) -> Option<String> {
lock.stacks
.iter()
.find(|(stack, stamp)| {
stack.as_str() != name
&& stamp.machines.iter().any(|m| m == machine)
&& !records_config_file(stamp, state_dir, config_file)
})
.map(|(stack, _)| stack.clone())
}
#[must_use]
pub fn stack_conflict(
lock: &GlobalLock,
state_dir: &Path,
name: &str,
config_file: Option<&Path>,
machines: &[String],
) -> Option<StackConflict> {
if let Some(old) = stack_written_from_other_file(lock, state_dir, name, config_file) {
return Some(StackConflict::OtherFile { old });
}
machines.iter().find_map(|machine| {
machine_owner(lock, state_dir, name, machine, config_file).map(|stack| {
StackConflict::MachineOwnedBy {
machine: machine.clone(),
stack,
}
})
})
}
#[must_use]
pub fn stack_written_from_other_file(
lock: &GlobalLock,
state_dir: &Path,
name: &str,
config_file: Option<&Path>,
) -> Option<String> {
let recorded = lock.stamp_for(name)?.file.as_ref()?;
let current = config_file?;
(!same_config_file(recorded, state_dir, current)).then(|| recorded.clone())
}
pub fn apply_stamp(
lock: &mut GlobalLock,
state_dir: &Path,
config: (&str, Option<&Path>), machine_results: &[(String, usize, usize, usize)], ) {
let stamped_as = replay::stamping_name(config.0);
let config_name = stamped_as.as_str();
let stamped_from = replay::stamping_file(config.1);
let config_file = stamped_from.as_deref();
let now = now_iso8601();
let generator = format!("forjar {}", env!("CARGO_PKG_VERSION"));
lock.schema = SCHEMA_CURRENT.to_string();
lock.name = config_name.to_string();
lock.last_apply.clone_from(&now);
lock.generator.clone_from(&generator);
let retired = retire_renamed(lock, state_dir, (config_name, config_file));
let stamp = next_stamp(
lock.stamp_for(config_name),
&retired,
stamped_config_file(state_dir, config_file),
machine_results.iter().map(|(m, ..)| m.clone()).collect(),
declared::declared().as_deref(),
(&now, &generator),
);
lock.stacks.insert(config_name.to_string(), stamp);
for (name, total, converged, failed) in machine_results {
lock.machines.insert(
name.clone(),
MachineSummary {
resources: *total,
converged: *converged,
failed: *failed,
last_apply: now.clone(),
},
);
}
}
pub fn merge_outputs(lock: &mut GlobalLock, config_name: &str, outputs: &IndexMap<String, String>) {
let owner = replay::stamping_name(config_name);
let config_name = owner.as_str();
for key in owned_output_keys(lock, config_name) {
lock.outputs.shift_remove(&key);
}
for (key, value) in outputs {
lock.outputs.insert(key.clone(), value.clone());
}
let owned: Vec<String> = outputs.keys().cloned().collect();
match lock.stacks.get_mut(config_name) {
Some(stamp) => stamp.outputs = owned,
None => {
lock.stacks.insert(
config_name.to_string(),
StackStamp {
file: None,
last_apply: now_iso8601(),
generator: format!("forjar {}", env!("CARGO_PKG_VERSION")),
machines: Vec::new(),
outputs: owned,
},
);
}
}
}
fn owned_output_keys(lock: &GlobalLock, config_name: &str) -> Vec<String> {
lock.stamp_for(config_name)
.map(|stamp| stamp.outputs.clone())
.unwrap_or_default()
}