1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
//! Pre-apply snapshots and numbered generations (FJ-1381, FJ-1386, GH-376).
//!
//! Extracted from `apply.rs`, which sits over the 500-line ratchet. Both
//! features hang off one config key, `policy.snapshot_generations`, which is
//! at once the on-switch and the retention count — and that overloading is
//! what made `forjar undo` inert on every default config, since undo has
//! nothing to target unless something here ran.
//!
//! # GH-376: a generation is recorded AFTER the apply, not before
//!
//! Both halves used to run before `executor::apply`. That made every recorded
//! generation internally inconsistent and `current` a lie:
//!
//! ```text
//! gen 0 config_hash = v1 state.lock = (absent) <- pre-v1
//! gen 1 config_hash = v2 state.lock = v1
//! gen 2 * config_hash = v3 state.lock = v2 <- `current`
//! live state.lock = v3 <- no generation holds it
//! ```
//!
//! The metadata described the config being applied while the snapshot beside it
//! held the state from BEFORE that apply, so no generation ever paired a config
//! with the state it produced — snapshotting the config body into that layout
//! would have captured the very config the generation exists to undo. And after
//! N applies the newest generation was N−1 while the host was at N, so
//! `undo --generations 1` reached back TWO applies.
//!
//! Recording after a successful apply fixes both at the source: generation N
//! holds the lock apply N produced and the config that produced it, `current`
//! names the state the host is actually in, and `target = current - generations`
//! is once again the right arithmetic. The empty generation 0 disappears — the
//! first apply now records its own result, so the second apply is still
//! undoable, which is the property the old pre-apply gen 0 existed to preserve.
//!
//! The pre-apply NAMED snapshot (`snapshots/pre-apply-*`) is unaffected and
//! still runs before the apply, where it belongs.
use crate::core::types;
use std::path::Path;
thread_local! {
/// GH-376: set while `undo` replays a recorded generation.
static RECORDING_PAUSED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Scope guard that stops the apply underneath it from appending a generation.
///
/// `undo` re-converges the host by applying the TARGET generation's recorded
/// config. Letting that apply append a fresh generation would leave `current`
/// past the generation the host is now in, so a second `undo` would target the
/// state it had just restored and report "no changes" — an undo that cannot be
/// repeated. Rolling the `current` pointer back and leaving it there is both
/// truthful and what `rollback --generation` already does.
pub(super) struct PauseGenerationRecording {
previous: bool,
}
impl PauseGenerationRecording {
pub(super) fn new() -> Self {
let previous = RECORDING_PAUSED.with(|p| p.replace(true));
Self { previous }
}
}
impl Drop for PauseGenerationRecording {
fn drop(&mut self) {
RECORDING_PAUSED.with(|p| p.set(self.previous));
}
}
/// FJ-1381: Auto-snapshot before apply if `snapshot_generations` is set.
///
/// A named snapshot copies the state dir, so it needs one to exist.
pub(super) fn maybe_auto_snapshot(
config: &types::ForjarConfig,
state_dir: &Path,
dry_run: bool,
verbose: bool,
) {
let Some(gens) = config.policy.snapshot_generations else {
return;
};
if gens == 0 || dry_run || !state_dir.exists() {
return;
}
save_pre_apply_snapshot(state_dir, gens, verbose);
}
/// GH-376: record the generation the apply just produced, with the config that
/// produced it.
///
/// Called after EVERY apply, failures included: a generation is a record of
/// what happened, and `--rollback-on-failure` needs the failed run's generation
/// to rewind to. Gating this on success made a stack with one failing resource
/// record none at all, for ever.
pub(super) fn maybe_record_generation(
config: &types::ForjarConfig,
state_dir: &Path,
dry_run: bool,
verbose: bool,
) {
let Some(gens) = config.policy.snapshot_generations else {
return;
};
if gens == 0 || dry_run || RECORDING_PAUSED.with(std::cell::Cell::get) {
return;
}
record_generation(state_dir, config, gens, verbose);
}
/// FJ-1381: save the pre-apply named snapshot, then GC older ones.
fn save_pre_apply_snapshot(state_dir: &Path, gens: u32, verbose: bool) {
let snap_name = format!("pre-apply-{}", crate::tripwire::eventlog::now_iso8601());
if let Err(e) = super::snapshot::cmd_snapshot_save(&snap_name, state_dir) {
eprintln!("warning: pre-apply snapshot failed: {e}");
} else if verbose {
eprintln!("snapshot: saved {snap_name}");
}
gc_old_snapshots(state_dir, gens, verbose);
}
/// FJ-1386: record a numbered generation for instant rollback, then GC old ones.
fn record_generation(state_dir: &Path, config: &types::ForjarConfig, gens: u32, verbose: bool) {
match super::generation::create_generation(state_dir, Some(config)) {
Ok(gen) => {
if verbose {
eprintln!("generation: created gen {gen}");
}
super::generation::gc_generations(state_dir, gens, verbose);
}
Err(e) => eprintln!("warning: generation creation failed: {e}"),
}
}
/// FJ-1381: Garbage-collect old snapshots, keeping only the newest `keep`.
fn gc_old_snapshots(state_dir: &Path, keep: u32, verbose: bool) {
let snap_dir = super::snapshot::snapshots_dir(state_dir);
if !snap_dir.exists() {
return;
}
let mut entries: Vec<_> = match std::fs::read_dir(&snap_dir) {
Ok(e) => e.flatten().filter(|e| e.path().is_dir()).collect(),
Err(_) => return,
};
let to_remove = super::apply_gates::snapshots_to_remove(entries.len(), keep);
if to_remove == 0 {
return;
}
entries.sort_by_key(|e| e.file_name());
for entry in entries.iter().take(to_remove) {
if verbose {
eprintln!(
"snapshot gc: removing {}",
entry.file_name().to_string_lossy()
);
}
let _ = std::fs::remove_dir_all(entry.path());
}
}