diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index 0c8f977d..3c21e574 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -84,6 +84,9 @@ type asked struct { // Acted is what the controller did on the warrant: empty before it did anything, "acting" while it acts, // then "done", "failed: …" or "nothing: …". Anything but empty is never acted on again. Acted string `json:"acted,omitempty"` + // Drill is an ask started at the controller's terminal (drill.go): about no condition, its answers + // perform nothing, and the reconciling of conditions leaves it alone. + Drill bool `json:"drill,omitempty"` } // askedStore keeps the asks (broker.AskedBucket). @@ -202,7 +205,7 @@ func (a *asker) reconcile(ctx context.Context) error { } byCondition := map[string]asked{} for _, r := range all { - if r.State == askOpen { + if r.State == askOpen && !r.Drill { if prior, held := byCondition[r.Condition]; !held || r.Opened.After(prior.Opened) { byCondition[r.Condition] = r } @@ -226,7 +229,7 @@ func (a *asker) reconcile(ctx context.Context) error { } byCondition = map[string]asked{} for _, r := range all { - if r.State == askOpen { + if r.State == askOpen && !r.Drill { byCondition[r.Condition] = r } } @@ -490,7 +493,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { if err != nil { return err } - stillOpen := false + stillOpen := r.Drill // a drill is about no condition for _, c := range open { stillOpen = stillOpen || c.Key == r.Condition } @@ -521,9 +524,12 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { args["why"] = why } var acted error - if act.Arguments["silence"] != "" { + switch { + case r.Drill && act.Verb == drillVerb: + // A drill's answer performs nothing: it is recorded below as the operator's decision. + case act.Arguments["silence"] != "": acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why) - } else { + default: acted = a.call(ctx, act, args) } r.Ended = a.now() diff --git a/cmd/mesh-controller/drill.go b/cmd/mesh-controller/drill.go new file mode 100644 index 00000000..1db79cf8 --- /dev/null +++ b/cmd/mesh-controller/drill.go @@ -0,0 +1,110 @@ +package main + +// The drill of the operator's answers (novox/hq ADR 0259, the live acceptance after rollout): an ask the +// operator starts at the controller's terminal, answered on the phone, whose approval changes nothing and is +// recorded as a person's decision like any other. +// +// mesh-controller drill [--for 15m] +// +// It asks with two answers — Approve (an approval, so only a channel that proves who answered carries it) and +// Decline (an acknowledgement) — each bound to the drill's own act. The serving controller acts on the warrant +// as on any other: it claims the ask once, checks the act is the one bound, performs nothing, and records the +// hand-act `warrant` with who answered, through which channel, and the proofs. `hand-acts` then shows it. +// +// **The terminal's alone**: a command a verb runs (MESH_VERB set) is refused, so no agent starts a drill — a +// drill is a question the operator expects, and one an agent could start would teach them to approve what they +// did not ask for. + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "time" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-sdk/go/asks" + + "github.com/novox/mesh-controller/internal/conditions" +) + +// drillVerb is the act a drill's answers bind: nothing is called. +const drillVerb = "drill" + +// drillActions are the drill's two answers. +func drillActions() []conditions.Action { + return []conditions.Action{ + {Label: "Approve", Verb: drillVerb, Level: conditions.LevelApprove, Arguments: map[string]string{"drill": "approve"}}, + {Label: "Decline", Verb: drillVerb, Level: conditions.LevelAcknowledge, Arguments: map[string]string{"drill": "decline"}}, + } +} + +// drillAsk is the drill's ask, as the router is sent it. +func drillAsk(id string, now time.Time, lasts time.Duration) (asks.Ask, map[string]int) { + q := asks.Ask{ID: id, Headline: "Drill: approve this test question?", Who: asks.Operator, + Explanation: "Needs you: approve or decline. You started this drill at the controller's terminal. Approving " + + "changes nothing on the mesh; it is recorded as your decision, so you can check the record.", + OnExpiry: "nothing is done", Expires: now.Add(lasts), About: "drill." + id} + options := map[string]int{} + for i, act := range drillActions() { + binds, _ := asks.ActDigest(boundAct(act)) + oid := optionID(act.Label) + options[oid] = i + q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesDrill(act), Level: asks.Level(act.Level), + Binds: binds}) + } + return q, options +} + +func doesDrill(act conditions.Action) string { + if act.Arguments["drill"] == "approve" { + return "nothing changes; your approval is recorded" + } + return "nothing changes; your answer is recorded" +} + +func drillCommand(ctx context.Context, args []string) error { + if os.Getenv(verbVar) != "" { + return errors.New("drill is the controller's terminal's alone: a verb may not start one, so no agent asks " + + "the operator a question they did not start (novox/hq ADR 0259)") + } + set := flag.NewFlagSet("drill", flag.ContinueOnError) + lasts := set.Duration("for", 15*time.Minute, "how long the question waits for an answer") + if err := set.Parse(args); err != nil { + return err + } + if *lasts < time.Minute || *lasts > askApproveFor { + return fmt.Errorf("a drill waits between a minute and %s", askApproveFor) + } + js, err := aBus() + if err != nil { + return err + } + defer js.Close() + now := time.Now() + id := newAskID() + q, options := drillAsk(id, now, *lasts) + if err := q.Check(now); err != nil { + return err + } + store := busAsked{conn: js.Conn()} + // Kept before it is published, as the asker keeps every ask, so a warrant always finds it. + if err := store.Put(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: drillActions(), Options: options, + State: askOpen, Opened: now, Drill: true}); err != nil { + return fmt.Errorf("the drill could not be kept in the controller's asks: %w", err) + } + body, err := json.Marshal(q) + if err != nil { + return err + } + if _, err := js.Context().Publish(asks.AskSubject(askerName), body, nats.MsgId("ask."+id), nats.Context(ctx)); err != nil { + return fmt.Errorf("the drill could not be asked: %w", err) + } + fmt.Printf("drill %s asked: answer it on your phone before %s. Then `mesh-controller hand-acts` shows the "+ + "answer as a warrant, with who answered, through which channel and the proofs; nothing else changes.\n", + id, q.Expires.Local().Format("15:04")) + return nil +} diff --git a/cmd/mesh-controller/drill_test.go b/cmd/mesh-controller/drill_test.go new file mode 100644 index 00000000..074abd4b --- /dev/null +++ b/cmd/mesh-controller/drill_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// A drill (the live acceptance of novox/hq ADR 0259): its approval is a warrant like any other — claimed once, +// its act checked against what the option bound, recorded as the operator's decision with who, how and the +// proofs — and it performs nothing. The reconciling of conditions leaves it open. +func TestADrillsApprovalIsRecordedAndPerformsNothing(t *testing.T) { + r := newAskerRig(t) + q, options := drillAsk("cdrill", r.now, askerDrillFor) + if err := q.Check(r.now); err != nil { + t.Fatalf("the drill's ask is refused: %v", err) + } + r.store["cdrill"] = asked{ID: "cdrill", Condition: q.About, Ask: q, Actions: drillActions(), Options: options, + State: askOpen, Opened: r.now, Drill: true} + if err := r.a.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + if got := r.store["cdrill"]; got.State != askOpen { + t.Fatalf("the reconciling of conditions ended the drill: %+v", got) + } + approve, _ := q.Option("approve") + w := asks.Warrant{Ask: "cdrill", Asker: "mesh-controller", Outcome: asks.OutcomeChosen, Option: approve.ID, + Label: approve.Label, Level: approve.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now, + AskDigest: q.Digest(), By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} + answerWith(t, r, w) + answerWith(t, r, w) // heard again + if len(r.called)+len(r.silenced) != 0 { + t.Errorf("a drill performed something: %v %v", r.called, r.silenced) + } + if len(r.acts) != 1 { + t.Fatalf("hand-acts %+v", r.acts) + } + act := r.acts[0] + if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "cdrill" || + strings.Join(act.Args, " ") != "drill drill=approve" || act.Outcome != "done" || strings.Join(act.Proofs, ",") != "P1" { + t.Errorf("the drill's record: %+v", act) + } + if !personsDecision(act) { + t.Error("a drill's answer counts as a repair") + } +} + +// Only the terminal starts a drill: a verb's process is refused before anything is asked. +func TestADrillIsTheTerminalsAlone(t *testing.T) { + t.Setenv(verbVar, "mesh-controller.command") + if err := drillCommand(context.Background(), nil); err == nil || !strings.Contains(err.Error(), "terminal") { + t.Fatalf("a verb started a drill: %v", err) + } +} + +// askerDrillFor is how long the test's drill waits. +const askerDrillFor = 15 * time.Minute diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index b480107b..6a085307 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -78,6 +78,8 @@ func run() error { return rotateCommand(ctx, args[1:]) case "ask": return askCommand(ctx, args[1:]) + case "drill": + return drillCommand(ctx, args[1:]) case "builds": return buildsCommand(ctx, args[1:]) // The build queue, controlled by hand (novox/hq ADR 0219).