//go:build integration package main import ( "context" "encoding/json" "strings" "testing" "time" "git.novox.be/novox/mesh-sdk/go/asks" "github.com/nats-io/nats.go" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/testbus" ) // 1315/AC3: the operator's Retry on a failed plan, acted on through the real callAction and the controller's own // `plans` verb, sets the plan building again at its tier and ends its plan-failed condition (novox/hq ADR 0449 ยง1). func TestAWarrantForRetryRetriesAFailedPlanInTheStoreAndEndsItsCondition(t *testing.T) { open := aMesh(t) ctx := t.Context() asked := asksRecorded(t) planMovedHeard(t) // the bus the verb says its moves on, and the hand-act log twoTiers(t, open) before := time.Now().UTC().Add(-time.Hour) ended := before.Add(time.Minute) failed := inventory.Plan{ID: "plan-1315-retry", Repository: "novox/a", Branch: "main", Commit: "c0ffee", Created: before, State: inventory.PlanFailed, Tier: 0, Tiers: [][]string{{"a"}, {"b"}}, Note: "a failed to build in tier 0", Delivery: &inventory.PlanDelivery{}, Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1", Why: link.KilledByHand}}, Times: &inventory.PlanTimes{Cut: &before, Started: &before, Ended: &ended, Class: inventory.ClassLeaf}} if err := open.inventory.SavePlan(ctx, &failed); err != nil { t.Fatal(err) } conn, err := nats.Connect(testbus.URL(t)) if err != nil { t.Fatal(err) } defer conn.Close() // The controller's seat answers `plans` as it does: by running the verb. sub, err := conn.Subscribe(link.SeatToolSubject("mesh-controller", "plans"), func(m *nats.Msg) { var args map[string]string _ = json.Unmarshal(m.Data, &args) answer := link.Answer{Result: json.RawMessage(`"ok"`)} if len(args) != 1 || args["retry"] == "" { answer = link.Answer{Error: "the retry was asked with more than the plan"} } else if err := plansCommand(context.Background(), []string{"retry", args["retry"]}); err != nil { answer = link.Answer{Error: err.Error()} } body, _ := json.Marshal(answer) _ = m.Respond(body) }) if err != nil { t.Fatal(err) } defer func() { _ = sub.Unsubscribe() }() // What S23 raises for it, read from the store as the watchdog reads it. raised := func(context.Context) ([]conditions.Condition, error) { facts, err := gatherFailedPlans(ctx, open.inventory, time.Now()) if err != nil { return nil, err } var out []conditions.Condition for _, o := range watchPlansFailed(&signalFacts{now: time.Now(), failed: facts}) { out = append(out, conditions.Condition{Key: o.Key(), Kind: o.Kind, Severity: o.Severity, Headline: o.Headline, Explanation: conditions.Verdict(o.Needs, o.Explanation), Needs: o.Needs, Actions: o.Actions}) } return out, nil } now := time.Now() store := memAskedStore{} var sent []published var acts []link.HandAct a := &asker{open: raised, store: store, silence: func(context.Context, string, time.Duration, string, string) error { return nil }, publish: func(_ context.Context, subject string, body []byte, id string) error { sent = append(sent, published{subject, id, body}) return nil }, call: callAction(conn), record: func(_ context.Context, act link.HandAct) error { acts = append(acts, act); return nil }, now: func() time.Time { return now }, logf: t.Logf} cs, _ := raised(ctx) if len(cs) != 1 || len(cs[0].Actions) != 1 { t.Fatalf("the failed plan raised %+v", cs) } if err := a.reconcile(ctx); err != nil { t.Fatal(err) } var w asks.Warrant for _, r := range store { for _, o := range r.Ask.Options { if o.Label == "Retry" { w = asks.Warrant{Ask: r.ID, Asker: "mesh-controller", About: r.Condition, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now, AskDigest: r.Ask.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} } } } if w.Ask == "" { t.Fatalf("no ask offers Retry: %+v", store) } body, _ := json.Marshal(w) if err := a.Decided(ctx, body); err != nil { t.Fatal(err) } if err := a.Decided(ctx, body); err != nil { // heard again: nothing more t.Fatal(err) } p, err := open.inventory.PlanByID(ctx, failed.ID) if err != nil { t.Fatal(err) } m := p.Modules["a"] if p.State != inventory.PlanBuilding || p.Tier != 0 || m.State != "asked" || m.Build == "" || m.Build == "build-1" { t.Fatalf("after the warrant the plan is %s at tier %d and a is %+v", p.State, p.Tier, m) } if len(*asked) != 1 { t.Fatalf("the build was asked %d times: %v", len(*asked), *asked) } if got := store[w.Ask]; got.Acted != "done" { t.Fatalf("the ask ended %q", got.Acted) } if len(acts) != 1 || acts[0].Verb != handActWarrant || !strings.Contains(strings.Join(acts[0].Args, " "), "retry="+failed.ID) { t.Fatalf("the warrant was recorded as %+v", acts) } if cs, _ = raised(ctx); len(cs) != 0 { t.Fatalf("the plan-failed condition did not end: %+v", cs) } }