Files
felhom-agent/internal/signedjobs/outcome_test.go
T
admin a1135018c7
gates / gates (push) Successful in 42s
agent (unreleased): a refused signed job and a watchdog give-up reach the hub (R-1383, D78)
The job runner clears a job with its outcome, a closed reason code and the op's name. The guest-power watchdog's
give-up rides the host report until the guest runs.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-11-14 17:29:53 +01:00

102 lines
4.3 KiB
Go

package signedjobs
import (
"context"
"errors"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
)
// outcomeQueue is a jobsQueue that also has the optional half (OutcomeSource), as *hub.Client does.
type outcomeQueue struct {
jobsQueue
omu sync.Mutex
outcomes map[string]hub.JobOutcome
}
func (q *outcomeQueue) CompleteJobWithOutcome(ctx context.Context, jobID string, o hub.JobOutcome) error {
q.omu.Lock()
if q.outcomes == nil {
q.outcomes = map[string]hub.JobOutcome{}
}
q.outcomes[jobID] = o
q.omu.Unlock()
return q.jobsQueue.CompleteJob(ctx, jobID)
}
// R-1383 (D78): a signed job that is refused or fails was cleared from the hub's queue exactly like one that ran —
// „an agent update never arrives” left one journal line on the box. The runner now says how each job ended, through
// the REAL gate and verifier: the stored effect is the outcome the hub receives with the clearing call.
// The two clock reasons are the ones the ruling is about: a box with a wrong clock refuses every job.
// COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/B/redproof-agent-job-outcome.txt).
func TestRunner_TellsTheHubHowEachJobEnded(t *testing.T) {
s := newTestSigner(t)
_, _, exec := newRealGateRunner(t, s)
base, _, _ := newRealGateRunner(t, s)
src := &outcomeQueue{}
r := NewRunner(src, base.gate, exec, testHost, quiet())
now := time.Now().UTC()
src.add(mintJob(t, s, "ok", testHost, "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour)))
src.add(mintJob(t, s, "expired", testHost, "", "ops-1", wipeParamsJSON, now.Add(-3*time.Hour), now.Add(-2*time.Hour)))
src.add(mintJob(t, s, "early", testHost, "", "ops-1", wipeParamsJSON, now.Add(2*time.Hour), now.Add(3*time.Hour)))
src.add(mintJob(t, s, "other-box", "some-other-host", "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour)))
src.add(corruptSig(t, mintJob(t, s, "forged", testHost, "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour))))
src.add(hub.JobWire{JobID: "garbage", BlobB64: "!!!not-base64!!!"})
if _, err := r.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
// and a job that is accepted and whose run fails
exec.err = errors.New("the wrapper refused")
src.add(mintJob(t, s, "run-fails", testHost, "", "ops-1", wipeParamsJSON, now, now.Add(time.Hour)))
if _, err := r.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
want := map[string]hub.JobOutcome{
"ok": {Outcome: "done", Op: "storage_wipe"},
"expired": {Outcome: "rejected", Reason: "expired", Op: "storage_wipe"},
"early": {Outcome: "rejected", Reason: "not_yet_valid", Op: "storage_wipe"},
"other-box": {Outcome: "rejected", Reason: "target", Op: "storage_wipe"},
"forged": {Outcome: "rejected", Reason: "malformed", Op: "storage_wipe"}, // corruptSig breaks the armour itself
"garbage": {Outcome: "malformed", Reason: "malformed"},
"run-fails": {Outcome: "failed", Reason: "execution_failed", Op: "storage_wipe"},
}
for id, w := range want {
if got, ok := src.outcomes[id]; !ok || got != w {
t.Errorf("job %q: the hub was told %+v (told=%v), want %+v", id, got, ok, w)
}
}
if len(src.outcomes) != len(want) {
t.Errorf("outcomes for %d jobs, want %d: %+v", len(src.outcomes), len(want), src.outcomes)
}
if exec.count() != 2 {
t.Errorf("executor ran %d times, want 2 (ok and run-fails only)", exec.count())
}
}
// Every code reasonCode can return is on the closed list the hub holds a sentence for.
func TestReasonCode_ClosedList(t *testing.T) {
known := map[string]bool{}
for _, c := range ReasonCodes {
known[c] = true
}
for _, d := range []reconcile.Decision{
{}, {Reason: reconcile.ReasonRejected, Err: errors.New("something new")},
{Reason: reconcile.ReasonRoleDenied}, {Reason: reconcile.ReasonBindingMismatch}, {Reason: reconcile.ReasonPendingSignature},
} {
if c := reasonCode(d); !known[c] {
t.Errorf("reasonCode(%+v) = %q, not on ReasonCodes", d, c)
}
}
if reasonCode(reconcile.Decision{Reason: reconcile.ReasonRejected, Err: errors.New("x")}) != "other" {
t.Error("an unknown refusal must be told as „other”, never as the error's own text")
}
if !known["execution_failed"] || !known["malformed"] {
t.Error("the two codes the runner sets itself are not on the list")
}
}