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
353
354
355
356
357
358
359
360
361
//! Plan command.
use super::apply_helpers::*;
use super::helpers::*;
use super::helpers_state::*;
use super::print_helpers::*;
use super::workspace::*;
use crate::core::plan_selectors::PlanSelectors;
use crate::core::{planner, resolver, types};
use std::path::Path;
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_plan(
file: &Path,
state_dir: &Path,
machine_filter: Option<&str>,
resource_filter: Option<&str>,
tag_filter: Option<&str>,
json: bool,
verbose: bool,
output_dir: Option<&Path>,
env_file: Option<&Path>,
workspace: Option<&str>,
no_diff: bool,
target: Option<&str>,
cost: bool,
what_if: &[String],
plan_out: Option<&Path>,
why: bool,
// GH-214: `-g` printed "not yet implemented … Flag ignored" and then the
// whole plan. It is a real filter now, so it has to reach the planner.
group_filter: Option<&str>,
) -> Result<(), String> {
let mut config = parse_and_validate(file)?;
// FJ-333: Apply hypothetical param overrides
apply_what_if_overrides(&mut config, what_if)?;
if let Some(path) = env_file {
load_env_params(&mut config, path)?;
}
inject_workspace_param(&mut config, workspace);
resolver::resolve_data_sources(&mut config)?;
// Refs #363: SEAL OVER THE CONFIG `apply` REBUILDS, NOT THE ONE THE PLANNER RAN.
//
// `save_plan_file` hands this value to `plan_seal::seal`, whose config leg
// is `config_hash::config_hash` over the WHOLE `ForjarConfig`. Below this
// line the config is NARROWED twice — `--target` retains one resource plus
// its transitive deps, and `strip_unrequested_phony` removes every
// unrequested phony resource. `apply --plan-file` rebuilds the config from
// the file and stops after `resolve_data_sources`
// (`apply_from_plan::prepare_config`), so it recomputes the leg over the
// UNNARROWED config and the two can never agree. Measured: a config with
// one `phony: true` resource planned and applied back-to-back, nothing
// changed in between,
//
// error: PLAN_HASH_MISMATCH: the config changed since the plan was
// sealed (config leg: expected blake3:48529cd5…, got blake3:76887d58…)
//
// Sharper than "the hashes differ": the sealed document DENOTED a different
// config from the one it was planned from. The plan file written from a
// config holding a phony `cleanup` applied cleanly against a config with
// `cleanup` physically deleted, so two configs differing only in their
// phony resources sealed identically — a small forgery surface this closes.
//
// Only the SEAL takes this value. The plan BODY stays narrowed, which is
// what `apply_from_plan::replan` reproduces (it performs the same phony
// strip before re-planning), so the diff leg still agrees.
//
// The snapshot sits here and not one line earlier or later on purpose: the
// four mutations above are exactly `prepare_config`'s, in the same order,
// minus `--what-if` — which is why `plan --what-if … --out` keeps being
// refused at apply time, as it must be. Nothing below mutates `config`
// except the two narrowings.
let seal_config = config.clone();
// FJ-285: --target filters config to one resource + transitive deps
if let Some(target_id) = target {
let keep = collect_transitive_deps(&config, target_id)?;
config.resources.retain(|k, _| keep.contains(k));
}
if verbose {
eprintln!(
"Planning {} ({} machines, {} resources)",
config.name,
config.machines.len(),
config.resources.len()
);
}
// Load existing locks so plan shows accurate Create vs Update vs NoOp
let locks = load_machine_locks(&config, state_dir, machine_filter)?;
// GH-273: say WHERE state came from, and when there was none.
super::state_visibility::report(state_dir, &config, &locks);
// FJ-2725: phony resources are goal-only; a bulk plan must not report them
// as perpetual changes, or `plan` never reaches "0 to change" again.
super::apply_selection::strip_unrequested_phony(&mut config, &[]);
// Refs #358: the selector set is a value now, because a saved plan has to
// RECORD it — `apply --plan-file` re-plans under a document's own filters
// to check what it claims, and a filtered plan is otherwise indistinguishable
// from one an editor deleted lines out of.
// GH-214: -r and -g used to print "not yet implemented … Flag ignored"
// followed by the whole plan, while `apply -r/-g` filtered correctly.
let selectors = PlanSelectors::new(machine_filter, resource_filter, tag_filter, group_filter);
let plan = super::plan_compute::plan_filtered(&config, &locks, &selectors)?;
if let Some(dir) = output_dir {
export_scripts(&config, dir)?;
}
// FJ-1250: Write plan to file for later execution
//
// forjar#370 asked whether producing the artifact should require operator
// authorization too. Decided explicitly: NO, and this comment is the
// decision, not an omission.
//
// A plan file is unauthenticated data. Any user can write one in a text
// editor, so an attacker never needs `forjar plan --out` to obtain one —
// gating production buys nothing an attacker cannot route around, while
// costing something real: `plan` is one of the nine ReadOnly verbs
// (`src/verb/registry.rs`), and `allowed_operators` is an apply-time gate
// (FJ-2300). Making a read refuse for an unauthorized reader would break
// that contract for the sake of a check with no defensive value. The gate
// that IS load-bearing is at execution, and it now runs there —
// `cmd_apply_from_plan` checks before it reads the plan file at all.
if let Some(out_path) = plan_out {
// Refs #363: `seal_config`, not `config` — see the snapshot above.
super::plan_file::save_plan_file(
&plan,
&selectors,
&seal_config,
file,
state_dir,
out_path,
)?;
println!("Plan saved to {}", out_path.display());
return Ok(());
}
if why {
// GH-214: explain only what the (possibly filtered) plan contains, so
// `--why` cannot contradict the plan printed beside it.
print_why_explanation(&config, &locks, &plan.execution_order, tag_filter);
}
// forjar#342: ONE binding, so both arms range over the same count and the
// TTY rendering and `--json` cannot disagree about the blind spot.
let unconsulted = super::print_helpers::unconsulted_observations(&locks);
if json {
super::plan_json::print_plan_json(&plan, &config, unconsulted)?;
} else {
print_plan(
&plan,
machine_filter,
if no_diff { None } else { Some(&config) },
unconsulted,
);
}
if cost && !plan.changes.is_empty() {
print_plan_cost(&plan);
}
Ok(())
}
/// FJ-333: Decides what the hypothetical params are for this run — parses each
/// `--what-if KEY=VALUE` onto the config and announces the set that was applied.
/// Rejects a pair without `=`. Lifted out of `cmd_plan` because it is the one
/// place the command validates its own argument syntax, and it is self-contained.
fn apply_what_if_overrides(
config: &mut types::ForjarConfig,
what_if: &[String],
) -> Result<(), String> {
for kv in what_if {
if let Some((key, value)) = kv.split_once('=') {
config.params.insert(
key.to_string(),
serde_yaml_ng::Value::String(value.to_string()),
);
} else {
return Err(format!(
"invalid --what-if format '{kv}': expected KEY=VALUE"
));
}
}
if !what_if.is_empty() {
println!(
"{}",
dim(&format!(
"[what-if] Hypothetical params: {}",
what_if.join(", ")
))
);
}
Ok(())
}
/// FJ-312: Compute and print change cost summary.
fn type_weight(t: &types::ResourceType) -> u32 {
match t {
types::ResourceType::Package => 3,
types::ResourceType::Service => 3,
types::ResourceType::Mount => 4,
types::ResourceType::Docker | types::ResourceType::Pepita => 5,
types::ResourceType::User => 3,
types::ResourceType::Network => 2,
types::ResourceType::Gpu => 4,
types::ResourceType::Model => 5,
types::ResourceType::Cron => 2,
_ => 1, // file, recipe
}
}
pub(crate) fn print_plan_cost(plan: &types::ExecutionPlan) {
let total_cost: u32 = plan
.changes
.iter()
.map(|c| type_weight(&c.resource_type))
.sum();
let destroy_cost: u32 = plan
.changes
.iter()
.filter(|c| c.action == types::PlanAction::Destroy)
.map(|c| type_weight(&c.resource_type) * 2) // destructive = 2x
.sum();
println!(
"\nCost: {} total (create/update: {}, destroy: {})",
total_cost + destroy_cost,
total_cost,
destroy_cost
);
if destroy_cost > 10 {
println!(
" {} High destructive cost — consider --dry-run first",
red("!")
);
}
}
/// FJ-344: Compact one-line-per-resource plan output.
pub(crate) fn cmd_plan_compact(
file: &Path,
state_dir: &Path,
machine_filter: Option<&str>,
json: bool,
) -> Result<(), String> {
let config = parse_and_validate(file)?;
let execution_order = resolver::build_execution_order(&config)?;
let locks = load_machine_locks(&config, state_dir, machine_filter)?;
let plan = planner::plan(&config, &execution_order, &locks, None);
if json {
let compact: Vec<serde_json::Value> = plan
.changes
.iter()
.map(|c| {
serde_json::json!({
"resource": c.resource_id,
"action": format!("{:?}", c.action),
"machine": c.machine,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&compact).unwrap_or_default()
);
} else {
for change in &plan.changes {
let icon = match change.action {
types::PlanAction::Create => green("+"),
types::PlanAction::Update => yellow("~"),
types::PlanAction::Destroy => red("-"),
types::PlanAction::NoOp => dim("="),
};
println!(" {} {} ({})", icon, change.resource_id, change.machine,);
}
println!(
"\n{} change(s)",
plan.changes
.iter()
.filter(|c| c.action != types::PlanAction::NoOp)
.count()
);
}
Ok(())
}
/// FJ-1379: Print per-resource --why explanation.
fn print_why_explanation(
config: &types::ForjarConfig,
locks: &std::collections::HashMap<String, types::StateLock>,
execution_order: &[String],
tag_filter: Option<&str>,
) {
println!("\n{}", bold("Change Explanations (--why):"));
let reasons = collect_why_reasons(config, locks, execution_order, tag_filter);
for reason in &reasons {
let icon = action_icon(&reason.action);
println!(" {} {} on {}", icon, reason.resource_id, reason.machine);
for r in &reason.reasons {
println!(" {}", dim(&format!("- {r}")));
}
}
println!();
}
/// Collect non-noop change reasons for all matching resources.
fn collect_why_reasons(
config: &types::ForjarConfig,
locks: &std::collections::HashMap<String, types::StateLock>,
execution_order: &[String],
tag_filter: Option<&str>,
) -> Vec<crate::core::planner::why::ChangeReason> {
use crate::core::planner::why;
let mut results = Vec::new();
for resource_id in execution_order {
let Some(resource) = config.resources.get(resource_id) else {
continue;
};
if let Some(tag) = tag_filter {
if !resource.tags.iter().any(|t| t == tag) {
continue;
}
}
// GH-212: explain the RESOLVED resource. Comparing the raw config
// against a lock that stores resolved values produced nonsense like
// "path changed: /tmp/x/a.txt -> {{params.sandbox}}/a.txt".
let resolved = crate::core::resolver::resolve_or_fallback(
resource_id,
resource,
&config.params,
&config.machines,
&config.secrets,
);
for machine_name in resource.machine.iter() {
let reason = why::explain_why(resource_id, &resolved, machine_name, locks);
if reason.action != types::PlanAction::NoOp {
results.push(reason);
}
}
}
results
}
/// Action icon for display.
fn action_icon(action: &types::PlanAction) -> String {
match action {
types::PlanAction::Create => green("+"),
types::PlanAction::Update => yellow("~"),
types::PlanAction::Destroy => red("-"),
types::PlanAction::NoOp => dim("="),
}
}
#[cfg(test)]
#[path = "plan_tests_selector_scope.rs"]
mod tests_selector_scope;