diff --git a/controller/app/src/main/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcome.java b/controller/app/src/main/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcome.java index 6ec59f2b..0efead42 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcome.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcome.java @@ -15,9 +15,11 @@ public final class AnsibleRunOutcome { - // Pre-recap failure hints: used ONLY when no PLAY RECAP was seen (Ansible crashed before it - // could summarize). When a recap IS present these are ignored, because Ansible prints [ERROR] - // and fatal lines for ignored errors too, which are not failures. + // Hard crash signatures: Ansible can die printing these yet still exit 0, and a later clean + // PLAY RECAP (IIAB emits several intermediate recaps) must NOT hide them, so a crash always fails. + private boolean sawCrash = false; + // [ERROR] is softer: Ansible prints it for IGNORED errors too (counted in ignored=, not failed=), + // so a clean recap overrides it. Used as the verdict only when no recap was seen. private boolean sawError = false; private boolean sawRecap = false; // a PLAY RECAP host-summary line appeared private boolean recapFailure = false; // a recap host line reported failed>0 or unreachable>0 @@ -25,9 +27,11 @@ public final class AnsibleRunOutcome { /** Feed each output line as it streams from the container. */ public void observe(String line) { if (line == null) return; - if (line.contains("[ERROR]") - || line.contains("Unable to use multiprocessing") + if (line.contains("Unable to use multiprocessing") || line.contains("HEARTBEAT SESSION STOPPED")) { + sawCrash = true; + } + if (line.contains("[ERROR]")) { sawError = true; } // A PLAY RECAP host summary carries BOTH "unreachable=" and "failed=" (every real recap line @@ -40,10 +44,11 @@ public void observe(String line) { } } - /** True if the run failed. A non-zero exit always fails; otherwise trust the PLAY RECAP when - * present, and fall back to the [ERROR]/crash signatures only when no recap was emitted. */ + /** True if the run failed. A non-zero exit or a hard crash signature always fails; otherwise + * trust the PLAY RECAP when present, and fall back to the [ERROR] signal only with no recap. */ public boolean failed(int exitCode) { if (exitCode != 0) return true; + if (sawCrash) return true; if (sawRecap) return recapFailure; return sawError; } diff --git a/controller/app/src/test/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcomeTest.java b/controller/app/src/test/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcomeTest.java index b713c8ac..610b9ae1 100644 --- a/controller/app/src/test/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcomeTest.java +++ b/controller/app/src/test/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcomeTest.java @@ -72,4 +72,13 @@ public class AnsibleRunOutcomeTest { o.observe("127.0.0.1 : ok=1 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0"); assertTrue(o.failed(0)); } + + @Test public void crashSignatureAfterCleanRecap_isStillFailed() { + // K2GO-450: IIAB emits several intermediate PLAY RECAPs, so a hard crash can follow an early + // clean recap. A clean recap only overrides the softer [ERROR], never a crash signature. + AnsibleRunOutcome o = new AnsibleRunOutcome(); + o.observe("127.0.0.1 : ok=5 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0"); + o.observe("ERROR! Unable to use multiprocessing, see stderr (lack of access to /dev/shm)"); + assertTrue(o.failed(0)); + } }