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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
//! FJ-1250 / Refs #356 / Refs #358 — `forjar apply --plan-file`.
//!
//! Executes a previously reviewed, sealed plan. Four things make this different
//! from an ordinary apply, and all four live here or in
//! [`super::apply_from_plan_checks`]:
//!
//! * the plan's integrity is verified before anything runs
//! (`plan_file::load_plan_file` → `core::plan_seal`);
//! * the plan's CLAIM is verified against a freshly computed plan
//! (`check_plan_still_holds`), because integrity is not truth;
//! * the executed set is the REVIEWED set — the scope derived from the plan
//! body is what the executor is given, instead of the whole config; and
//! * every apply flag the operator passes is either honoured or refused.
//!
//! Lifted out of `apply_variants.rs`, which is a grab-bag of unrelated apply
//! modes and had no room left for any of it.
use super::apply_from_plan_checks::*;
use super::apply_helpers::*;
use super::helpers::*;
use super::helpers_state::load_machine_locks;
use super::plan_file::load_plan_file;
use super::workspace::*;
use crate::core::plan_selectors::PlanSelectors;
use crate::core::{executor, resolver, types};
use std::path::Path;
/// The apply flags that say HOW the reviewed delta executes.
///
/// Refs #358: `execute_scoped_plan` built its `ApplyConfig` from a literal in
/// which every one of these was hard-coded off, so
/// `apply --plan-file --rollback-on-failure` armed nothing, `--progress` showed
/// nothing, `--retry 3` retried nothing and `--timeout 5` timed out never. All
/// of them parsed, none of them applied, and the run exited 0 — an operator who
/// believed a rollback was armed was wrong, silently.
///
/// These are separated from the SELECTORS deliberately. A selector asks *what*
/// converges and has to be intersected with the reviewed set; a knob asks *how*
/// the reviewed set converges and needs no such reasoning, which is why passing
/// it through is simply correct.
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ApplyKnobs {
/// FJ-266: remove a stale state lock before the run.
pub force_unlock: bool,
/// FJ-272: `[N/total]` counter.
pub progress: bool,
/// Per-transport-operation timeout.
pub timeout_secs: Option<u64>,
/// FJ-283: retry failed resources N times.
pub retry: u32,
/// FJ-290: parallel wave execution.
pub parallel: bool,
/// FJ-313: max concurrent resources per wave.
pub max_parallel: Option<usize>,
/// FJ-304: per-resource timeout.
pub resource_timeout: Option<u64>,
/// FJ-310: restore the pre-apply locks if any resource fails.
pub rollback_on_failure: bool,
}
/// Everything `apply --plan-file` was invoked with.
///
/// A struct rather than fourteen positional arguments: the defect being fixed
/// here is a long `ApplyConfig` literal whose fields were silently wrong, and a
/// long argument list is the same failure mode one call frame up.
pub(crate) struct PlanApplyRequest<'a> {
/// The config file, parsed exactly as an ordinary apply would.
pub file: &'a Path,
/// State directory holding the machine locks.
pub state_dir: &'a Path,
/// The saved plan to execute.
pub plan_path: &'a Path,
/// Announce what was loaded, and trace the generated scripts.
pub verbose: bool,
/// `--env-file`.
pub env_file: Option<&'a Path>,
/// `--workspace`.
pub workspace: Option<&'a str>,
/// forjar#370: `--operator`, or the ambient identity when unset. This is an
/// apply, so it is authorized like one.
pub operator: Option<&'a str>,
/// Any flag in the `--dry-run` family (GH-208).
pub dry_run: bool,
/// Refs #368: `--yes`. The FJ-286 prompt was unreachable through a plan
/// file, so `apply --plan-file` over a destroy converged it without asking;
/// this struct did not carry the flag that would have answered.
pub yes: bool,
/// Refs #368: `--confirm-destructive`. A hard block on the ordinary path
/// (`apply_preflight` returns Err without reading stdin) and a no-op here —
/// `apply --plan-file --confirm-destructive` blocked nothing.
pub confirm_destructive: bool,
/// `-m` / `-r` / `-t` / `-g` as passed to THIS invocation; they may only
/// narrow the reviewed delta.
pub selectors: PlanSelectors,
/// How the reviewed delta executes.
pub knobs: ApplyKnobs,
}
/// Rebuild the config exactly as an ordinary apply would see it.
///
/// The plan was sealed against this shape, not against the raw file: `--env-file`
/// params, the injected workspace param and resolved data sources all move the
/// canonical config hash, so `plan --out` and `apply --plan-file` have to agree
/// on the order these run in.
fn prepare_config(
file: &Path,
env_file: Option<&Path>,
workspace: Option<&str>,
) -> Result<types::ForjarConfig, String> {
let mut config = parse_and_validate(file)?;
if let Some(path) = env_file {
load_env_params(&mut config, path)?;
}
inject_workspace_param(&mut config, workspace);
resolver::resolve_data_sources(&mut config)?;
Ok(config)
}
/// Recompute the plan from live inputs, under `selectors`.
///
/// Nearly free: `cmd_apply_from_plan` has already parsed and resolved the
/// config, the planner is pure over `(config, locks)`, and the locks are the
/// same files `plan_seal`'s state leg has just read.
///
/// The pipeline is `plan_compute::plan_filtered` — the same function `cmd_plan`
/// uses — preceded by the same phony strip. Two spellings of "the planner plus
/// the four selectors" would make the comparison fire on plans nobody edited.
fn replan(
config: &types::ForjarConfig,
state_dir: &Path,
selectors: &PlanSelectors,
) -> Result<types::ExecutionPlan, String> {
let mut config = config.clone();
super::apply_selection::strip_unrequested_phony(&mut config, &[]);
let locks = load_machine_locks(&config, state_dir, selectors.machine.as_deref())?;
super::plan_compute::plan_filtered(&config, &locks, selectors)
}
/// The `plan`-style sigil for an action.
fn sigil(action: &types::PlanAction) -> &'static str {
match action {
types::PlanAction::Create => "+",
types::PlanAction::Update => "~",
types::PlanAction::Destroy => "-",
types::PlanAction::NoOp => "=",
}
}
/// Refs #358: `--dry-run` PREVIEWS the reviewed plan instead of converging it.
///
/// `cmd_apply_from_plan` took no `dry_run` at all, so `apply --plan-file
/// --dry-run` converged the machine for real and printed `Plan applied: 1
/// converged`. For a two-phase plan/review/apply feature that is the worst
/// available default — the flag exists precisely so an operator can ask what a
/// sealed plan would do without doing it.
///
/// The preview is printed from the plan BODY, which is the artifact that was
/// reviewed, narrowed by the same predicate the real run will use. Asking
/// `executor::apply_scoped` instead would honour `dry_run` (it returns before
/// `dispatch_apply`) but return a result carrying no changes, so the preview
/// would print "0 converged" and tell the operator nothing.
///
/// Printing the body is only honest because `check_plan_still_holds` has
/// already run: until this round it compared `action` alone, so the description
/// printed on each line here was a string out of the document that nothing had
/// checked — see `apply_from_plan_checks::divergence` for what that printed.
fn preview_scoped_plan(
plan: &types::ExecutionPlan,
scope: &executor::PlanScope,
config: &types::ForjarConfig,
selectors: &PlanSelectors,
) {
println!("Dry run — the reviewed plan would execute:");
let mut shown = 0usize;
for change in &plan.changes {
if !scope.covers(&change.machine, &change.resource_id) {
continue;
}
if !survives(config, selectors, &change.machine, &change.resource_id) {
continue;
}
shown += 1;
println!(
" {} {} on {}: {}",
sigil(&change.action),
change.resource_id,
change.machine,
change.description
);
}
println!("\n{shown} reviewed change(s). No changes applied.");
}
/// Converge exactly the `(machine, resource)` pairs the reviewed plan named,
/// under the flags this invocation passed.
///
/// This function used to be inline under the comment "Execute as a normal apply
/// using the plan's resource list", which said the opposite of what it did —
/// with all selectors `None` and no scope, `executor::apply` converged the whole
/// current config and the plan body was decorative. None of those selectors CAN
/// express a plan: `resource_filter` is a single id, and a plan names a set of
/// pairs. That set is `scope`.
///
/// The comment that replaced it was wrong in the other direction: it claimed the
/// selectors stay `None` because they "were already applied when the plan body
/// was written". That is true of how the plan was PRODUCED and no reason at all
/// to drop flags the operator is passing NOW, to this invocation — which is what
/// the whole literal was doing, to every field except `machine_filter`. Every
/// field is fed from the request; the three that cannot be honoured
/// (`--force`, `--force-tag`, `--refresh`) are refused before this runs, so
/// there is nothing left for a `false` here to hide.
fn execute_scoped_plan(
config: &types::ForjarConfig,
req: &PlanApplyRequest,
scope: &executor::PlanScope,
) -> Result<(), String> {
let knobs = req.knobs;
let cfg = executor::ApplyConfig {
config,
state_dir: req.state_dir,
// Refused by `reject_replanning_flags`: each of these three re-plans the
// delta rather than executing the reviewed one.
force: false,
refresh: false,
force_tag: None,
dry_run: false,
machine_filter: req.selectors.machine.as_deref(),
resource_filter: req.selectors.resource.as_deref(),
tag_filter: req.selectors.tag.as_deref(),
group_filter: req.selectors.group.as_deref(),
timeout_secs: knobs.timeout_secs,
force_unlock: knobs.force_unlock,
progress: knobs.progress,
retry: knobs.retry,
parallel: super::apply_gates::parallel_flag(knobs.parallel),
resource_timeout: knobs.resource_timeout,
rollback_on_failure: knobs.rollback_on_failure,
max_parallel: knobs.max_parallel,
trace: req.verbose,
run_id: Some(types::generate_run_id()),
};
let results = executor::apply_scoped(&cfg, Some(scope))?;
let (converged, unchanged, failed) = super::apply_output::count_results(&results);
println!("Plan applied: {converged} converged, {unchanged} unchanged, {failed} failed");
if failed > 0 {
return Err(format!("{failed} resource(s) failed"));
}
Ok(())
}
/// Say what was loaded and what it will touch, when asked.
fn report_plan(
plan: &types::ExecutionPlan,
scope: &executor::PlanScope,
selectors: &PlanSelectors,
) {
let n_changes = plan.to_create + plan.to_update + plan.to_destroy;
eprintln!(
"Executing saved plan: {} changes ({} create, {} update, {} destroy)",
n_changes, plan.to_create, plan.to_update, plan.to_destroy
);
if let Some(flags) = selectors.describe() {
eprintln!("Plan was written with the selectors: {flags}");
}
eprintln!(
"Plan scope: {} resource(s) across machine(s): {}",
scope.len(),
scope.machine_names().join(", ")
);
}
/// The reviewed plan asks for nothing. Say so, and say what it leaves undone.
fn honour_an_empty_plan(
config: &types::ForjarConfig,
state_dir: &Path,
sealed: bool,
written_with: &PlanSelectors,
) -> Result<(), String> {
check_empty_plan_is_trustworthy(sealed)?;
println!("Plan has no changes to apply.");
if !written_with.is_unfiltered() {
let whole = replan(config, state_dir, &PlanSelectors::default())?;
disclose_work_outside_the_filter(written_with, &whole);
}
Ok(())
}
/// FJ-1250: Execute a previously saved plan file.
///
/// # forjar#370: this is an apply, so it is authorized like one
///
/// `dispatch_apply_b::apply_mode_exits` returns here for `--plan-file` BEFORE
/// `apply_execute`, whose first line is `check_operator_auth`. So the gate was
/// reachable only on the ordinary path, and the whole of it was skippable by
/// routing through a plan file. Measured on 1.21.0 with
/// `allowed_operators: [alice]`, as a non-alice operator:
///
/// ```text
/// forjar apply --yes -> not authorized EXIT=1
/// forjar plan --out p.json -> EXIT=0
/// forjar apply --plan-file p.json --yes -> 2 converged EXIT=0
/// forjar apply --plan-file p2.json --operator mallory --yes -> applied EXIT=0
/// ```
///
/// A plan file is unauthenticated — any user can write one in a text editor —
/// so the bypass needed no privilege at all. The check lives at the TOP of this
/// function, not at the call site, so it holds for every caller of the
/// plan-file executor rather than for the one dispatcher that remembered.
///
/// `forjar plan --out` is deliberately NOT gated; see `cli::plan::cmd_plan`.
pub(crate) fn cmd_apply_from_plan(req: &PlanApplyRequest) -> Result<(), String> {
// forjar#370: FIRST, and before the plan file is even read — the same
// position and the same function `apply_execute` uses.
super::dispatch_apply::check_operator_auth(req.file, req.operator, None)?;
let config = prepare_config(req.file, req.env_file, req.workspace)?;
let loaded = load_plan_file(req.plan_path, &config, req.state_dir)?;
let scope = executor::PlanScope::from_plan(&loaded.plan);
if req.verbose {
report_plan(&loaded.plan, &scope, &loaded.selectors);
}
let fresh = replan(&config, req.state_dir, &loaded.selectors)?;
check_plan_still_holds(&loaded.plan, &fresh, &loaded.selectors)?;
if scope.is_empty() {
return honour_an_empty_plan(&config, req.state_dir, loaded.sealed, &loaded.selectors);
}
check_selectors_narrow_the_plan(&scope, &config, &req.selectors)?;
// Refs #368: the preflight an ordinary apply runs. `apply_mode_exits`
// returns for `--plan-file` before `apply_execute`, and `cmd_apply` is the
// only caller of `apply_pre_validate`, so every gate below `check_operator_auth`
// was unreachable through a plan file — including the BLAKE3 state-integrity
// gate whose own refusal says "No apply flag overrides this check".
super::apply_from_plan_gates::run_plan_apply_gates(&config, req, &loaded.plan, &scope)?;
if req.dry_run {
preview_scoped_plan(&loaded.plan, &scope, &config, &req.selectors);
return Ok(());
}
execute_scoped_plan(&config, req, &scope)
}
#[cfg(test)]
#[path = "tests_apply_from_plan.rs"]
mod tests_apply_from_plan;