ignition_core/rig/modules.rs
1//! The generated compose override (Phase 16, RMOD-04/05/06): a hand-rolled,
2//! `ign`-owned `compose.ign-modules.yml` (D-06 — no YAML crate) carrying a
3//! read-only bind mount of a verified cached module artifact plus the
4//! gateway's acceptance environment variables, passed as a SECOND `-f` on
5//! every project-scoped compose invocation.
6//!
7//! **The override is NEVER merged onto the user's compose file** — it is a
8//! separate file `ign` writes only inside `plan.project_dir`, regenerated
9//! whole on every provisioning run (D-08), and deleted entirely once no
10//! module remains declared. The user's compose file is never touched by
11//! any function in this module.
12//!
13//! **Ordering matters (D-11):** the override's `-f` is always the SECOND
14//! one — [`crate::rig::compose::up_args`] (and, from Task 3, the other
15//! three builders) append it after the base file. An override that landed
16//! first would invert compose's merge precedence and shift
17//! `--project-directory`/`.env` inference (research Pitfall 3).
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use crate::config::ModuleDeclaration;
23use crate::error::CoreError;
24use crate::module::ModuleSpec;
25use crate::module::fetch::{ArtifactSource, FetchPolicy, ModuleFeed};
26
27use super::RigPlan;
28
29/// The `ID@VERSION` separator for `--with-module` (D-15): unambiguous
30/// because neither [`crate::module::validate_module_id`]'s charset
31/// (`[a-z0-9-]`) nor [`crate::module::validate_version`]'s
32/// (`[A-Za-z0-9._+-]`) includes `@`.
33const WITH_MODULE_SEPARATOR: char = '@';
34
35/// Split one `--with-module` flag value into a validated
36/// `(id, ModuleDeclaration)` pair (D-15): one `split_once` on
37/// [`WITH_MODULE_SEPARATOR`], then BOTH halves through the EXISTING
38/// validators — no third validator is written here. A value with no
39/// separator, or with either half empty (which also catches a bare
40/// `@`), is `CoreError::InvalidInput` naming the value received and the
41/// expected shape.
42fn parse_with_module_flag(raw: &str) -> Result<(String, ModuleDeclaration), CoreError> {
43 let Some((id, version)) = raw.split_once(WITH_MODULE_SEPARATOR) else {
44 return Err(CoreError::InvalidInput {
45 reason: format!(
46 "--with-module value {raw:?} is missing the '@' separator \
47 (expected ID@VERSION, e.g. git@2.3.4)"
48 ),
49 });
50 };
51 crate::module::validate_module_id(id)?;
52 crate::module::validate_version(version)?;
53 Ok((
54 id.to_string(),
55 ModuleDeclaration {
56 version: version.to_string(),
57 },
58 ))
59}
60
61/// Overlay `--with-module ID@VERSION` flags onto `config`'s declared
62/// modules (D-15/D-16), PURE — no filesystem or network access. A flag
63/// entry replaces `config`'s entry for the same id (the flag wins,
64/// D-16's "additive to config for this invocation only, no subtractive
65/// form" contract) — `config` is never mutated in place, only cloned.
66/// `flags` order never affects the result: the returned map is a
67/// `BTreeMap`, so repeated flags land in `ModuleSpec::id` order
68/// regardless of which order they were passed on the command line.
69///
70/// Every refusal is [`CoreError::InvalidInput`] (exit 2) naming the
71/// offending value and the expected `ID@VERSION` shape — a REGISTRY
72/// check (is the id actually registered?) is deliberately NOT performed
73/// here; that stays [`provision_modules`]'s job (`CoreError::
74/// ModuleNotRegistered`, exit 3), which applies identically to a
75/// config-declared id and a flag-declared one.
76pub fn merge_declarations(
77 config: &BTreeMap<String, ModuleDeclaration>,
78 flags: &[String],
79) -> Result<BTreeMap<String, ModuleDeclaration>, CoreError> {
80 let mut merged = config.clone();
81 for raw in flags {
82 let (id, declaration) = parse_with_module_flag(raw)?;
83 merged.insert(id, declaration);
84 }
85 Ok(merged)
86}
87
88/// Pre-flight JUST the `--with-module` flag values — BEFORE
89/// [`crate::rig::resolve_plan`] ever runs, so a malformed or
90/// unregistered flag value refuses at exit 2/3 with ZERO Docker
91/// invocation (Task 2, D-15/D-13). Runs [`merge_declarations`] against
92/// an EMPTY config map purely to reuse its syntax validation (the
93/// config half is irrelevant here — only the flags' own well-
94/// formedness and registration matter), then checks every resulting id
95/// against the registry the same way [`provision_modules`] does.
96///
97/// This is a pre-check, not a replacement: [`provision_modules`]
98/// performs the AUTHORITATIVE registry check once the plan (and its
99/// config-declared modules) exist — a config-declared unregistered id
100/// is still caught there. This function only shortens the path for the
101/// flag's own mistakes, which is what lets those two refusals be
102/// proven without ever touching Docker.
103pub fn preflight_with_module_flags(flags: &[String]) -> Result<(), CoreError> {
104 let parsed = merge_declarations(&BTreeMap::new(), flags)?;
105 for id in parsed.keys() {
106 crate::module::spec_for(id).ok_or_else(|| {
107 let known: Vec<String> = crate::module::MODULES
108 .iter()
109 .map(|spec| spec.id.to_string())
110 .collect();
111 CoreError::ModuleNotRegistered {
112 id: id.clone(),
113 known,
114 }
115 })?;
116 }
117 Ok(())
118}
119
120/// The override's filename — always at `std::path::absolute(plan.
121/// project_dir.join(OVERRIDE_FILENAME))` (D-10). `std::path::absolute`
122/// (stable 1.79), never `canonicalize`: the latter resolves symlinks and
123/// would rewrite a path the user recognizes.
124pub const OVERRIDE_FILENAME: &str = "compose.ign-modules.yml";
125
126/// The container-side module-scan directory on the stock
127/// `inductiveautomation/ignition` image (16-RESEARCH.md, live-verified) —
128/// the mount target is this directory joined with `<ModuleSpec::id>.modl`.
129const CONTAINER_MODULE_DIR: &str = "/usr/local/bin/ignition/user-lib/modules";
130
131/// One already-fetched, already-verified module artifact ready to mount —
132/// the input to [`generate_override`]. `source` MUST be absolute (Task 2's
133/// pre-flight enforces this before generation ever sees it).
134#[derive(Debug, Clone)]
135pub struct MountedModule {
136 /// The registry entry (carries `id` and `gateway_module_id`).
137 pub spec: &'static ModuleSpec,
138 /// The pinned version string.
139 pub version: String,
140 /// Absolute path to the verified cached artifact on the host.
141 pub source: PathBuf,
142 /// Where the bytes came from this call (cache hit vs. fresh
143 /// download) — carried for [`ProvisionedModule`], not used by
144 /// generation itself.
145 pub source_kind: ArtifactSource,
146}
147
148/// Single-quote a YAML scalar, doubling any embedded apostrophe (D-07):
149/// never double-quoted, because a double-quoted YAML scalar treats a
150/// backslash as an escape character, which would mangle a Windows path
151/// like `C:\Users\...` a second way.
152fn single_quote_yaml(value: &str) -> String {
153 format!("'{}'", value.replace('\'', "''"))
154}
155
156/// Double-quote a YAML scalar for an environment VALUE (D-07): bare `Y`
157/// parses as a YAML boolean, so every `ACCEPT_*` value is quoted.
158fn double_quote_yaml(value: &str) -> String {
159 format!("\"{value}\"")
160}
161
162/// Join every mount's [`ModuleSpec::gateway_module_id`] into the
163/// comma-separated form the stock `inductiveautomation/ignition`
164/// image's entrypoint is documented to expect for
165/// `ACCEPT_MODULE_CERTS`/`ACCEPT_MODULE_LICENSES`. **This format is an
166/// ASSUMPTION, not verified live in this session** — see
167/// `two_modules_on_one_rig_golden`'s doc comment. A single named helper
168/// exists so that if plan 16-03's live gate finds a different separator
169/// or shape, fixing it is a one-line change here, never a scatter
170/// through [`generate_override`].
171fn join_gateway_module_ids(mounts: &[&MountedModule]) -> String {
172 mounts
173 .iter()
174 .map(|mount| mount.spec.gateway_module_id)
175 .collect::<Vec<_>>()
176 .join(",")
177}
178
179/// The mount target inside the container for one mount: the fixed
180/// [`CONTAINER_MODULE_DIR`] joined with `<ModuleSpec::id>.modl`.
181/// `ModuleSpec::id` is already constrained to `[a-z0-9-]{1,32}` by
182/// [`crate::module::validate_module_id`] — no new validation is invented
183/// here, and no user-supplied segment can traverse out of the directory.
184fn mount_target(id: &str) -> String {
185 format!("{CONTAINER_MODULE_DIR}/{id}.modl")
186}
187
188/// Generate the override's full contents — PURE, no filesystem access,
189/// deterministic (Phase 16, D-06/D-07). `mounts` need not arrive
190/// pre-sorted: this function sorts a local copy by [`ModuleSpec::id`] so
191/// regeneration is byte-stable regardless of caller iteration order.
192///
193/// Emits, in order: the generated-by header (states `ign` owns the file,
194/// that it is regenerated whole rather than merged, and that deleting it
195/// fully reverts provisioning — RMOD-06); `services:` → `service` →
196/// `environment:` (`ACCEPT_IGNITION_EULA` then `ACCEPT_MODULE_CERTS` then
197/// `ACCEPT_MODULE_LICENSES`, double-quoted values, the two accept lists
198/// comma-joining every mount's `gateway_module_id`); `volumes:` (one
199/// long-form mapping entry per mount, `source`/`target` single-quoted
200/// with apostrophe doubling, `read_only` a bare boolean). `\n` line
201/// endings only, exactly one trailing newline.
202pub fn generate_override(service: &str, mounts: &[MountedModule]) -> String {
203 let mut sorted: Vec<&MountedModule> = mounts.iter().collect();
204 sorted.sort_by_key(|mount| mount.spec.id);
205
206 let accept_ids = join_gateway_module_ids(&sorted);
207
208 let mut out = String::new();
209 out.push_str("# Generated by ign — DO NOT EDIT.\n");
210 out.push_str(
211 "# This file is regenerated WHOLE on every `ign rig up`/`rig reset` that \
212 provisions a declared module — it is never hand-merged. Deleting it fully \
213 reverts module provisioning for this rig without touching anything you \
214 wrote in your own compose file.\n",
215 );
216 out.push_str("services:\n");
217 // Quoted as well as validated. `resolve_entry` now rejects a
218 // `module_service` that names no real service, which makes injection
219 // unreachable — but that guard lives in another module, and this
220 // function is `pub`. Quoting here means a future caller that skips the
221 // guard still cannot terminate the scalar and inject a sibling key.
222 // A plain service name quotes to itself, so the golden bytes only gain
223 // the apostrophes.
224 out.push_str(&format!(" {}:\n", single_quote_yaml(service)));
225 out.push_str(" environment:\n");
226 out.push_str(&format!(
227 " ACCEPT_IGNITION_EULA: {}\n",
228 double_quote_yaml("Y")
229 ));
230 out.push_str(&format!(
231 " ACCEPT_MODULE_CERTS: {}\n",
232 double_quote_yaml(&accept_ids)
233 ));
234 out.push_str(&format!(
235 " ACCEPT_MODULE_LICENSES: {}\n",
236 double_quote_yaml(&accept_ids)
237 ));
238 out.push_str(" volumes:\n");
239 for mount in &sorted {
240 let target = mount_target(mount.spec.id);
241 out.push_str(" - type: bind\n");
242 out.push_str(&format!(
243 " source: {}\n",
244 single_quote_yaml(&mount.source.display().to_string())
245 ));
246 out.push_str(&format!(" target: {}\n", single_quote_yaml(&target)));
247 out.push_str(" read_only: true\n");
248 }
249 out
250}
251
252/// The absolute override path for `plan` (D-10):
253/// `std::path::absolute(plan.project_dir.join(OVERRIDE_FILENAME))`.
254/// `std::path::absolute` neither requires the path to exist nor resolves
255/// symlinks (unlike `canonicalize`) — the deliberate choice so a path the
256/// user recognizes is never silently rewritten.
257fn override_path(plan: &RigPlan) -> Result<PathBuf, CoreError> {
258 let joined = plan.project_dir.join(OVERRIDE_FILENAME);
259 std::path::absolute(&joined).map_err(|err| {
260 CoreError::Rig(format!(
261 "cannot compute absolute override path for {}: {err}",
262 joined.display()
263 ))
264 })
265}
266
267/// The pre-existing override on disk for `plan` (D-10), for verbs that
268/// NEVER provision — `rig down`, `rig status`, `rig logs` (Task 3). These
269/// verbs never fetch and never write, so the file's presence on disk IS
270/// the whole truth about what the last `up` used. Returns the absolute
271/// path ONLY when it exists AND is a regular file; any other state
272/// (absent, a directory, or a path-computation failure) is `None` —
273/// never a refusal, because these verbs must keep working on a rig that
274/// has never provisioned a module at all.
275///
276/// A stale override surviving into a `down` is harmless: it only adds a
277/// read-only mount to a service that is about to be removed. The next
278/// `up` (through [`provision_modules`] and, for the no-declaration case,
279/// [`undeclared_provisioning`]) deletes it per D-08's tri-state if
280/// nothing is declared, or regenerates it whole if something still is
281/// — so a file this function finds never accumulates stale state across
282/// cycles. **This is true only since commit `50cfbbc`**, which made
283/// [`clear_override`] reachable from BOTH `provision_modules`' early
284/// return and `main.rs`'s empty-merged-modules dispatch branch; before
285/// that fix, the delete branch was unreachable in production and a
286/// stale file was verified, live, to orphan across undeclare cycles.
287/// [`previously_provisioned`] (Phase 19, D-19-06) depends on that fix
288/// too: it reads this same file BEFORE the delete runs, so a stale
289/// override that never got cleared would make every subsequent orphan
290/// diff a lie.
291pub fn existing_override(plan: &RigPlan) -> Option<PathBuf> {
292 let path = override_path(plan).ok()?;
293 let metadata = std::fs::metadata(&path).ok()?;
294 if metadata.is_file() { Some(path) } else { None }
295}
296
297/// One module the pre-overwrite override named that `declared` no
298/// longer does — the `ign rig up`/`rig reset` result-data shape (all
299/// keys always present, the [`ProvisionedModule`] convention). Carries
300/// the exact removal command in the DATA itself (`remove_with`) so an
301/// agent reading `--json` learns the next step without parsing prose.
302#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
303#[serde(rename_all = "snake_case")]
304pub struct OrphanedModule {
305 /// The [`ModuleSpec::id`].
306 pub id: String,
307 /// The gateway's own acceptance-variable value.
308 pub gateway_module_id: String,
309 /// The exact command that removes this module from the gateway.
310 pub remove_with: String,
311}
312
313/// Parse one `target:` line from a generated override into a candidate
314/// module id, or `None` if the line is not a well-formed mount target
315/// line at all (not just an unregistered one — that half of D-19-07 is
316/// [`previously_provisioned`]'s job via [`crate::module::spec_for`]).
317/// Requires the [`CONTAINER_MODULE_DIR`] prefix and the `.modl` suffix
318/// exactly — a target line naming a path outside the module directory,
319/// or missing the extension, yields `None` here rather than a
320/// mis-extracted id.
321fn candidate_id_from_target_line(line: &str) -> Option<String> {
322 let rest = line.trim().strip_prefix("target:")?.trim();
323 let path = rest.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))?;
324 // The prefix is checked against the WHOLE path, not just the
325 // basename: a target such as `/tmp/outside/git.modl` shares a
326 // basename with a real mount but is not one `ign` generated, and
327 // taking only the last segment would resolve it to `git`.
328 let stem = path
329 .strip_prefix(CONTAINER_MODULE_DIR)?
330 .strip_prefix('/')?
331 .strip_suffix(".modl")?;
332 if stem.is_empty() || stem.contains('/') {
333 return None;
334 }
335 Some(stem.to_string())
336}
337
338/// Recover every registry-known module the PRE-overwrite override on
339/// disk named, in [`ModuleSpec::id`] order (D-19-06, D-19-07) — the
340/// ONLY derivation path for the orphan diff, never the gateway's own
341/// module list (would let `ign` "discover" modules it never provisioned,
342/// the exact over-reach SC-4 forbids, and would require a credential on
343/// `rig up`) and never the running container's mounts (already known to
344/// go stale, 19-RESEARCH.md Pitfall 3).
345///
346/// Reads [`existing_override`], then scans line by line for the
347/// long-form bind mount's `target:` entries — deliberately NOT
348/// `ACCEPT_MODULE_CERTS`/`ACCEPT_MODULE_LICENSES`, which carry the
349/// GATEWAY id directly and would bypass the registry intersection below.
350/// Each candidate id is resolved through [`crate::module::spec_for`];
351/// an id the registry does not know (a hand-edited override, a module
352/// from a future `ign` version, or an operator-typed id) is DROPPED
353/// SILENTLY — never an error, and never eligible to become an orphan
354/// report, because a module `ign` did not itself register can never be
355/// nominated for removal by `ign` (D-19-07's structural belt).
356///
357/// No override on disk, or a file that is not a generated override at
358/// all (an unreadable file, or one with no recognizable `target:`
359/// lines), yields an empty result — never a refusal, because `rig up`
360/// must keep working on a rig with no prior provisioning history.
361/// Deduplicated and sorted by [`ModuleSpec::id`] for deterministic
362/// output regardless of the file's own line order.
363pub fn previously_provisioned(plan: &RigPlan) -> Vec<&'static ModuleSpec> {
364 let Some(path) = existing_override(plan) else {
365 return Vec::new();
366 };
367 let Ok(contents) = std::fs::read_to_string(&path) else {
368 return Vec::new();
369 };
370 let mut specs: Vec<&'static ModuleSpec> = contents
371 .lines()
372 .filter_map(candidate_id_from_target_line)
373 .filter_map(|id| crate::module::spec_for(&id))
374 .collect();
375 specs.sort_by_key(|spec| spec.id);
376 specs.dedup_by_key(|spec| spec.id);
377 specs
378}
379
380/// POSIX-shell-quote a `[rigs.NAME]` key for the copy-paste command in
381/// [`OrphanedModule::remove_with`].
382///
383/// A rig key is an unrestricted TOML table name and `--rig` accepts the
384/// same unrestricted string, so the key reaches a generated command line
385/// as raw text. A key with a space splits into two arguments; a key with
386/// shell syntax (`;`, `$(...)`, backticks) changes what the pasted line
387/// DOES. `remove_with` is advice a user pastes into a shell, and the
388/// command it names is the irreversible one — this is the same defect
389/// shape as the unquoted `module_service` YAML interpolation, in a
390/// different output language.
391///
392/// Safe bare tokens are returned unchanged so the common case stays
393/// readable (`--rig live`, not `--rig 'live'`). Everything else is
394/// single-quoted, with an embedded `'` closed, escaped and reopened
395/// (`'\''`) — the POSIX rule, NOT the YAML apostrophe-doubling used by
396/// [`generate_override`].
397fn shell_quote_rig(name: &str) -> String {
398 let bare = !name.is_empty()
399 && name
400 .chars()
401 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
402 if bare {
403 return name.to_string();
404 }
405 format!("'{}'", name.replace('\'', "'\\''"))
406}
407
408/// The pure set difference behind the orphan report: every `previous`
409/// spec whose id is absent from `declared` — no I/O, sorted by id. A
410/// module `declared` newly adds is never reported (only ABSENCE from
411/// `declared` makes a `previous` entry an orphan).
412pub fn orphaned_modules(
413 previous: &[&'static ModuleSpec],
414 declared: &BTreeMap<String, ModuleDeclaration>,
415 config_name: Option<&str>,
416) -> Vec<OrphanedModule> {
417 let mut orphans: Vec<OrphanedModule> = previous
418 .iter()
419 .filter(|spec| !declared.contains_key(spec.id))
420 .map(|spec| OrphanedModule {
421 id: spec.id.to_string(),
422 gateway_module_id: spec.gateway_module_id.to_string(),
423 // `--rig` is included whenever the plan came from config,
424 // even with one rig declared: the uninstall is irreversible,
425 // and an unqualified command resolves by discovery order, so
426 // on a multi-rig setup it can remove the module from a
427 // DIFFERENT gateway than the one reporting the orphan. A
428 // cwd/convention rig has no key to select by, so it gets the
429 // bare form — that rig is not addressable by `--rig` either.
430 remove_with: match config_name {
431 Some(rig) => format!(
432 "ign rig module uninstall {} --rig {} --yes",
433 spec.id,
434 shell_quote_rig(rig)
435 ),
436 None => format!("ign rig module uninstall {} --yes", spec.id),
437 },
438 })
439 .collect();
440 orphans.sort_by(|a, b| a.id.cmp(&b.id));
441 orphans
442}
443
444/// Write, overwrite whole, or remove the override for `plan` — D-08's
445/// tri-state, completed in Task 2 (RMOD-06):
446///
447/// - `mounts` non-empty: computes the absolute override path (D-10),
448/// refuses BEFORE writing if something other than a regular file
449/// already occupies it (never a swallowed write error), then writes
450/// [`generate_override`]'s output WHOLE — replacing anything a
451/// previous run left behind, never merged or appended — and returns
452/// `Some(path)`.
453/// - `mounts` empty: removes a pre-existing override, tolerating
454/// `NotFound` (a second run against an already-absent file is still
455/// `Ok`), and returns `None`. This is the delete half of RMOD-06: an
456/// undeclared rig must never leave a stale mount behind (research
457/// Pitfall 4).
458///
459/// After this call returns, the override's `-f` is only ever passed for
460/// a path this run either wrote or found (D-08) — never an empty-but-
461/// present file.
462pub fn write_override(
463 plan: &RigPlan,
464 service: &str,
465 mounts: &[MountedModule],
466) -> Result<Option<PathBuf>, CoreError> {
467 let path = override_path(plan)?;
468
469 if mounts.is_empty() {
470 return match std::fs::remove_file(&path) {
471 Ok(()) => Ok(None),
472 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
473 Err(err) => Err(CoreError::Rig(format!(
474 "cannot remove stale module override at {}: {err}",
475 path.display()
476 ))),
477 };
478 }
479
480 if let Ok(metadata) = std::fs::metadata(&path)
481 && !metadata.is_file()
482 {
483 return Err(CoreError::Rig(format!(
484 "cannot write module override at {} — something other than a \
485 regular file already occupies that path; remove it by hand \
486 and re-run `ign rig up`",
487 path.display()
488 )));
489 }
490
491 let contents = generate_override(service, mounts);
492 std::fs::write(&path, contents).map_err(|err| {
493 CoreError::Rig(format!(
494 "cannot write module override at {}: {err}",
495 path.display()
496 ))
497 })?;
498 Ok(Some(path))
499}
500
501/// Pre-flight a declared module's mount source BEFORE it becomes a
502/// [`MountedModule`] and before generation or the runner ever sees it
503/// (Task 2; Pitfalls 1 and 7; T-16-02). Checked in THIS order — a
504/// relative path that happens to resolve under the current directory
505/// must be refused for BEING relative, not accepted because it
506/// resolved:
507///
508/// 1. `source` is absolute.
509/// 2. `std::fs::metadata(source)` succeeds (the path is accessible).
510/// 3. the metadata reports a regular file, not a directory — a missing
511/// bind-mount source silently becomes an empty DIRECTORY on both
512/// host and container (reproduced live in 16-RESEARCH.md), leaving a
513/// rig that boots healthy with no module ever loaded.
514///
515/// Every failure is [`CoreError::Rig`] (exit 7, D-13) naming the module
516/// id and the offending path, with an actionable next step.
517pub fn preflight_mount_source(id: &str, source: &Path) -> Result<(), CoreError> {
518 if !source.is_absolute() {
519 return Err(CoreError::Rig(format!(
520 "module {id:?} artifact path {} is not absolute — ign refuses \
521 to mount a relative path (it would resolve against compose's \
522 own working directory, not this cached artifact)",
523 source.display()
524 )));
525 }
526 let metadata = std::fs::metadata(source).map_err(|err| {
527 CoreError::Rig(format!(
528 "module {id:?} artifact at {} is not accessible: {err} — the \
529 cache entry is missing; re-run `ign rig up` to re-fetch it",
530 source.display()
531 ))
532 })?;
533 if !metadata.is_file() {
534 return Err(CoreError::Rig(format!(
535 "module {id:?} artifact path {} is not a regular file (found a \
536 directory) — a missing or corrupted bind-mount source \
537 silently becomes an empty directory on both host and \
538 container; remove it and re-run `ign rig up` to re-fetch",
539 source.display()
540 )));
541 }
542 Ok(())
543}
544
545/// One provisioned module — the `ign rig up`/`reset` result-data shape
546/// (all keys always present, the [`crate::actions::rig::RigUpResult`]
547/// convention).
548#[derive(Debug, Clone, serde::Serialize)]
549#[serde(rename_all = "snake_case")]
550pub struct ProvisionedModule {
551 /// The [`ModuleSpec::id`].
552 pub id: String,
553 /// The pinned version string.
554 pub version: String,
555 /// The gateway's own acceptance-variable value.
556 pub gateway_module_id: String,
557 /// The verified artifact's sha256 digest.
558 pub digest_sha256: String,
559 /// Whether the artifact came from the cache or a fresh download.
560 pub source: ArtifactSource,
561 /// The container-side mount path (`CONTAINER_MODULE_DIR/<id>.modl`).
562 pub mount_target: String,
563}
564
565/// The result of [`provision_modules`]: the override path (`None` for an
566/// undeclared rig) plus the per-module outcome. [`Default`] is the
567/// no-declaration case — constructible without touching the network or
568/// the disk, which is what makes threading it through every `rig_up`/
569/// `rig_reset` call site (including the SC-1 pinned-vector tests) a
570/// zero-cost no-op.
571#[derive(Debug, Clone, Default)]
572pub struct ModuleProvisioning {
573 /// The override's absolute path, when one was written or found.
574 pub override_file: Option<PathBuf>,
575 /// The per-module outcome, in [`ModuleSpec::id`] order.
576 pub modules: Vec<ProvisionedModule>,
577 /// A module the PRE-overwrite override named that `declared` no
578 /// longer does (Phase 19, D-19-01/D-19-06) — ALWAYS present, empty
579 /// for a rig whose provisioning changed nothing, the
580 /// [`ProvisionedModule`] convention. `ign` reports this; it never
581 /// removes it.
582 pub orphaned_modules: Vec<OrphanedModule>,
583}
584
585impl ModuleProvisioning {
586 /// The override file paths as a slice suitable for
587 /// [`crate::rig::compose::up_args`]'s `override_files` parameter —
588 /// empty for the no-declaration case (SC-1).
589 pub fn override_files(&self) -> &[PathBuf] {
590 self.override_file.as_slice()
591 }
592}
593
594/// Resolve, fetch, pre-flight, generate, and write the override for every
595/// module `declared` on `plan` (D-14). Iterates `declared` in
596/// `BTreeMap` (sorted-by-id) order, which is what keeps
597/// [`generate_override`]'s `ModuleSpec::id` ordering deterministic end
598/// to end without an extra sort at this layer.
599///
600/// The no-declaration case (`declared.is_empty()`) returns
601/// [`ModuleProvisioning::default`] WITHOUT touching service resolution
602/// at all — a rig that publishes no gateway port and declares no module
603/// must still provision cleanly (SC-1). With at least one module
604/// declared, the service is resolved FIRST (before any fetch): `plan.
605/// gateway_service` already carries `RigEntry::module_service`'s
606/// precedence over the derived value, folded in by `resolve_entry`
607/// (Task 1, D-12) — `None` here is refused (Task 2), naming the rig's
608/// services and the config key that fixes it. Each declared id is then
609/// looked up ([`CoreError::ModuleNotRegistered`], Task 2, D-13, exit 3
610/// — a well-formed id that simply isn't registered is a config problem,
611/// never a silent skip), fetched through Phase 15's
612/// [`crate::module::fetch::ModuleFeed::fetch_and_verify`] (never
613/// re-chmodded, re-hashed, or re-verified here — D-04), and
614/// pre-flighted ([`preflight_mount_source`], Task 2) before it ever
615/// becomes a [`MountedModule`].
616/// Clear a previous run's override for a rig that now declares NOTHING —
617/// D-08's delete half, reachable WITHOUT a [`ModuleFeed`].
618///
619/// This exists because the obvious shape was wrong. `provision_modules`
620/// returns early on an empty `declared`, and `main.rs`'s dispatch
621/// short-circuits before building a feed at all (SC-1: an undeclared rig
622/// constructs no network client). Between them, [`write_override`]'s
623/// delete branch was UNREACHABLE in production — verified live: the
624/// stale `compose.ign-modules.yml` orphaned on disk across two full
625/// undeclare cycles.
626///
627/// That matters beyond tidiness: [`existing_override`] is what
628/// `rig_down`/`rig_status`/`rig_logs` consult, so a stale file kept
629/// passing a mount for a module the user had removed — the exact hazard
630/// Phase 16's threat row T-16-06 claimed D-08 mitigated.
631///
632/// Idempotent: a missing file is `Ok(())`, so a second undeclared `up` is
633/// not an error.
634pub fn clear_override(plan: &RigPlan) -> Result<(), CoreError> {
635 write_override(plan, "", &[]).map(|_| ())
636}
637
638/// The no-declaration case, computed in ONE place for BOTH callers
639/// (`provision_modules`' own early return and `main.rs`'s
640/// empty-merged-modules dispatch branch) — Phase 19, D-19-06/D-19-07.
641///
642/// The ORDER is the whole point: [`previously_provisioned`] reads the
643/// override BEFORE [`clear_override`] deletes it. Reading after the
644/// delete would find nothing on disk and silently report an empty
645/// orphan list even when a module really was just undeclared — which is
646/// exactly the bug this function exists to make structurally
647/// unreachable, by giving the no-declaration case exactly one
648/// implementation instead of two inline call sites that could drift.
649///
650/// Returns a [`ModuleProvisioning`] with no override file, no
651/// provisioned modules, and the orphan diff against an empty declared
652/// set — [`clear_override`]'s own idempotency (a missing file is
653/// `Ok(())`) is what makes a second consecutive undeclared `up` return
654/// an empty orphan list rather than erroring.
655pub fn undeclared_provisioning(plan: &RigPlan) -> Result<ModuleProvisioning, CoreError> {
656 let previous = previously_provisioned(plan);
657 let orphaned = orphaned_modules(&previous, &BTreeMap::new(), plan.config_name.as_deref());
658 clear_override(plan)?;
659 Ok(ModuleProvisioning {
660 override_file: None,
661 modules: Vec::new(),
662 orphaned_modules: orphaned,
663 })
664}
665
666pub async fn provision_modules(
667 feed: &ModuleFeed,
668 plan: &RigPlan,
669 declared: &BTreeMap<String, ModuleDeclaration>,
670 cache_root: &Path,
671 policy: FetchPolicy,
672) -> Result<ModuleProvisioning, CoreError> {
673 if declared.is_empty() {
674 // Not just an early return: a previous run's override must GO,
675 // and the orphan diff must be READ before that delete runs.
676 // See `undeclared_provisioning` for why this is the ONE
677 // implementation of the no-declaration case, not an inline
678 // call site that could drift from `main.rs`'s own branch.
679 return undeclared_provisioning(plan);
680 }
681
682 // The orphan diff's input, captured BEFORE `write_override` below
683 // regenerates or overwrites the override (D-19-06): the PRE-write
684 // file is the one artifact `ign` wrote itself for what LAST ran,
685 // which is what the diff must compare against.
686 let previous = previously_provisioned(plan);
687
688 let service = plan.gateway_service.clone().ok_or_else(|| {
689 CoreError::Rig(format!(
690 "cannot derive a gateway service to mount modules into for rig {:?} \
691 (services: {:?}) — set module_service under [rigs.{}] to name one \
692 explicitly",
693 plan.name, plan.services, plan.name
694 ))
695 })?;
696
697 let mut mounts: Vec<MountedModule> = Vec::new();
698 let mut provisioned: Vec<ProvisionedModule> = Vec::new();
699 for (id, declaration) in declared {
700 let spec = crate::module::spec_for(id).ok_or_else(|| {
701 let known: Vec<String> = crate::module::MODULES
702 .iter()
703 .map(|s| s.id.to_string())
704 .collect();
705 CoreError::ModuleNotRegistered {
706 id: id.clone(),
707 known,
708 }
709 })?;
710 let fetched = feed
711 .fetch_and_verify(spec, &declaration.version, cache_root, policy)
712 .await?;
713 preflight_mount_source(spec.id, &fetched.path)?;
714 mounts.push(MountedModule {
715 spec,
716 version: declaration.version.clone(),
717 source: fetched.path.clone(),
718 source_kind: fetched.source,
719 });
720 provisioned.push(ProvisionedModule {
721 id: spec.id.to_string(),
722 version: declaration.version.clone(),
723 gateway_module_id: spec.gateway_module_id.to_string(),
724 digest_sha256: fetched.digest_sha256,
725 source: fetched.source,
726 mount_target: mount_target(spec.id),
727 });
728 }
729
730 let override_file = write_override(plan, &service, &mounts)?;
731 let orphaned = orphaned_modules(&previous, declared, plan.config_name.as_deref());
732
733 Ok(ModuleProvisioning {
734 override_file,
735 modules: provisioned,
736 orphaned_modules: orphaned,
737 })
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use crate::module::fetch::{ArtifactSource, FetchPolicy, ModuleFeed};
744 use crate::module::{GIT_MODULE, PROJECT_SCAN_ENDPOINT};
745 use sha2::{Digest, Sha256};
746
747 /// The golden-byte pin (T-16-01, plan Task 1 item 1). Every value
748 /// `generate_override` interpolates is either charset-validated
749 /// upstream (`ModuleSpec::id`/`gateway_module_id`) or a path `ign`
750 /// itself constructed — pinning the exact bytes, not a set of
751 /// `contains` checks, is what proves a future change can never let
752 /// an interpolated value terminate its YAML scalar and inject a
753 /// sibling key. If this test starts failing because the header
754 /// wording, key order, quote style, or line endings legitimately
755 /// changed, update the literal deliberately — a silent drift here
756 /// is exactly the regression this test exists to catch.
757 #[test]
758 fn generate_override_pins_golden_bytes_and_quoting() {
759 let mount = MountedModule {
760 spec: &GIT_MODULE,
761 version: "2.3.4".to_string(),
762 source: PathBuf::from("/cache/modules/git/2.3.4/Git-2.3.4-signed.modl"),
763 source_kind: ArtifactSource::Cache,
764 };
765 let expected = concat!(
766 "# Generated by ign — DO NOT EDIT.\n",
767 "# This file is regenerated WHOLE on every `ign rig up`/`rig reset` that provisions a declared module — it is never hand-merged. Deleting it fully reverts module provisioning for this rig without touching anything you wrote in your own compose file.\n",
768 "services:\n",
769 " 'ignition':\n",
770 " environment:\n",
771 " ACCEPT_IGNITION_EULA: \"Y\"\n",
772 " ACCEPT_MODULE_CERTS: \"com.axone_io.ignition.git\"\n",
773 " ACCEPT_MODULE_LICENSES: \"com.axone_io.ignition.git\"\n",
774 " volumes:\n",
775 " - type: bind\n",
776 " source: '/cache/modules/git/2.3.4/Git-2.3.4-signed.modl'\n",
777 " target: '/usr/local/bin/ignition/user-lib/modules/git.modl'\n",
778 " read_only: true\n",
779 );
780 assert_eq!(
781 generate_override("ignition", std::slice::from_ref(&mount)),
782 expected,
783 "golden bytes drifted from the pinned override shape"
784 );
785
786 // D-07's quoting decisions, exercised separately from the main
787 // golden: paths are SINGLE-quoted with apostrophe doubling (the
788 // YAML single-quoted-scalar escape — this is what "round-trip"
789 // means here), never double-quoted (a double-quoted scalar
790 // would treat `\` as an escape character and mangle a Windows
791 // path a second way). Env VALUES are always double-quoted — the
792 // main golden above already pins that half (`"Y"` would
793 // otherwise parse as a YAML boolean if left bare).
794 let quoted_mount = MountedModule {
795 spec: &GIT_MODULE,
796 version: "9.9.9".to_string(),
797 source: PathBuf::from("/cache/O'Brien's Modules/git-9.9.9.modl"),
798 source_kind: ArtifactSource::Download,
799 };
800 let out = generate_override("ignition", std::slice::from_ref("ed_mount));
801 assert!(
802 out.contains(" source: '/cache/O''Brien''s Modules/git-9.9.9.modl'\n"),
803 "an embedded apostrophe must double, never vanish or backslash-escape: {out}"
804 );
805 }
806
807 /// SC-5's provisioning-shape proof (16-02 plan Task 1 item 4): two
808 /// modules declared on one rig produce ONE override with two
809 /// long-form bind mounts, ordered by [`crate::module::ModuleSpec::
810 /// id`] (`git` < `project-scan-endpoint` lexicographically) —
811 /// proven here by passing them in the OPPOSITE order, so the
812 /// ordering in the golden can only come from `generate_override`'s
813 /// own sort, never from caller iteration order — and both
814 /// acceptance-variable lists comma-joining both gateway ids in that
815 /// same order.
816 ///
817 /// **The comma-joining format is an ASSUMPTION, not verified live in
818 /// this session** (16-01-SUMMARY.md, "What This Plan Does NOT
819 /// Prove"): it is the stock `inductiveautomation/ignition` image's
820 /// documented entrypoint convention, but no live gateway in this
821 /// session has confirmed it accepts a comma-separated list naming
822 /// TWO different modules. Plan 16-03's live gate is what confirms or
823 /// falsifies it; [`join_gateway_module_ids`] is the one place a
824 /// format correction would land.
825 #[test]
826 fn two_modules_on_one_rig_golden() {
827 let git_mount = MountedModule {
828 spec: &GIT_MODULE,
829 version: "2.3.4".to_string(),
830 source: PathBuf::from("/cache/modules/git/2.3.4-deadbeef.modl"),
831 source_kind: ArtifactSource::Cache,
832 };
833 let scan_mount = MountedModule {
834 spec: &PROJECT_SCAN_ENDPOINT,
835 version: "1.0.0".to_string(),
836 source: PathBuf::from("/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl"),
837 source_kind: ArtifactSource::Download,
838 };
839
840 // Reverse registry-id order on purpose (see doc comment above).
841 let mounts = [scan_mount, git_mount];
842 let expected = concat!(
843 "# Generated by ign — DO NOT EDIT.\n",
844 "# This file is regenerated WHOLE on every `ign rig up`/`rig reset` that provisions a declared module — it is never hand-merged. Deleting it fully reverts module provisioning for this rig without touching anything you wrote in your own compose file.\n",
845 "services:\n",
846 " 'ignition':\n",
847 " environment:\n",
848 " ACCEPT_IGNITION_EULA: \"Y\"\n",
849 " ACCEPT_MODULE_CERTS: \"com.axone_io.ignition.git,project-scan-endpoint\"\n",
850 " ACCEPT_MODULE_LICENSES: \"com.axone_io.ignition.git,project-scan-endpoint\"\n",
851 " volumes:\n",
852 " - type: bind\n",
853 " source: '/cache/modules/git/2.3.4-deadbeef.modl'\n",
854 " target: '/usr/local/bin/ignition/user-lib/modules/git.modl'\n",
855 " read_only: true\n",
856 " - type: bind\n",
857 " source: '/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl'\n",
858 " target: '/usr/local/bin/ignition/user-lib/modules/project-scan-endpoint.modl'\n",
859 " read_only: true\n",
860 );
861 assert_eq!(
862 generate_override("ignition", &mounts),
863 expected,
864 "two-module golden drifted — check mount ordering, the comma-join, \
865 or the per-mount block shape"
866 );
867 }
868
869 /// Research Pitfall 6: a source path shaped like a Windows absolute
870 /// path must appear INTACT inside the generated `source` scalar —
871 /// the falsifiable proof that the long bind-mount mapping syntax
872 /// (`source:`/`target:` keys, each its own line) is what
873 /// `generate_override` emits, never the short colon-separated form
874 /// (`C:\path:/target:ro`) that a drive-letter colon would corrupt.
875 #[test]
876 fn windows_style_source_path_golden() {
877 let mount = MountedModule {
878 spec: &GIT_MODULE,
879 version: "2.3.4".to_string(),
880 source: PathBuf::from(
881 r"C:\Users\dev\AppData\Local\ignition-cli\modules\git\2.3.4-deadbeef.modl",
882 ),
883 source_kind: ArtifactSource::Cache,
884 };
885 let out = generate_override("ignition", std::slice::from_ref(&mount));
886 assert!(
887 out.contains(
888 " source: 'C:\\Users\\dev\\AppData\\Local\\ignition-cli\\modules\\git\\2.3.4-deadbeef.modl'\n"
889 ),
890 "the Windows-style path must survive intact inside a single-quoted \
891 long-form mapping — a short-form colon-separated mount would have \
892 mistaken the drive-letter colon for the source:target separator: {out}"
893 );
894 }
895
896 /// Plan Task 1 item 5 / the inherited test-matrix rule: a
897 /// one-module override left on disk by a PREVIOUS run, re-provisioned
898 /// with a SECOND module added — the file must name both, with
899 /// nothing duplicated and nothing from the old one-module content
900 /// orphaned.
901 #[test]
902 fn adding_a_second_module_to_a_pre_existing_override_regenerates_whole() {
903 let project_dir = tempfile::tempdir().expect("project tempdir");
904 let plan = RigPlan {
905 name: "fixture-rig".to_string(),
906 config_name: None,
907 compose_file: project_dir.path().join("docker-compose.yml"),
908 project_dir: project_dir.path().to_path_buf(),
909 services: vec!["ignition".to_string()],
910 host_ports: Vec::new(),
911 port_mappings: Vec::new(),
912 volumes: Vec::new(),
913 gateway_service: Some("ignition".to_string()),
914 modules: BTreeMap::new(),
915 };
916
917 let git_mount = MountedModule {
918 spec: &GIT_MODULE,
919 version: "2.3.4".to_string(),
920 source: PathBuf::from("/cache/modules/git/2.3.4-deadbeef.modl"),
921 source_kind: ArtifactSource::Cache,
922 };
923 let path = write_override(&plan, "ignition", std::slice::from_ref(&git_mount))
924 .expect("seed a one-module override, as if a prior run provisioned only git")
925 .expect("mounts were non-empty");
926 let one_module_contents = std::fs::read_to_string(&path).expect("read one-module override");
927 assert!(
928 one_module_contents.contains("git.modl")
929 && !one_module_contents.contains("project-scan-endpoint"),
930 "precondition: the seeded override must name only the git module"
931 );
932
933 let scan_mount = MountedModule {
934 spec: &PROJECT_SCAN_ENDPOINT,
935 version: "1.0.0".to_string(),
936 source: PathBuf::from("/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl"),
937 source_kind: ArtifactSource::Cache,
938 };
939 write_override(&plan, "ignition", &[git_mount.clone(), scan_mount.clone()])
940 .expect("regenerate the override with both modules")
941 .expect("mounts were non-empty");
942
943 let two_module_contents =
944 std::fs::read_to_string(&path).expect("read regenerated override");
945 assert_eq!(
946 two_module_contents,
947 generate_override("ignition", &[git_mount, scan_mount]),
948 "the file must equal the fresh golden for BOTH mounts"
949 );
950 assert_eq!(
951 two_module_contents.matches("git.modl").count(),
952 1,
953 "the git mount must appear exactly once, not duplicated: {two_module_contents}"
954 );
955 assert_eq!(
956 two_module_contents
957 .matches("project-scan-endpoint.modl")
958 .count(),
959 1,
960 "the newly added module must appear exactly once: {two_module_contents}"
961 );
962 }
963
964 /// T-16-03 / SC-4 (plan Task 1 item 2): a stronger gate than a grep
965 /// for write calls — record the user's compose file's BYTES before
966 /// provisioning and assert byte-identity afterward. `ign` must
967 /// write ONLY `compose.ign-modules.yml`, never the file sitting
968 /// next to it. Exercises the WHOLE `provision_modules` path (not
969 /// just `write_override`) against a pure cache hit — mirrors the
970 /// `offline_with_populated_cache_succeeds` idiom in
971 /// `tests/module_fetch_contract.rs`: an unroutable feed base proves
972 /// no network round-trip is even attempted.
973 #[tokio::test]
974 async fn user_compose_file_is_never_modified() {
975 let project_dir = tempfile::tempdir().expect("project tempdir");
976 let compose_path = project_dir.path().join("docker-compose.yml");
977 let compose_bytes: &[u8] =
978 b"services:\n ignition:\n image: inductiveautomation/ignition:8.3.6\n";
979 std::fs::write(&compose_path, compose_bytes).expect("seed the user's compose file");
980 let before = std::fs::read(&compose_path).expect("read compose before provisioning");
981
982 // Pre-seed the cache so `provision_modules` is a pure cache hit
983 // — no mock server, no network path exists to accidentally
984 // touch anything.
985 let cache_root = tempfile::tempdir().expect("cache tempdir");
986 let payload = b"synthetic .modl payload for user_compose_file_is_never_modified".to_vec();
987 let mut hasher = Sha256::new();
988 hasher.update(&payload);
989 let digest = format!("{:x}", hasher.finalize());
990 let module_dir = cache_root.path().join("modules").join("git");
991 std::fs::create_dir_all(&module_dir).expect("create module cache dir");
992 std::fs::write(module_dir.join(format!("2.3.4-{digest}.modl")), &payload)
993 .expect("seed cached artifact");
994
995 let plan = RigPlan {
996 name: "fixture-rig".to_string(),
997 config_name: None,
998 compose_file: compose_path.clone(),
999 project_dir: project_dir.path().to_path_buf(),
1000 services: vec!["ignition".to_string()],
1001 host_ports: vec![9088],
1002 port_mappings: Vec::new(),
1003 volumes: Vec::new(),
1004 gateway_service: Some("ignition".to_string()),
1005 modules: BTreeMap::new(),
1006 };
1007 let mut declared: BTreeMap<String, ModuleDeclaration> = BTreeMap::new();
1008 declared.insert(
1009 "git".to_string(),
1010 ModuleDeclaration {
1011 version: "2.3.4".to_string(),
1012 },
1013 );
1014
1015 let feed = ModuleFeed::for_base(
1016 url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
1017 )
1018 .expect("feed builds");
1019
1020 let provisioning = provision_modules(
1021 &feed,
1022 &plan,
1023 &declared,
1024 cache_root.path(),
1025 FetchPolicy::CacheFirst,
1026 )
1027 .await
1028 .expect("cache-hit provisioning succeeds with nothing listening on the network");
1029
1030 let expected_override = project_dir.path().join(OVERRIDE_FILENAME);
1031 assert_eq!(
1032 provisioning.override_file.as_deref(),
1033 Some(expected_override.as_path()),
1034 "the override is written into the project dir, never the user's compose path"
1035 );
1036 assert!(
1037 expected_override.exists(),
1038 "the override file itself must exist on disk after provisioning"
1039 );
1040
1041 let after = std::fs::read(&compose_path).expect("read compose after provisioning");
1042 assert_eq!(
1043 before, after,
1044 "the user's compose file must be byte-identical before and after provisioning"
1045 );
1046 assert_eq!(
1047 after, compose_bytes,
1048 "and must equal exactly the bytes this test seeded"
1049 );
1050 }
1051
1052 // -------------------------------------------------------------------
1053 // merge_declarations / preflight_with_module_flags (16-02 Task 2,
1054 // D-15/D-16)
1055 // -------------------------------------------------------------------
1056
1057 /// D-16: a flag naming a NEW id adds to config's declarations; a
1058 /// flag naming the SAME id as config OVERRIDES that id's version
1059 /// for this invocation — the entry appears once, flag version wins.
1060 #[test]
1061 fn merge_declarations_overlays_flag_onto_config() {
1062 let mut config = BTreeMap::new();
1063 config.insert(
1064 "git".to_string(),
1065 ModuleDeclaration {
1066 version: "2.3.4".to_string(),
1067 },
1068 );
1069
1070 // A flag naming a second id: both present afterward.
1071 let merged = merge_declarations(&config, &["project-scan-endpoint@1.0.0".to_string()])
1072 .expect("well-formed flag merges");
1073 assert_eq!(merged.len(), 2);
1074 assert_eq!(merged["git"].version, "2.3.4", "config entry untouched");
1075 assert_eq!(merged["project-scan-endpoint"].version, "1.0.0");
1076
1077 // A flag naming the SAME id as config: the flag's version wins,
1078 // the entry appears exactly once.
1079 let merged =
1080 merge_declarations(&config, &["git@9.9.9".to_string()]).expect("same-id flag merges");
1081 assert_eq!(merged.len(), 1, "one entry, not two, for the same id");
1082 assert_eq!(
1083 merged["git"].version, "9.9.9",
1084 "the FLAG's version wins over config's"
1085 );
1086
1087 // Untouched config is never mutated by the merge.
1088 assert_eq!(config["git"].version, "2.3.4");
1089 }
1090
1091 /// Every malformed shape refuses `invalid_input` / exit 2: no
1092 /// separator, an empty id half, an empty version half (a bare `@`
1093 /// hits this same branch) — these three are refused directly by
1094 /// `parse_with_module_flag`, so the message names the FULL raw
1095 /// value received. An id failing `validate_module_id` and a
1096 /// version failing `validate_version` are refused by those
1097 /// EXISTING validators instead (no third validator is written, per
1098 /// D-15) — their messages name only the offending half, which the
1099 /// second half of this table checks for.
1100 #[test]
1101 fn merge_declarations_refuses_malformed_values() {
1102 let config = BTreeMap::new();
1103
1104 // Refused by parse_with_module_flag ITSELF — only the
1105 // missing-separator case, which is the one failure the parser
1106 // detects before either validator runs, so it is the only one
1107 // whose message can name the whole raw value. A value that DOES
1108 // split (even into an empty half) reaches a validator, and those
1109 // belong in the table below.
1110 let bad = "git-no-separator";
1111 let err = merge_declarations(&config, &[bad.to_string()])
1112 .expect_err("a value with no '@' must be refused");
1113 assert_eq!(err.code(), "invalid_input");
1114 assert_eq!(err.exit_code(), 2);
1115 assert!(
1116 err.to_string().contains(bad),
1117 "message names the received value {bad:?}: {err}"
1118 );
1119
1120 // Refused by the EXISTING validate_module_id/validate_version
1121 // (D-15: no third validator) — the message names the offending
1122 // half, not necessarily the whole ID@VERSION value.
1123 for (bad, expected_fragment) in [
1124 ("UPPER@2.3.4", "UPPER"),
1125 ("git@../escape", "../escape"),
1126 // These DO split on '@' into an empty half, so they are
1127 // refused by the validators, not by the parser — the message
1128 // names which half was empty. Asserting the validator's own
1129 // wording keeps the empty-string fragment from matching
1130 // everything vacuously.
1131 ("@2.3.4", "module id \"\" is not a safe identifier"),
1132 ("@", "module id \"\" is not a safe identifier"),
1133 ("git@", "module version \"\" is not a safe version string"),
1134 ] {
1135 let err = merge_declarations(&config, &[bad.to_string()])
1136 .expect_err(&format!("{bad:?} must be refused"));
1137 assert_eq!(err.code(), "invalid_input", "{bad:?}");
1138 assert_eq!(err.exit_code(), 2, "{bad:?}");
1139 assert!(
1140 err.to_string().contains(expected_fragment),
1141 "message names the offending half {expected_fragment:?}: {err}"
1142 );
1143 }
1144 }
1145
1146 /// D-15: repeated `--with-module` flags produce a map ordered by
1147 /// id (BTreeMap's natural order), never by the order flags were
1148 /// passed on the command line.
1149 #[test]
1150 fn merge_declarations_is_order_independent() {
1151 let config = BTreeMap::new();
1152 let forward = merge_declarations(
1153 &config,
1154 &[
1155 "project-scan-endpoint@1.0.0".to_string(),
1156 "git@2.3.4".to_string(),
1157 ],
1158 )
1159 .expect("forward order merges");
1160 let reverse = merge_declarations(
1161 &config,
1162 &[
1163 "git@2.3.4".to_string(),
1164 "project-scan-endpoint@1.0.0".to_string(),
1165 ],
1166 )
1167 .expect("reverse order merges");
1168 assert_eq!(forward, reverse, "flag order must not affect the result");
1169 assert_eq!(
1170 forward.keys().collect::<Vec<_>>(),
1171 vec!["git", "project-scan-endpoint"],
1172 "iteration order is id order, not flag order"
1173 );
1174 }
1175
1176 /// The dispatch-level guard (Task 2): a malformed flag value
1177 /// refuses via the SAME `invalid_input` path as
1178 /// `merge_declarations` — `preflight_with_module_flags` is a
1179 /// thin wrapper, not a second implementation.
1180 #[test]
1181 fn preflight_with_module_flags_refuses_malformed_value() {
1182 let err = preflight_with_module_flags(&["not-a-flag-value".to_string()])
1183 .expect_err("malformed value refused");
1184 assert_eq!(err.code(), "invalid_input");
1185 assert_eq!(err.exit_code(), 2);
1186 }
1187
1188 /// The dispatch-level guard's other half: a well-formed but
1189 /// UNREGISTERED id refuses `module_not_registered` / exit 3,
1190 /// naming the unregistered id and every currently registered one —
1191 /// reachable with ZERO plan/Docker context, which is what lets the
1192 /// CLI catch it before `resolve_plan` ever runs.
1193 #[test]
1194 fn preflight_with_module_flags_refuses_unregistered_id() {
1195 let err = preflight_with_module_flags(&["nope@1.0.0".to_string()])
1196 .expect_err("unregistered id refused");
1197 assert_eq!(err.code(), "module_not_registered");
1198 assert_eq!(err.exit_code(), 3);
1199 let message = err.to_string();
1200 assert!(message.contains("nope"), "{message}");
1201 assert!(message.contains("git"), "known ids listed: {message}");
1202 assert!(
1203 message.contains("project-scan-endpoint"),
1204 "known ids listed: {message}"
1205 );
1206 }
1207
1208 /// The guard's happy path: every currently registered id, and a
1209 /// well-formed unregistered-nowhere-near-registry id is refused —
1210 /// proven together so a future registry change can't silently
1211 /// widen or narrow acceptance.
1212 #[test]
1213 fn preflight_with_module_flags_accepts_every_registered_id() {
1214 for spec in crate::module::MODULES {
1215 let flag = format!("{}@1.0.0", spec.id);
1216 preflight_with_module_flags(std::slice::from_ref(&flag))
1217 .unwrap_or_else(|err| panic!("{} must preflight cleanly: {err}", spec.id));
1218 }
1219 }
1220
1221 /// RMOD-01 / plan Task 2 item 4: `--with-module` against a rig with
1222 /// NO config-declared module — `merge_declarations` then
1223 /// `provision_modules` end to end — writes an override and reports
1224 /// the module in `ModuleProvisioning::modules` (the shape
1225 /// `RigUpResult.provisioned_modules` clones verbatim); the SAME rig
1226 /// with NO flag (`merge_declarations` against an empty flag slice)
1227 /// writes nothing and reports an empty list (SC-1, re-proven at
1228 /// this seam).
1229 #[tokio::test]
1230 async fn with_module_flag_alone_provisions_and_empty_flags_stay_sc1() {
1231 let project_dir = tempfile::tempdir().expect("project tempdir");
1232 let plan = RigPlan {
1233 name: "fixture-rig".to_string(),
1234 config_name: None,
1235 compose_file: project_dir.path().join("docker-compose.yml"),
1236 project_dir: project_dir.path().to_path_buf(),
1237 services: vec!["ignition".to_string()],
1238 host_ports: Vec::new(),
1239 port_mappings: Vec::new(),
1240 volumes: Vec::new(),
1241 gateway_service: Some("ignition".to_string()),
1242 modules: BTreeMap::new(),
1243 };
1244
1245 // Pre-seed the cache so this is a pure cache hit — the flag
1246 // merge/provisioning path is what's under test, not the feed.
1247 let cache_root = tempfile::tempdir().expect("cache tempdir");
1248 let payload = b"synthetic .modl payload for with_module_flag_alone_provisions".to_vec();
1249 let mut hasher = Sha256::new();
1250 hasher.update(&payload);
1251 let digest = format!("{:x}", hasher.finalize());
1252 let module_dir = cache_root.path().join("modules").join("git");
1253 std::fs::create_dir_all(&module_dir).expect("create module cache dir");
1254 std::fs::write(module_dir.join(format!("2.3.4-{digest}.modl")), &payload)
1255 .expect("seed cached artifact");
1256
1257 let feed = ModuleFeed::for_base(
1258 url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
1259 )
1260 .expect("feed builds");
1261
1262 // No config declaration, no flag: SC-1 stays intact at this seam.
1263 let empty_declared = merge_declarations(&plan.modules, &[]).expect("empty merge");
1264 assert!(empty_declared.is_empty());
1265 let empty_provisioning = provision_modules(
1266 &feed,
1267 &plan,
1268 &empty_declared,
1269 cache_root.path(),
1270 FetchPolicy::CacheFirst,
1271 )
1272 .await
1273 .expect("no-declaration provisioning succeeds");
1274 assert!(empty_provisioning.modules.is_empty());
1275 assert!(empty_provisioning.override_file.is_none());
1276 assert!(
1277 !override_path(&plan)
1278 .expect("override path computes")
1279 .exists(),
1280 "no override file written when nothing is declared"
1281 );
1282
1283 // The flag alone, no config declaration: provisions and reports.
1284 let flagged =
1285 merge_declarations(&plan.modules, &["git@2.3.4".to_string()]).expect("flag-only merge");
1286 assert_eq!(flagged.len(), 1);
1287 let provisioning = provision_modules(
1288 &feed,
1289 &plan,
1290 &flagged,
1291 cache_root.path(),
1292 FetchPolicy::CacheFirst,
1293 )
1294 .await
1295 .expect("flag-only provisioning succeeds with a cache hit");
1296 assert_eq!(provisioning.modules.len(), 1);
1297 assert_eq!(provisioning.modules[0].id, "git");
1298 assert_eq!(provisioning.modules[0].version, "2.3.4");
1299 assert_eq!(provisioning.modules[0].source, ArtifactSource::Cache);
1300 assert!(provisioning.override_file.is_some());
1301 assert!(
1302 override_path(&plan)
1303 .expect("override path computes")
1304 .exists(),
1305 "override file written once a module is provisioned via the flag"
1306 );
1307 }
1308
1309 /// THE actual SC-1 mechanism (not just its OBSERVABLE effect): a
1310 /// rig with NO derivable gateway service (`gateway_service: None`
1311 /// — no port targeting 8088/443, no `module_service` override) and
1312 /// NO declared module (config empty, no `--with-module` flag) must
1313 /// still provision cleanly. `provision_modules` resolves the
1314 /// service ONLY when something is declared (`declared.is_empty()`
1315 /// short-circuits BEFORE that resolution) — a rig this bare would
1316 /// otherwise hit `CoreError::Rig` ("cannot derive a gateway service
1317 /// to mount modules into") for a rig that never asked to mount
1318 /// anything. This is what "prove the short-circuit, don't assert
1319 /// it" means: the two sibling tests above always seed
1320 /// `gateway_service: Some(..)`, which would mask exactly this
1321 /// regression.
1322 #[tokio::test]
1323 async fn no_declaration_provisions_cleanly_even_with_no_derivable_gateway_service() {
1324 let project_dir = tempfile::tempdir().expect("project tempdir");
1325 let plan = RigPlan {
1326 name: "bare-rig".to_string(),
1327 config_name: None,
1328 compose_file: project_dir.path().join("docker-compose.yml"),
1329 project_dir: project_dir.path().to_path_buf(),
1330 services: vec!["sidecar".to_string()],
1331 host_ports: Vec::new(),
1332 port_mappings: Vec::new(),
1333 volumes: Vec::new(),
1334 gateway_service: None,
1335 modules: BTreeMap::new(),
1336 };
1337
1338 let feed = ModuleFeed::for_base(
1339 url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
1340 )
1341 .expect("feed builds");
1342 let cache_root = tempfile::tempdir().expect("cache tempdir");
1343
1344 let declared = merge_declarations(&plan.modules, &[]).expect("empty merge");
1345 assert!(declared.is_empty());
1346 let provisioning = provision_modules(
1347 &feed,
1348 &plan,
1349 &declared,
1350 cache_root.path(),
1351 FetchPolicy::CacheFirst,
1352 )
1353 .await
1354 .expect(
1355 "a rig with no declared module must provision cleanly even when it has \
1356 NO derivable gateway service — service resolution must never run for it",
1357 );
1358 assert!(provisioning.modules.is_empty());
1359 assert!(provisioning.override_file.is_none());
1360 }
1361
1362 /// Test-matrix rule (mandatory): a rig whose override was written
1363 /// by a PREVIOUS run declaring one module in CONFIG, now invoked
1364 /// with an ADDITIONAL `--with-module` flag naming a SECOND, already
1365 /// pre-seeded in the cache — ends with an override naming BOTH.
1366 /// (Task 1's `adding_a_second_module_to_a_pre_existing_override_
1367 /// regenerates_whole` proves this at `write_override`'s layer; this
1368 /// test proves it through the FULL `merge_declarations` →
1369 /// `provision_modules` seam a real `--with-module` invocation
1370 /// drives.)
1371 #[tokio::test]
1372 async fn pre_existing_override_plus_a_flag_module_regenerates() {
1373 let project_dir = tempfile::tempdir().expect("project tempdir");
1374 let mut config_modules = BTreeMap::new();
1375 config_modules.insert(
1376 "git".to_string(),
1377 ModuleDeclaration {
1378 version: "2.3.4".to_string(),
1379 },
1380 );
1381 let plan = RigPlan {
1382 name: "fixture-rig".to_string(),
1383 config_name: None,
1384 compose_file: project_dir.path().join("docker-compose.yml"),
1385 project_dir: project_dir.path().to_path_buf(),
1386 services: vec!["ignition".to_string()],
1387 host_ports: Vec::new(),
1388 port_mappings: Vec::new(),
1389 volumes: Vec::new(),
1390 gateway_service: Some("ignition".to_string()),
1391 modules: config_modules,
1392 };
1393
1394 let cache_root = tempfile::tempdir().expect("cache tempdir");
1395 let seed = |module_id: &str, version: &str, payload: &[u8]| {
1396 let mut hasher = Sha256::new();
1397 hasher.update(payload);
1398 let digest = format!("{:x}", hasher.finalize());
1399 let dir = cache_root.path().join("modules").join(module_id);
1400 std::fs::create_dir_all(&dir).expect("create module cache dir");
1401 std::fs::write(dir.join(format!("{version}-{digest}.modl")), payload)
1402 .expect("seed cached artifact");
1403 };
1404 seed("git", "2.3.4", b"synthetic git payload for regenerate test");
1405 seed(
1406 "project-scan-endpoint",
1407 "1.0.0",
1408 b"synthetic project-scan-endpoint payload for regenerate test",
1409 );
1410
1411 let feed = ModuleFeed::for_base(
1412 url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
1413 )
1414 .expect("feed builds");
1415
1416 // Run 1: config alone (as a prior `rig up` with no flag would).
1417 let declared = merge_declarations(&plan.modules, &[]).expect("config-only merge");
1418 let first = provision_modules(
1419 &feed,
1420 &plan,
1421 &declared,
1422 cache_root.path(),
1423 FetchPolicy::CacheFirst,
1424 )
1425 .await
1426 .expect("config-only provisioning succeeds");
1427 assert_eq!(first.modules.len(), 1);
1428 let override_file = first.override_file.clone().expect("override written");
1429 let one_module_contents =
1430 std::fs::read_to_string(&override_file).expect("read one-module override");
1431 assert!(
1432 one_module_contents.contains("git.modl")
1433 && !one_module_contents.contains("project-scan-endpoint"),
1434 "precondition: the seeded override must name only git"
1435 );
1436
1437 // Run 2: the SAME rig, now with an additional --with-module flag.
1438 let declared =
1439 merge_declarations(&plan.modules, &["project-scan-endpoint@1.0.0".to_string()])
1440 .expect("config-plus-flag merge");
1441 assert_eq!(declared.len(), 2);
1442 let second = provision_modules(
1443 &feed,
1444 &plan,
1445 &declared,
1446 cache_root.path(),
1447 FetchPolicy::CacheFirst,
1448 )
1449 .await
1450 .expect("config-plus-flag provisioning succeeds");
1451 assert_eq!(second.modules.len(), 2);
1452
1453 let two_module_contents =
1454 std::fs::read_to_string(&override_file).expect("read regenerated override");
1455 assert!(
1456 two_module_contents.contains("git.modl"),
1457 "the config-declared module survives: {two_module_contents}"
1458 );
1459 assert!(
1460 two_module_contents.contains("project-scan-endpoint.modl"),
1461 "the flag-added module is present: {two_module_contents}"
1462 );
1463 assert_eq!(
1464 two_module_contents.matches("git.modl").count(),
1465 1,
1466 "git must appear exactly once, not duplicated"
1467 );
1468 assert_eq!(
1469 two_module_contents
1470 .matches("project-scan-endpoint.modl")
1471 .count(),
1472 1,
1473 "the flag-added module must appear exactly once"
1474 );
1475 }
1476
1477 /// The test-matrix rule's cache-mix half: a cache that already
1478 /// holds ONE of two modules while the OTHER must be fetched over
1479 /// the network — a wiremock server stands in for the second
1480 /// module's feed, and its mock is asserted hit EXACTLY once, which
1481 /// is the falsifiable proof the cached entry was never re-fetched.
1482 #[tokio::test]
1483 async fn provisioning_fetches_only_the_missing_artifact_when_one_is_cached() {
1484 let project_dir = tempfile::tempdir().expect("project tempdir");
1485 let plan = RigPlan {
1486 name: "fixture-rig".to_string(),
1487 config_name: None,
1488 compose_file: project_dir.path().join("docker-compose.yml"),
1489 project_dir: project_dir.path().to_path_buf(),
1490 services: vec!["ignition".to_string()],
1491 host_ports: Vec::new(),
1492 port_mappings: Vec::new(),
1493 volumes: Vec::new(),
1494 gateway_service: Some("ignition".to_string()),
1495 modules: BTreeMap::new(),
1496 };
1497
1498 let cache_root = tempfile::tempdir().expect("cache tempdir");
1499 let cached_payload = b"synthetic git payload already cached".to_vec();
1500 let mut hasher = Sha256::new();
1501 hasher.update(&cached_payload);
1502 let cached_digest = format!("{:x}", hasher.finalize());
1503 let git_dir = cache_root.path().join("modules").join("git");
1504 std::fs::create_dir_all(&git_dir).expect("create git cache dir");
1505 std::fs::write(
1506 git_dir.join(format!("2.3.4-{cached_digest}.modl")),
1507 &cached_payload,
1508 )
1509 .expect("seed cached git artifact");
1510
1511 // The second module's release feed — a real wiremock server,
1512 // asserted hit exactly once (`.expect(1)` on the mount).
1513 let server = wiremock::MockServer::start().await;
1514 let scan_payload = b"synthetic project-scan-endpoint payload, freshly fetched".to_vec();
1515 let mut hasher = Sha256::new();
1516 hasher.update(&scan_payload);
1517 let scan_digest = format!("{:x}", hasher.finalize());
1518 let release_json = serde_json::json!({
1519 "tag_name": "v1.0.0",
1520 "assets": [{
1521 "name": "Project-Scan-Endpoint.modl",
1522 "url": format!("{}/assets/1", server.uri()),
1523 "browser_download_url": format!("{}/download/Project-Scan-Endpoint.modl", server.uri()),
1524 "digest": format!("sha256:{scan_digest}"),
1525 "content_type": "application/octet-stream",
1526 "size": scan_payload.len(),
1527 "state": "uploaded",
1528 }],
1529 });
1530 wiremock::Mock::given(wiremock::matchers::method("GET"))
1531 .and(wiremock::matchers::path(
1532 "/repos/bw-design-group/ignition-project-scan-endpoint/releases/tags/v1.0.0",
1533 ))
1534 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(release_json))
1535 .expect(1)
1536 .mount(&server)
1537 .await;
1538 wiremock::Mock::given(wiremock::matchers::method("GET"))
1539 .and(wiremock::matchers::path(
1540 "/download/Project-Scan-Endpoint.modl",
1541 ))
1542 .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(scan_payload.clone()))
1543 .expect(1)
1544 .mount(&server)
1545 .await;
1546 // If `git` were ever re-fetched, THIS path would be hit — it
1547 // isn't mounted at all, so any request to it 404s and the test
1548 // would fail loudly rather than silently re-downloading.
1549
1550 let feed = ModuleFeed::for_base(server.uri().parse().expect("server uri parses"))
1551 .expect("feed builds");
1552
1553 let mut declared = BTreeMap::new();
1554 declared.insert(
1555 "git".to_string(),
1556 ModuleDeclaration {
1557 version: "2.3.4".to_string(),
1558 },
1559 );
1560 declared.insert(
1561 "project-scan-endpoint".to_string(),
1562 ModuleDeclaration {
1563 version: "1.0.0".to_string(),
1564 },
1565 );
1566
1567 let provisioning = provision_modules(
1568 &feed,
1569 &plan,
1570 &declared,
1571 cache_root.path(),
1572 FetchPolicy::CacheFirst,
1573 )
1574 .await
1575 .expect("mixed cache/fetch provisioning succeeds");
1576
1577 assert_eq!(provisioning.modules.len(), 2);
1578 let git_outcome = provisioning
1579 .modules
1580 .iter()
1581 .find(|m| m.id == "git")
1582 .expect("git provisioned");
1583 assert_eq!(
1584 git_outcome.source,
1585 ArtifactSource::Cache,
1586 "the pre-seeded module must be a cache hit, never re-fetched"
1587 );
1588 let scan_outcome = provisioning
1589 .modules
1590 .iter()
1591 .find(|m| m.id == "project-scan-endpoint")
1592 .expect("project-scan-endpoint provisioned");
1593 assert_eq!(
1594 scan_outcome.source,
1595 ArtifactSource::Download,
1596 "the missing module must be freshly downloaded"
1597 );
1598
1599 let contents = std::fs::read_to_string(
1600 provisioning
1601 .override_file
1602 .as_ref()
1603 .expect("override written"),
1604 )
1605 .expect("read override");
1606 assert!(contents.contains("git.modl") && contents.contains("project-scan-endpoint.modl"));
1607 // wiremock's `.expect(1)` mounts assert on drop (server teardown
1608 // at end of scope) — an unmet or exceeded expectation panics.
1609 }
1610}