use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config::ModuleDeclaration;
use crate::error::CoreError;
use crate::module::ModuleSpec;
use crate::module::fetch::{ArtifactSource, FetchPolicy, ModuleFeed};
use super::RigPlan;
const WITH_MODULE_SEPARATOR: char = '@';
fn parse_with_module_flag(raw: &str) -> Result<(String, ModuleDeclaration), CoreError> {
let Some((id, version)) = raw.split_once(WITH_MODULE_SEPARATOR) else {
return Err(CoreError::InvalidInput {
reason: format!(
"--with-module value {raw:?} is missing the '@' separator \
(expected ID@VERSION, e.g. git@2.3.4)"
),
});
};
crate::module::validate_module_id(id)?;
crate::module::validate_version(version)?;
Ok((
id.to_string(),
ModuleDeclaration {
version: version.to_string(),
},
))
}
pub fn merge_declarations(
config: &BTreeMap<String, ModuleDeclaration>,
flags: &[String],
) -> Result<BTreeMap<String, ModuleDeclaration>, CoreError> {
let mut merged = config.clone();
for raw in flags {
let (id, declaration) = parse_with_module_flag(raw)?;
merged.insert(id, declaration);
}
Ok(merged)
}
pub fn preflight_with_module_flags(flags: &[String]) -> Result<(), CoreError> {
let parsed = merge_declarations(&BTreeMap::new(), flags)?;
for id in parsed.keys() {
crate::module::spec_for(id).ok_or_else(|| {
let known: Vec<String> = crate::module::MODULES
.iter()
.map(|spec| spec.id.to_string())
.collect();
CoreError::ModuleNotRegistered {
id: id.clone(),
known,
}
})?;
}
Ok(())
}
pub const OVERRIDE_FILENAME: &str = "compose.ign-modules.yml";
const CONTAINER_MODULE_DIR: &str = "/usr/local/bin/ignition/user-lib/modules";
#[derive(Debug, Clone)]
pub struct MountedModule {
pub spec: &'static ModuleSpec,
pub version: String,
pub source: PathBuf,
pub source_kind: ArtifactSource,
}
fn single_quote_yaml(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn double_quote_yaml(value: &str) -> String {
format!("\"{value}\"")
}
fn join_gateway_module_ids(mounts: &[&MountedModule]) -> String {
mounts
.iter()
.map(|mount| mount.spec.gateway_module_id)
.collect::<Vec<_>>()
.join(",")
}
fn mount_target(id: &str) -> String {
format!("{CONTAINER_MODULE_DIR}/{id}.modl")
}
pub fn generate_override(service: &str, mounts: &[MountedModule]) -> String {
let mut sorted: Vec<&MountedModule> = mounts.iter().collect();
sorted.sort_by_key(|mount| mount.spec.id);
let accept_ids = join_gateway_module_ids(&sorted);
let mut out = String::new();
out.push_str("# Generated by ign — DO NOT EDIT.\n");
out.push_str(
"# 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",
);
out.push_str("services:\n");
out.push_str(&format!(" {}:\n", single_quote_yaml(service)));
out.push_str(" environment:\n");
out.push_str(&format!(
" ACCEPT_IGNITION_EULA: {}\n",
double_quote_yaml("Y")
));
out.push_str(&format!(
" ACCEPT_MODULE_CERTS: {}\n",
double_quote_yaml(&accept_ids)
));
out.push_str(&format!(
" ACCEPT_MODULE_LICENSES: {}\n",
double_quote_yaml(&accept_ids)
));
out.push_str(" volumes:\n");
for mount in &sorted {
let target = mount_target(mount.spec.id);
out.push_str(" - type: bind\n");
out.push_str(&format!(
" source: {}\n",
single_quote_yaml(&mount.source.display().to_string())
));
out.push_str(&format!(" target: {}\n", single_quote_yaml(&target)));
out.push_str(" read_only: true\n");
}
out
}
fn override_path(plan: &RigPlan) -> Result<PathBuf, CoreError> {
let joined = plan.project_dir.join(OVERRIDE_FILENAME);
std::path::absolute(&joined).map_err(|err| {
CoreError::Rig(format!(
"cannot compute absolute override path for {}: {err}",
joined.display()
))
})
}
pub fn existing_override(plan: &RigPlan) -> Option<PathBuf> {
let path = override_path(plan).ok()?;
let metadata = std::fs::metadata(&path).ok()?;
if metadata.is_file() { Some(path) } else { None }
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub struct OrphanedModule {
pub id: String,
pub gateway_module_id: String,
pub remove_with: String,
}
fn candidate_id_from_target_line(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix("target:")?.trim();
let path = rest.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))?;
let stem = path
.strip_prefix(CONTAINER_MODULE_DIR)?
.strip_prefix('/')?
.strip_suffix(".modl")?;
if stem.is_empty() || stem.contains('/') {
return None;
}
Some(stem.to_string())
}
pub fn previously_provisioned(plan: &RigPlan) -> Vec<&'static ModuleSpec> {
let Some(path) = existing_override(plan) else {
return Vec::new();
};
let Ok(contents) = std::fs::read_to_string(&path) else {
return Vec::new();
};
let mut specs: Vec<&'static ModuleSpec> = contents
.lines()
.filter_map(candidate_id_from_target_line)
.filter_map(|id| crate::module::spec_for(&id))
.collect();
specs.sort_by_key(|spec| spec.id);
specs.dedup_by_key(|spec| spec.id);
specs
}
fn shell_quote_rig(name: &str) -> String {
let bare = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
if bare {
return name.to_string();
}
format!("'{}'", name.replace('\'', "'\\''"))
}
pub fn orphaned_modules(
previous: &[&'static ModuleSpec],
declared: &BTreeMap<String, ModuleDeclaration>,
config_name: Option<&str>,
) -> Vec<OrphanedModule> {
let mut orphans: Vec<OrphanedModule> = previous
.iter()
.filter(|spec| !declared.contains_key(spec.id))
.map(|spec| OrphanedModule {
id: spec.id.to_string(),
gateway_module_id: spec.gateway_module_id.to_string(),
remove_with: match config_name {
Some(rig) => format!(
"ign rig module uninstall {} --rig {} --yes",
spec.id,
shell_quote_rig(rig)
),
None => format!("ign rig module uninstall {} --yes", spec.id),
},
})
.collect();
orphans.sort_by(|a, b| a.id.cmp(&b.id));
orphans
}
pub fn write_override(
plan: &RigPlan,
service: &str,
mounts: &[MountedModule],
) -> Result<Option<PathBuf>, CoreError> {
let path = override_path(plan)?;
if mounts.is_empty() {
return match std::fs::remove_file(&path) {
Ok(()) => Ok(None),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(CoreError::Rig(format!(
"cannot remove stale module override at {}: {err}",
path.display()
))),
};
}
if let Ok(metadata) = std::fs::metadata(&path)
&& !metadata.is_file()
{
return Err(CoreError::Rig(format!(
"cannot write module override at {} — something other than a \
regular file already occupies that path; remove it by hand \
and re-run `ign rig up`",
path.display()
)));
}
let contents = generate_override(service, mounts);
std::fs::write(&path, contents).map_err(|err| {
CoreError::Rig(format!(
"cannot write module override at {}: {err}",
path.display()
))
})?;
Ok(Some(path))
}
pub fn preflight_mount_source(id: &str, source: &Path) -> Result<(), CoreError> {
if !source.is_absolute() {
return Err(CoreError::Rig(format!(
"module {id:?} artifact path {} is not absolute — ign refuses \
to mount a relative path (it would resolve against compose's \
own working directory, not this cached artifact)",
source.display()
)));
}
let metadata = std::fs::metadata(source).map_err(|err| {
CoreError::Rig(format!(
"module {id:?} artifact at {} is not accessible: {err} — the \
cache entry is missing; re-run `ign rig up` to re-fetch it",
source.display()
))
})?;
if !metadata.is_file() {
return Err(CoreError::Rig(format!(
"module {id:?} artifact path {} is not a regular file (found a \
directory) — a missing or corrupted bind-mount source \
silently becomes an empty directory on both host and \
container; remove it and re-run `ign rig up` to re-fetch",
source.display()
)));
}
Ok(())
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ProvisionedModule {
pub id: String,
pub version: String,
pub gateway_module_id: String,
pub digest_sha256: String,
pub source: ArtifactSource,
pub mount_target: String,
}
#[derive(Debug, Clone, Default)]
pub struct ModuleProvisioning {
pub override_file: Option<PathBuf>,
pub modules: Vec<ProvisionedModule>,
pub orphaned_modules: Vec<OrphanedModule>,
}
impl ModuleProvisioning {
pub fn override_files(&self) -> &[PathBuf] {
self.override_file.as_slice()
}
}
pub fn clear_override(plan: &RigPlan) -> Result<(), CoreError> {
write_override(plan, "", &[]).map(|_| ())
}
pub fn undeclared_provisioning(plan: &RigPlan) -> Result<ModuleProvisioning, CoreError> {
let previous = previously_provisioned(plan);
let orphaned = orphaned_modules(&previous, &BTreeMap::new(), plan.config_name.as_deref());
clear_override(plan)?;
Ok(ModuleProvisioning {
override_file: None,
modules: Vec::new(),
orphaned_modules: orphaned,
})
}
pub async fn provision_modules(
feed: &ModuleFeed,
plan: &RigPlan,
declared: &BTreeMap<String, ModuleDeclaration>,
cache_root: &Path,
policy: FetchPolicy,
) -> Result<ModuleProvisioning, CoreError> {
if declared.is_empty() {
return undeclared_provisioning(plan);
}
let previous = previously_provisioned(plan);
let service = plan.gateway_service.clone().ok_or_else(|| {
CoreError::Rig(format!(
"cannot derive a gateway service to mount modules into for rig {:?} \
(services: {:?}) — set module_service under [rigs.{}] to name one \
explicitly",
plan.name, plan.services, plan.name
))
})?;
let mut mounts: Vec<MountedModule> = Vec::new();
let mut provisioned: Vec<ProvisionedModule> = Vec::new();
for (id, declaration) in declared {
let spec = crate::module::spec_for(id).ok_or_else(|| {
let known: Vec<String> = crate::module::MODULES
.iter()
.map(|s| s.id.to_string())
.collect();
CoreError::ModuleNotRegistered {
id: id.clone(),
known,
}
})?;
let fetched = feed
.fetch_and_verify(spec, &declaration.version, cache_root, policy)
.await?;
preflight_mount_source(spec.id, &fetched.path)?;
mounts.push(MountedModule {
spec,
version: declaration.version.clone(),
source: fetched.path.clone(),
source_kind: fetched.source,
});
provisioned.push(ProvisionedModule {
id: spec.id.to_string(),
version: declaration.version.clone(),
gateway_module_id: spec.gateway_module_id.to_string(),
digest_sha256: fetched.digest_sha256,
source: fetched.source,
mount_target: mount_target(spec.id),
});
}
let override_file = write_override(plan, &service, &mounts)?;
let orphaned = orphaned_modules(&previous, declared, plan.config_name.as_deref());
Ok(ModuleProvisioning {
override_file,
modules: provisioned,
orphaned_modules: orphaned,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::module::fetch::{ArtifactSource, FetchPolicy, ModuleFeed};
use crate::module::{GIT_MODULE, PROJECT_SCAN_ENDPOINT};
use sha2::{Digest, Sha256};
#[test]
fn generate_override_pins_golden_bytes_and_quoting() {
let mount = MountedModule {
spec: &GIT_MODULE,
version: "2.3.4".to_string(),
source: PathBuf::from("/cache/modules/git/2.3.4/Git-2.3.4-signed.modl"),
source_kind: ArtifactSource::Cache,
};
let expected = concat!(
"# Generated by ign — DO NOT EDIT.\n",
"# 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",
"services:\n",
" 'ignition':\n",
" environment:\n",
" ACCEPT_IGNITION_EULA: \"Y\"\n",
" ACCEPT_MODULE_CERTS: \"com.axone_io.ignition.git\"\n",
" ACCEPT_MODULE_LICENSES: \"com.axone_io.ignition.git\"\n",
" volumes:\n",
" - type: bind\n",
" source: '/cache/modules/git/2.3.4/Git-2.3.4-signed.modl'\n",
" target: '/usr/local/bin/ignition/user-lib/modules/git.modl'\n",
" read_only: true\n",
);
assert_eq!(
generate_override("ignition", std::slice::from_ref(&mount)),
expected,
"golden bytes drifted from the pinned override shape"
);
let quoted_mount = MountedModule {
spec: &GIT_MODULE,
version: "9.9.9".to_string(),
source: PathBuf::from("/cache/O'Brien's Modules/git-9.9.9.modl"),
source_kind: ArtifactSource::Download,
};
let out = generate_override("ignition", std::slice::from_ref("ed_mount));
assert!(
out.contains(" source: '/cache/O''Brien''s Modules/git-9.9.9.modl'\n"),
"an embedded apostrophe must double, never vanish or backslash-escape: {out}"
);
}
#[test]
fn two_modules_on_one_rig_golden() {
let git_mount = MountedModule {
spec: &GIT_MODULE,
version: "2.3.4".to_string(),
source: PathBuf::from("/cache/modules/git/2.3.4-deadbeef.modl"),
source_kind: ArtifactSource::Cache,
};
let scan_mount = MountedModule {
spec: &PROJECT_SCAN_ENDPOINT,
version: "1.0.0".to_string(),
source: PathBuf::from("/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl"),
source_kind: ArtifactSource::Download,
};
let mounts = [scan_mount, git_mount];
let expected = concat!(
"# Generated by ign — DO NOT EDIT.\n",
"# 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",
"services:\n",
" 'ignition':\n",
" environment:\n",
" ACCEPT_IGNITION_EULA: \"Y\"\n",
" ACCEPT_MODULE_CERTS: \"com.axone_io.ignition.git,project-scan-endpoint\"\n",
" ACCEPT_MODULE_LICENSES: \"com.axone_io.ignition.git,project-scan-endpoint\"\n",
" volumes:\n",
" - type: bind\n",
" source: '/cache/modules/git/2.3.4-deadbeef.modl'\n",
" target: '/usr/local/bin/ignition/user-lib/modules/git.modl'\n",
" read_only: true\n",
" - type: bind\n",
" source: '/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl'\n",
" target: '/usr/local/bin/ignition/user-lib/modules/project-scan-endpoint.modl'\n",
" read_only: true\n",
);
assert_eq!(
generate_override("ignition", &mounts),
expected,
"two-module golden drifted — check mount ordering, the comma-join, \
or the per-mount block shape"
);
}
#[test]
fn windows_style_source_path_golden() {
let mount = MountedModule {
spec: &GIT_MODULE,
version: "2.3.4".to_string(),
source: PathBuf::from(
r"C:\Users\dev\AppData\Local\ignition-cli\modules\git\2.3.4-deadbeef.modl",
),
source_kind: ArtifactSource::Cache,
};
let out = generate_override("ignition", std::slice::from_ref(&mount));
assert!(
out.contains(
" source: 'C:\\Users\\dev\\AppData\\Local\\ignition-cli\\modules\\git\\2.3.4-deadbeef.modl'\n"
),
"the Windows-style path must survive intact inside a single-quoted \
long-form mapping — a short-form colon-separated mount would have \
mistaken the drive-letter colon for the source:target separator: {out}"
);
}
#[test]
fn adding_a_second_module_to_a_pre_existing_override_regenerates_whole() {
let project_dir = tempfile::tempdir().expect("project tempdir");
let plan = RigPlan {
name: "fixture-rig".to_string(),
config_name: None,
compose_file: project_dir.path().join("docker-compose.yml"),
project_dir: project_dir.path().to_path_buf(),
services: vec!["ignition".to_string()],
host_ports: Vec::new(),
port_mappings: Vec::new(),
volumes: Vec::new(),
gateway_service: Some("ignition".to_string()),
modules: BTreeMap::new(),
};
let git_mount = MountedModule {
spec: &GIT_MODULE,
version: "2.3.4".to_string(),
source: PathBuf::from("/cache/modules/git/2.3.4-deadbeef.modl"),
source_kind: ArtifactSource::Cache,
};
let path = write_override(&plan, "ignition", std::slice::from_ref(&git_mount))
.expect("seed a one-module override, as if a prior run provisioned only git")
.expect("mounts were non-empty");
let one_module_contents = std::fs::read_to_string(&path).expect("read one-module override");
assert!(
one_module_contents.contains("git.modl")
&& !one_module_contents.contains("project-scan-endpoint"),
"precondition: the seeded override must name only the git module"
);
let scan_mount = MountedModule {
spec: &PROJECT_SCAN_ENDPOINT,
version: "1.0.0".to_string(),
source: PathBuf::from("/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl"),
source_kind: ArtifactSource::Cache,
};
write_override(&plan, "ignition", &[git_mount.clone(), scan_mount.clone()])
.expect("regenerate the override with both modules")
.expect("mounts were non-empty");
let two_module_contents =
std::fs::read_to_string(&path).expect("read regenerated override");
assert_eq!(
two_module_contents,
generate_override("ignition", &[git_mount, scan_mount]),
"the file must equal the fresh golden for BOTH mounts"
);
assert_eq!(
two_module_contents.matches("git.modl").count(),
1,
"the git mount must appear exactly once, not duplicated: {two_module_contents}"
);
assert_eq!(
two_module_contents
.matches("project-scan-endpoint.modl")
.count(),
1,
"the newly added module must appear exactly once: {two_module_contents}"
);
}
#[tokio::test]
async fn user_compose_file_is_never_modified() {
let project_dir = tempfile::tempdir().expect("project tempdir");
let compose_path = project_dir.path().join("docker-compose.yml");
let compose_bytes: &[u8] =
b"services:\n ignition:\n image: inductiveautomation/ignition:8.3.6\n";
std::fs::write(&compose_path, compose_bytes).expect("seed the user's compose file");
let before = std::fs::read(&compose_path).expect("read compose before provisioning");
let cache_root = tempfile::tempdir().expect("cache tempdir");
let payload = b"synthetic .modl payload for user_compose_file_is_never_modified".to_vec();
let mut hasher = Sha256::new();
hasher.update(&payload);
let digest = format!("{:x}", hasher.finalize());
let module_dir = cache_root.path().join("modules").join("git");
std::fs::create_dir_all(&module_dir).expect("create module cache dir");
std::fs::write(module_dir.join(format!("2.3.4-{digest}.modl")), &payload)
.expect("seed cached artifact");
let plan = RigPlan {
name: "fixture-rig".to_string(),
config_name: None,
compose_file: compose_path.clone(),
project_dir: project_dir.path().to_path_buf(),
services: vec!["ignition".to_string()],
host_ports: vec![9088],
port_mappings: Vec::new(),
volumes: Vec::new(),
gateway_service: Some("ignition".to_string()),
modules: BTreeMap::new(),
};
let mut declared: BTreeMap<String, ModuleDeclaration> = BTreeMap::new();
declared.insert(
"git".to_string(),
ModuleDeclaration {
version: "2.3.4".to_string(),
},
);
let feed = ModuleFeed::for_base(
url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
)
.expect("feed builds");
let provisioning = provision_modules(
&feed,
&plan,
&declared,
cache_root.path(),
FetchPolicy::CacheFirst,
)
.await
.expect("cache-hit provisioning succeeds with nothing listening on the network");
let expected_override = project_dir.path().join(OVERRIDE_FILENAME);
assert_eq!(
provisioning.override_file.as_deref(),
Some(expected_override.as_path()),
"the override is written into the project dir, never the user's compose path"
);
assert!(
expected_override.exists(),
"the override file itself must exist on disk after provisioning"
);
let after = std::fs::read(&compose_path).expect("read compose after provisioning");
assert_eq!(
before, after,
"the user's compose file must be byte-identical before and after provisioning"
);
assert_eq!(
after, compose_bytes,
"and must equal exactly the bytes this test seeded"
);
}
#[test]
fn merge_declarations_overlays_flag_onto_config() {
let mut config = BTreeMap::new();
config.insert(
"git".to_string(),
ModuleDeclaration {
version: "2.3.4".to_string(),
},
);
let merged = merge_declarations(&config, &["project-scan-endpoint@1.0.0".to_string()])
.expect("well-formed flag merges");
assert_eq!(merged.len(), 2);
assert_eq!(merged["git"].version, "2.3.4", "config entry untouched");
assert_eq!(merged["project-scan-endpoint"].version, "1.0.0");
let merged =
merge_declarations(&config, &["git@9.9.9".to_string()]).expect("same-id flag merges");
assert_eq!(merged.len(), 1, "one entry, not two, for the same id");
assert_eq!(
merged["git"].version, "9.9.9",
"the FLAG's version wins over config's"
);
assert_eq!(config["git"].version, "2.3.4");
}
#[test]
fn merge_declarations_refuses_malformed_values() {
let config = BTreeMap::new();
let bad = "git-no-separator";
let err = merge_declarations(&config, &[bad.to_string()])
.expect_err("a value with no '@' must be refused");
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2);
assert!(
err.to_string().contains(bad),
"message names the received value {bad:?}: {err}"
);
for (bad, expected_fragment) in [
("UPPER@2.3.4", "UPPER"),
("git@../escape", "../escape"),
("@2.3.4", "module id \"\" is not a safe identifier"),
("@", "module id \"\" is not a safe identifier"),
("git@", "module version \"\" is not a safe version string"),
] {
let err = merge_declarations(&config, &[bad.to_string()])
.expect_err(&format!("{bad:?} must be refused"));
assert_eq!(err.code(), "invalid_input", "{bad:?}");
assert_eq!(err.exit_code(), 2, "{bad:?}");
assert!(
err.to_string().contains(expected_fragment),
"message names the offending half {expected_fragment:?}: {err}"
);
}
}
#[test]
fn merge_declarations_is_order_independent() {
let config = BTreeMap::new();
let forward = merge_declarations(
&config,
&[
"project-scan-endpoint@1.0.0".to_string(),
"git@2.3.4".to_string(),
],
)
.expect("forward order merges");
let reverse = merge_declarations(
&config,
&[
"git@2.3.4".to_string(),
"project-scan-endpoint@1.0.0".to_string(),
],
)
.expect("reverse order merges");
assert_eq!(forward, reverse, "flag order must not affect the result");
assert_eq!(
forward.keys().collect::<Vec<_>>(),
vec!["git", "project-scan-endpoint"],
"iteration order is id order, not flag order"
);
}
#[test]
fn preflight_with_module_flags_refuses_malformed_value() {
let err = preflight_with_module_flags(&["not-a-flag-value".to_string()])
.expect_err("malformed value refused");
assert_eq!(err.code(), "invalid_input");
assert_eq!(err.exit_code(), 2);
}
#[test]
fn preflight_with_module_flags_refuses_unregistered_id() {
let err = preflight_with_module_flags(&["nope@1.0.0".to_string()])
.expect_err("unregistered id refused");
assert_eq!(err.code(), "module_not_registered");
assert_eq!(err.exit_code(), 3);
let message = err.to_string();
assert!(message.contains("nope"), "{message}");
assert!(message.contains("git"), "known ids listed: {message}");
assert!(
message.contains("project-scan-endpoint"),
"known ids listed: {message}"
);
}
#[test]
fn preflight_with_module_flags_accepts_every_registered_id() {
for spec in crate::module::MODULES {
let flag = format!("{}@1.0.0", spec.id);
preflight_with_module_flags(std::slice::from_ref(&flag))
.unwrap_or_else(|err| panic!("{} must preflight cleanly: {err}", spec.id));
}
}
#[tokio::test]
async fn with_module_flag_alone_provisions_and_empty_flags_stay_sc1() {
let project_dir = tempfile::tempdir().expect("project tempdir");
let plan = RigPlan {
name: "fixture-rig".to_string(),
config_name: None,
compose_file: project_dir.path().join("docker-compose.yml"),
project_dir: project_dir.path().to_path_buf(),
services: vec!["ignition".to_string()],
host_ports: Vec::new(),
port_mappings: Vec::new(),
volumes: Vec::new(),
gateway_service: Some("ignition".to_string()),
modules: BTreeMap::new(),
};
let cache_root = tempfile::tempdir().expect("cache tempdir");
let payload = b"synthetic .modl payload for with_module_flag_alone_provisions".to_vec();
let mut hasher = Sha256::new();
hasher.update(&payload);
let digest = format!("{:x}", hasher.finalize());
let module_dir = cache_root.path().join("modules").join("git");
std::fs::create_dir_all(&module_dir).expect("create module cache dir");
std::fs::write(module_dir.join(format!("2.3.4-{digest}.modl")), &payload)
.expect("seed cached artifact");
let feed = ModuleFeed::for_base(
url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
)
.expect("feed builds");
let empty_declared = merge_declarations(&plan.modules, &[]).expect("empty merge");
assert!(empty_declared.is_empty());
let empty_provisioning = provision_modules(
&feed,
&plan,
&empty_declared,
cache_root.path(),
FetchPolicy::CacheFirst,
)
.await
.expect("no-declaration provisioning succeeds");
assert!(empty_provisioning.modules.is_empty());
assert!(empty_provisioning.override_file.is_none());
assert!(
!override_path(&plan)
.expect("override path computes")
.exists(),
"no override file written when nothing is declared"
);
let flagged =
merge_declarations(&plan.modules, &["git@2.3.4".to_string()]).expect("flag-only merge");
assert_eq!(flagged.len(), 1);
let provisioning = provision_modules(
&feed,
&plan,
&flagged,
cache_root.path(),
FetchPolicy::CacheFirst,
)
.await
.expect("flag-only provisioning succeeds with a cache hit");
assert_eq!(provisioning.modules.len(), 1);
assert_eq!(provisioning.modules[0].id, "git");
assert_eq!(provisioning.modules[0].version, "2.3.4");
assert_eq!(provisioning.modules[0].source, ArtifactSource::Cache);
assert!(provisioning.override_file.is_some());
assert!(
override_path(&plan)
.expect("override path computes")
.exists(),
"override file written once a module is provisioned via the flag"
);
}
#[tokio::test]
async fn no_declaration_provisions_cleanly_even_with_no_derivable_gateway_service() {
let project_dir = tempfile::tempdir().expect("project tempdir");
let plan = RigPlan {
name: "bare-rig".to_string(),
config_name: None,
compose_file: project_dir.path().join("docker-compose.yml"),
project_dir: project_dir.path().to_path_buf(),
services: vec!["sidecar".to_string()],
host_ports: Vec::new(),
port_mappings: Vec::new(),
volumes: Vec::new(),
gateway_service: None,
modules: BTreeMap::new(),
};
let feed = ModuleFeed::for_base(
url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
)
.expect("feed builds");
let cache_root = tempfile::tempdir().expect("cache tempdir");
let declared = merge_declarations(&plan.modules, &[]).expect("empty merge");
assert!(declared.is_empty());
let provisioning = provision_modules(
&feed,
&plan,
&declared,
cache_root.path(),
FetchPolicy::CacheFirst,
)
.await
.expect(
"a rig with no declared module must provision cleanly even when it has \
NO derivable gateway service — service resolution must never run for it",
);
assert!(provisioning.modules.is_empty());
assert!(provisioning.override_file.is_none());
}
#[tokio::test]
async fn pre_existing_override_plus_a_flag_module_regenerates() {
let project_dir = tempfile::tempdir().expect("project tempdir");
let mut config_modules = BTreeMap::new();
config_modules.insert(
"git".to_string(),
ModuleDeclaration {
version: "2.3.4".to_string(),
},
);
let plan = RigPlan {
name: "fixture-rig".to_string(),
config_name: None,
compose_file: project_dir.path().join("docker-compose.yml"),
project_dir: project_dir.path().to_path_buf(),
services: vec!["ignition".to_string()],
host_ports: Vec::new(),
port_mappings: Vec::new(),
volumes: Vec::new(),
gateway_service: Some("ignition".to_string()),
modules: config_modules,
};
let cache_root = tempfile::tempdir().expect("cache tempdir");
let seed = |module_id: &str, version: &str, payload: &[u8]| {
let mut hasher = Sha256::new();
hasher.update(payload);
let digest = format!("{:x}", hasher.finalize());
let dir = cache_root.path().join("modules").join(module_id);
std::fs::create_dir_all(&dir).expect("create module cache dir");
std::fs::write(dir.join(format!("{version}-{digest}.modl")), payload)
.expect("seed cached artifact");
};
seed("git", "2.3.4", b"synthetic git payload for regenerate test");
seed(
"project-scan-endpoint",
"1.0.0",
b"synthetic project-scan-endpoint payload for regenerate test",
);
let feed = ModuleFeed::for_base(
url::Url::parse("http://127.0.0.1:1").expect("unroutable url parses"),
)
.expect("feed builds");
let declared = merge_declarations(&plan.modules, &[]).expect("config-only merge");
let first = provision_modules(
&feed,
&plan,
&declared,
cache_root.path(),
FetchPolicy::CacheFirst,
)
.await
.expect("config-only provisioning succeeds");
assert_eq!(first.modules.len(), 1);
let override_file = first.override_file.clone().expect("override written");
let one_module_contents =
std::fs::read_to_string(&override_file).expect("read one-module override");
assert!(
one_module_contents.contains("git.modl")
&& !one_module_contents.contains("project-scan-endpoint"),
"precondition: the seeded override must name only git"
);
let declared =
merge_declarations(&plan.modules, &["project-scan-endpoint@1.0.0".to_string()])
.expect("config-plus-flag merge");
assert_eq!(declared.len(), 2);
let second = provision_modules(
&feed,
&plan,
&declared,
cache_root.path(),
FetchPolicy::CacheFirst,
)
.await
.expect("config-plus-flag provisioning succeeds");
assert_eq!(second.modules.len(), 2);
let two_module_contents =
std::fs::read_to_string(&override_file).expect("read regenerated override");
assert!(
two_module_contents.contains("git.modl"),
"the config-declared module survives: {two_module_contents}"
);
assert!(
two_module_contents.contains("project-scan-endpoint.modl"),
"the flag-added module is present: {two_module_contents}"
);
assert_eq!(
two_module_contents.matches("git.modl").count(),
1,
"git must appear exactly once, not duplicated"
);
assert_eq!(
two_module_contents
.matches("project-scan-endpoint.modl")
.count(),
1,
"the flag-added module must appear exactly once"
);
}
#[tokio::test]
async fn provisioning_fetches_only_the_missing_artifact_when_one_is_cached() {
let project_dir = tempfile::tempdir().expect("project tempdir");
let plan = RigPlan {
name: "fixture-rig".to_string(),
config_name: None,
compose_file: project_dir.path().join("docker-compose.yml"),
project_dir: project_dir.path().to_path_buf(),
services: vec!["ignition".to_string()],
host_ports: Vec::new(),
port_mappings: Vec::new(),
volumes: Vec::new(),
gateway_service: Some("ignition".to_string()),
modules: BTreeMap::new(),
};
let cache_root = tempfile::tempdir().expect("cache tempdir");
let cached_payload = b"synthetic git payload already cached".to_vec();
let mut hasher = Sha256::new();
hasher.update(&cached_payload);
let cached_digest = format!("{:x}", hasher.finalize());
let git_dir = cache_root.path().join("modules").join("git");
std::fs::create_dir_all(&git_dir).expect("create git cache dir");
std::fs::write(
git_dir.join(format!("2.3.4-{cached_digest}.modl")),
&cached_payload,
)
.expect("seed cached git artifact");
let server = wiremock::MockServer::start().await;
let scan_payload = b"synthetic project-scan-endpoint payload, freshly fetched".to_vec();
let mut hasher = Sha256::new();
hasher.update(&scan_payload);
let scan_digest = format!("{:x}", hasher.finalize());
let release_json = serde_json::json!({
"tag_name": "v1.0.0",
"assets": [{
"name": "Project-Scan-Endpoint.modl",
"url": format!("{}/assets/1", server.uri()),
"browser_download_url": format!("{}/download/Project-Scan-Endpoint.modl", server.uri()),
"digest": format!("sha256:{scan_digest}"),
"content_type": "application/octet-stream",
"size": scan_payload.len(),
"state": "uploaded",
}],
});
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/repos/bw-design-group/ignition-project-scan-endpoint/releases/tags/v1.0.0",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(release_json))
.expect(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/download/Project-Scan-Endpoint.modl",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(scan_payload.clone()))
.expect(1)
.mount(&server)
.await;
let feed = ModuleFeed::for_base(server.uri().parse().expect("server uri parses"))
.expect("feed builds");
let mut declared = BTreeMap::new();
declared.insert(
"git".to_string(),
ModuleDeclaration {
version: "2.3.4".to_string(),
},
);
declared.insert(
"project-scan-endpoint".to_string(),
ModuleDeclaration {
version: "1.0.0".to_string(),
},
);
let provisioning = provision_modules(
&feed,
&plan,
&declared,
cache_root.path(),
FetchPolicy::CacheFirst,
)
.await
.expect("mixed cache/fetch provisioning succeeds");
assert_eq!(provisioning.modules.len(), 2);
let git_outcome = provisioning
.modules
.iter()
.find(|m| m.id == "git")
.expect("git provisioned");
assert_eq!(
git_outcome.source,
ArtifactSource::Cache,
"the pre-seeded module must be a cache hit, never re-fetched"
);
let scan_outcome = provisioning
.modules
.iter()
.find(|m| m.id == "project-scan-endpoint")
.expect("project-scan-endpoint provisioned");
assert_eq!(
scan_outcome.source,
ArtifactSource::Download,
"the missing module must be freshly downloaded"
);
let contents = std::fs::read_to_string(
provisioning
.override_file
.as_ref()
.expect("override written"),
)
.expect("read override");
assert!(contents.contains("git.modl") && contents.contains("project-scan-endpoint.modl"));
}
}