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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//! 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`.
///
/// PMAT-177: NOT in a state dir several stacks share. Retention counts the
/// snapshots in the DIR while the promise it keeps — `policy.snapshot_generations`
/// — is made by ONE config, so in a shared dir alpha's apply deleted the
/// pre-apply snapshots bravo and charlie made: the copies an operator reaches
/// for to recover from exactly this class of mistake. Snapshots carry no owner,
/// so there is nothing here to count per stack yet; attributing them is
/// PMAT-162, and until it lands the apply keeps every snapshot and says why.
///
/// PMAT-182: the condition itself lives in
/// [`state::stamp::retention::skip_note`], which the generation sweep asks
/// too. It was written here, inline, and the second retention path went a day
/// without it.
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;
}
if let Some(note) = crate::core::state::stamp::retention::skip_note(state_dir, "snapshot") {
eprintln!("{note}");
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());
}
}
#[cfg(test)]
mod tests_gc_scope {
use super::*;
use crate::core::state::{new_global_lock, save_global_lock};
use crate::core::types::StackStamp;
fn stamp(machine: &str) -> StackStamp {
StackStamp {
file: Some(format!("../{machine}/forjar.yaml")),
last_apply: "2026-01-01T00:00:00Z".to_string(),
generator: "forjar test".to_string(),
machines: vec![machine.to_string()],
outputs: Vec::new(),
}
}
/// Plant `count` snapshot directories and return their names.
fn plant(state_dir: &Path, count: usize) -> Vec<String> {
let dir = super::super::snapshot::snapshots_dir(state_dir);
std::fs::create_dir_all(&dir).unwrap();
(0..count)
.map(|i| {
let name = format!("planted-{i:02}");
std::fs::create_dir_all(dir.join(&name)).unwrap();
name
})
.collect()
}
fn kept(state_dir: &Path) -> Vec<String> {
let dir = super::super::snapshot::snapshots_dir(state_dir);
let mut names: Vec<String> = std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
names.sort();
names
}
/// Write a global lock naming exactly `stacks` stacks.
fn lock_with(state_dir: &Path, stacks: &[&str]) {
let mut lock = new_global_lock(stacks[0]);
for name in stacks {
lock.stacks.insert((*name).to_string(), stamp(name));
}
save_global_lock(state_dir, &lock).unwrap();
}
/// PMAT-182: the sweep asks the SHARED helper, so the two retention paths
/// cannot answer this differently — which is exactly how the generation
/// sweep was left unguarded when this one was fixed.
#[test]
fn the_sweep_asks_the_shared_helper_about_this_dir() {
let dir = tempfile::tempdir().unwrap();
let skip = |d: &Path| crate::core::state::stamp::retention::skip_note(d, "snapshot");
assert_eq!(skip(dir.path()), None, "no global lock");
lock_with(dir.path(), &["alpha"]);
assert_eq!(skip(dir.path()), None, "one stack");
lock_with(dir.path(), &["alpha", "bravo"]);
assert_eq!(
skip(dir.path()).as_deref(),
Some("note: snapshot gc skipped: state dir holds 2 stacks (PMAT-162)")
);
}
/// PMAT-177: the deletion itself is what the shared dir is spared.
#[test]
fn gc_keeps_every_snapshot_in_a_shared_dir() {
let dir = tempfile::tempdir().unwrap();
let planted = plant(dir.path(), 12);
lock_with(dir.path(), &["alpha", "bravo"]);
gc_old_snapshots(dir.path(), 2, false);
assert_eq!(kept(dir.path()), planted, "a shared dir loses no snapshot");
}
/// ANTI-VACUITY: the single-stack dir still prunes to its keep count.
#[test]
fn gc_still_prunes_a_single_stack_dir() {
let dir = tempfile::tempdir().unwrap();
plant(dir.path(), 12);
lock_with(dir.path(), &["alpha"]);
gc_old_snapshots(dir.path(), 2, false);
assert_eq!(
kept(dir.path()),
["planted-10", "planted-11"],
"retention is unchanged where the dir has one owner"
);
}
}