use super::failure_text;
use crate::core::types::{Machine, Resource};
use crate::transport;
pub(crate) fn missing_outputs(resource: &Resource, machine: &Machine) -> Vec<String> {
if resource.output_artifacts.is_empty() || !crate::transport::controller_answers_for(machine) {
return Vec::new();
}
let base = crate::core::task::probe::probe_base_dir(resource);
resource
.output_artifacts
.iter()
.filter(|a| !crate::core::task::probe::resolve_under(&base, a).exists())
.cloned()
.collect()
}
pub(crate) fn unproduced_outputs_error(resource: &Resource, machine: &Machine) -> Option<String> {
let missing = missing_outputs(resource, machine);
if missing.is_empty() {
None
} else {
Some(missing_outputs_error(&missing))
}
}
pub(crate) fn missing_outputs_error(missing: &[String]) -> String {
format!(
"command exited 0 but declared output artifact(s) were not produced: {}. \
The resource is NOT converged — a script can exit 0 without doing its \
job (a swallowed error, a wrong path, or a command that consumed the \
rest of the script from stdin).",
missing.join(", ")
)
}
pub(crate) fn run_pre_apply_hook(
machine: &Machine,
hook: &str,
timeout: Option<u64>,
) -> Option<String> {
run_hook(machine, hook, timeout, "pre_apply")
}
pub(crate) fn check_post_hook(
machine: &Machine,
hook: &str,
timeout: Option<u64>,
) -> Option<String> {
run_hook(machine, hook, timeout, "post_apply")
}
fn run_hook(machine: &Machine, hook: &str, timeout: Option<u64>, label: &str) -> Option<String> {
if let Err(e) = crate::core::purifier::validate_script(hook) {
return Some(format!("{label} hook failed I8 validation: {e}"));
}
match transport::exec_script_timeout(machine, hook, timeout) {
Ok(out) if !out.success() => Some(failure_text::hook_failure(label, &out)),
Err(e) => Some(failure_text::hook_error(label, &e)),
_ => None,
}
}
pub fn unverified_after_apply(resource: &Resource, machine: &Machine) -> Option<String> {
if !verification_enabled() {
return None;
}
verify_against_host(resource, machine)
}
pub fn verification_enabled() -> bool {
std::env::var("FORJAR_VERIFY").as_deref() != Ok("warn")
}
pub fn verify_against_host(resource: &Resource, machine: &Machine) -> Option<String> {
let script = crate::core::codegen::check_script(resource).ok()?;
match crate::transport::exec_script(machine, &script) {
Ok(out) if out.success() => None,
Ok(out) if out.exit_code == 2 => None,
Ok(out) => Some(failure_text::host_verdict(&out)),
Err(_) => None,
}
}
pub(crate) fn post_apply_failure(
resolved: &Resource,
machine: &Machine,
timeout_secs: Option<u64>,
) -> Option<String> {
if let Some(ref post_hook) = resolved.post_apply {
if let Some(error) = check_post_hook(machine, post_hook, timeout_secs) {
return Some(error);
}
}
if let Some(error) = unproduced_outputs_error(resolved, machine) {
return Some(error);
}
unverified_after_apply(resolved, machine)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::ResourceType;
fn local() -> Machine {
serde_yaml_ng::from_str("hostname: localhost\naddr: localhost\n").unwrap()
}
fn remote() -> Machine {
serde_yaml_ng::from_str("hostname: far\naddr: 10.9.9.9\n").unwrap()
}
fn namespace() -> Machine {
serde_yaml_ng::from_str("hostname: ns\naddr: 127.0.0.1\ntransport: pepita\n").unwrap()
}
fn task(dir: &std::path::Path, outs: &[&str]) -> Resource {
Resource {
resource_type: ResourceType::Task,
output_artifacts: outs.iter().map(|s| s.to_string()).collect(),
working_dir: Some(dir.display().to_string()),
..Default::default()
}
}
#[test]
fn a_missing_declared_artifact_is_reported() {
let d = tempfile::tempdir().unwrap();
let r = task(d.path(), &["second.txt"]);
assert_eq!(
missing_outputs(&r, &local()),
vec!["second.txt".to_string()]
);
}
#[test]
fn a_produced_artifact_is_not_reported() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("second.txt"), "ok").unwrap();
assert!(missing_outputs(&task(d.path(), &["second.txt"]), &local()).is_empty());
}
#[test]
fn only_the_missing_ones_are_named() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("there.txt"), "ok").unwrap();
assert_eq!(
missing_outputs(&task(d.path(), &["there.txt", "gone.txt"]), &local()),
vec!["gone.txt".to_string()]
);
}
#[test]
fn a_directory_artifact_counts_as_produced() {
let d = tempfile::tempdir().unwrap();
std::fs::create_dir_all(d.path().join("build")).unwrap();
assert!(missing_outputs(&task(d.path(), &["build"]), &local()).is_empty());
}
#[test]
fn a_resource_declaring_no_outputs_is_not_verified() {
let d = tempfile::tempdir().unwrap();
assert!(missing_outputs(&task(d.path(), &[]), &local()).is_empty());
}
#[test]
fn a_remote_resource_is_never_verified_against_this_host() {
let d = tempfile::tempdir().unwrap();
assert!(
missing_outputs(&task(d.path(), &["second.txt"]), &remote()).is_empty(),
"a remote target must not be judged by this host's filesystem"
);
}
#[test]
fn the_apply_entry_point_is_silent_when_there_is_nothing_to_answer_for() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("x"), "ok").unwrap();
assert!(unproduced_outputs_error(&task(d.path(), &["x"]), &local()).is_none());
assert!(unproduced_outputs_error(&task(d.path(), &[]), &local()).is_none());
assert!(unproduced_outputs_error(&task(d.path(), &["gone"]), &remote()).is_none());
assert!(unproduced_outputs_error(&task(d.path(), &["gone"]), &local()).is_some());
}
#[test]
fn the_error_names_the_artifacts_and_the_likely_cause() {
let e = missing_outputs_error(&["a.txt".into(), "b.txt".into()]);
assert!(e.contains("a.txt") && e.contains("b.txt"), "{e}");
assert!(e.contains("NOT converged"), "{e}");
}
#[test]
fn only_a_machine_this_host_answers_for_has_its_artifacts_verified_here() {
let d = tempfile::tempdir().unwrap();
let r = task(d.path(), &["absent.txt"]);
for (machine, expected, why) in [
(
local(),
vec!["absent.txt".to_string()],
"a local target IS the controller, so a declared artifact that is not here is genuinely missing",
),
(
remote(),
Vec::new(),
"a routable remote was already exempt and stays exempt",
),
(
namespace(),
Vec::new(),
"forjar#495: a namespaced machine's artifacts are inside the namespace, not here, so looking for them on the controller reports a resource that is fine as broken",
),
] {
assert_eq!(missing_outputs(&r, &machine), expected, "{why}");
}
}
}