Skip to content

Commit ea09fa4

Browse files
K2GO-450 fix(install): trust the Ansible PLAY RECAP, not stray [ERROR] lines
A forgejo module install succeeds (ansible failed=0, exit=0, forgejo reachable) but the forgejo role has an ignore_errors task that prints [ERROR]/fatal lines. AnsibleRunOutcome treated any [ERROR] as failure, so the install was marked FAILED (exit=0) and reverted, which gated out the repo seed: admin never created, no repos (the K2GO-450 symptom). The PLAY RECAP (unreachable=/failed=) is ansible's authoritative verdict: trust it when present, and fall back to the [ERROR]/crash signatures only when no recap was emitted (a crash before the summary, the original ADFA-4435 case). Shared by every ansible module install.
1 parent 028e885 commit ea09fa4

2 files changed

Lines changed: 65 additions & 6 deletions

File tree

‎controller/app/src/main/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcome.java‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@
22
* File : AnsibleRunOutcome.java
33
* Author : AppDevForAll
44
* Copyright : Copyright (c) 2026 AppDevForAll
5-
* Description : ADFA-4435 — pure decision for whether a runrole/Ansible execution failed.
6-
* Ansible can print its failure to stdout yet still exit 0 (e.g. the /dev/shm
7-
* multiprocessing crash), so the verdict considers the output as well as the
8-
* exit code. No Android dependencies -> unit-testable on the JVM.
5+
* Description : ADFA-4435 / K2GO-450 - pure decision for whether a runrole/Ansible execution
6+
* failed. The PLAY RECAP (per-host "... unreachable=N failed=M ...") is Ansible's
7+
* authoritative verdict, so when a recap is present it decides: an IGNORED error
8+
* (ignore_errors -> counted in ignored=, not failed=) prints [ERROR]/fatal lines but
9+
* is NOT a failure. Only when no recap appears (Ansible died before it could
10+
* summarize, e.g. the /dev/shm multiprocessing crash) do the [ERROR]/crash
11+
* signatures stand in. A non-zero process exit is always a failure.
12+
* No Android dependencies -> unit-testable on the JVM.
913
*/
1014
package org.appdevforall.k2go.install.domain;
1115

1216
public final class AnsibleRunOutcome {
1317

18+
// Pre-recap failure hints: used ONLY when no PLAY RECAP was seen (Ansible crashed before it
19+
// could summarize). When a recap IS present these are ignored, because Ansible prints [ERROR]
20+
// and fatal lines for ignored errors too, which are not failures.
1421
private boolean sawError = false;
22+
private boolean sawRecap = false; // a PLAY RECAP host-summary line appeared
23+
private boolean recapFailure = false; // a recap host line reported failed>0 or unreachable>0
1524

1625
/** Feed each output line as it streams from the container. */
1726
public void observe(String line) {
@@ -21,10 +30,36 @@ public void observe(String line) {
2130
|| line.contains("HEARTBEAT SESSION STOPPED")) {
2231
sawError = true;
2332
}
33+
// A PLAY RECAP host summary carries BOTH "unreachable=" and "failed=" (every real recap line
34+
// does); that pair is what distinguishes it from a stray task line that happens to contain one.
35+
int failed = count(line, "failed=");
36+
int unreachable = count(line, "unreachable=");
37+
if (failed >= 0 && unreachable >= 0) {
38+
sawRecap = true;
39+
if (failed > 0 || unreachable > 0) recapFailure = true;
40+
}
2441
}
2542

26-
/** True if the run failed: a non-zero exit OR an error seen in the output. */
43+
/** True if the run failed. A non-zero exit always fails; otherwise trust the PLAY RECAP when
44+
* present, and fall back to the [ERROR]/crash signatures only when no recap was emitted. */
2745
public boolean failed(int exitCode) {
28-
return exitCode != 0 || sawError;
46+
if (exitCode != 0) return true;
47+
if (sawRecap) return recapFailure;
48+
return sawError;
49+
}
50+
51+
/** The non-negative integer immediately after {@code key} in {@code line}, or -1 if absent. */
52+
private static int count(String line, String key) {
53+
int i = line.indexOf(key);
54+
if (i < 0) return -1;
55+
int start = i + key.length();
56+
int j = start;
57+
while (j < line.length() && Character.isDigit(line.charAt(j))) j++;
58+
if (j == start) return -1;
59+
try {
60+
return Integer.parseInt(line.substring(start, j));
61+
} catch (NumberFormatException e) {
62+
return -1;
63+
}
2964
}
3065
}

‎controller/app/src/test/java/org/appdevforall/k2go/install/domain/AnsibleRunOutcomeTest.java‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,28 @@ public class AnsibleRunOutcomeTest {
4848
o.observe(null);
4949
assertFalse(o.failed(0));
5050
}
51+
52+
@Test public void ignoredError_withCleanRecap_isNotFailed() {
53+
// K2GO-450: the forgejo role has an ignore_errors task that prints [ERROR]/fatal, but the
54+
// PLAY RECAP is clean (failed=0, unreachable=0, ignored=1). An ignored error is not a
55+
// failure: with a recap present it is authoritative, so the run must NOT be marked failed.
56+
AnsibleRunOutcome o = new AnsibleRunOutcome();
57+
o.observe("fatal: [127.0.0.1]: FAILED! => {\"msg\": \"something\"} ...ignoring");
58+
o.observe("[ERROR]: an ignored task error");
59+
o.observe("127.0.0.1 : ok=338 changed=20 unreachable=0 failed=0 skipped=27 rescued=0 ignored=1");
60+
assertFalse(o.failed(0));
61+
}
62+
63+
@Test public void realFailureRecap_isFailed() {
64+
// A genuinely failed task increments failed= in the recap -> failure (even on a quirky exit 0).
65+
AnsibleRunOutcome o = new AnsibleRunOutcome();
66+
o.observe("127.0.0.1 : ok=10 changed=3 unreachable=0 failed=2 skipped=1 rescued=0 ignored=0");
67+
assertTrue(o.failed(0));
68+
}
69+
70+
@Test public void unreachableHost_isFailed() {
71+
AnsibleRunOutcome o = new AnsibleRunOutcome();
72+
o.observe("127.0.0.1 : ok=1 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0");
73+
assertTrue(o.failed(0));
74+
}
5175
}

0 commit comments

Comments
 (0)