mesh/merge-gate head 7f3ff3236149 merged into trunk 21ac8fd8586d — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head 7f3ff3236149 merged into trunk 21ac8fd8586d — pass: its merge-check.sh passed
mesh/delivery delivered
A renewal timed out at the renew interval, so the lease test's 1 s renewals could lose the lease on a slow build machine (novox/hq issue 591, review of #279).
203 lines
8.2 KiB
Go
203 lines
8.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// A plan as the store would answer it at each read: the reads are counted, and each answers the next.
|
|
type planReads struct {
|
|
plans []inventory.Plan
|
|
n int
|
|
}
|
|
|
|
func (r *planReads) read(context.Context) (inventory.Plan, error) {
|
|
p := r.plans[min(r.n, len(r.plans)-1)]
|
|
r.n++
|
|
return p, nil
|
|
}
|
|
|
|
func followedPlan(rev int64, state string, tier int) inventory.Plan {
|
|
return inventory.Plan{ID: "plan-1", Repository: "novox/x", Commit: "abc", State: state, Tier: tier,
|
|
Tiers: [][]string{{"a"}, {"b"}}, Revision: rev}
|
|
}
|
|
|
|
// **`plans` waits for a plan, and says what ended the wait** (novox/hq issue 229): an agent following a
|
|
// rollout is told when the plan moved, reached its next tier or ended, or that the bound passed — in one
|
|
// call, without a loop of its own.
|
|
func TestAWaitForAPlanEndsWhenWhatItWaitsForHappens(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
until string
|
|
reads []inventory.Plan
|
|
came string
|
|
times int
|
|
}{
|
|
{"moved: any save", "moved", []inventory.Plan{
|
|
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(1, inventory.PlanBuilding, 0),
|
|
followedPlan(2, inventory.PlanBuilding, 0)}, "moved", 3},
|
|
{"tier: a save inside the tier is not enough", "tier", []inventory.Plan{
|
|
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanBuilding, 0),
|
|
followedPlan(3, inventory.PlanRolling, 1)}, "tier", 3},
|
|
{"tier: a plan that ended has no next tier to wait for", "tier", []inventory.Plan{
|
|
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanFailed, 0)}, "done", 2},
|
|
{"done: a new tier is not the end", "done", []inventory.Plan{
|
|
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanRolling, 1),
|
|
followedPlan(3, inventory.PlanDone, 1)}, "done", 3},
|
|
{"done: a plan already ended answers at once", "done", []inventory.Plan{
|
|
followedPlan(4, inventory.PlanSuperseded, 1)}, "done", 1},
|
|
{"the bound passes", "moved", []inventory.Plan{followedPlan(1, inventory.PlanBuilding, 0)}, "bound", -1},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
r := &planReads{plans: c.reads}
|
|
p, came, err := waitOnPlan(context.Background(), r.read, c.until, 50*time.Millisecond, time.Millisecond)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if came != c.came {
|
|
t.Fatalf("the wait ended as %q, want %q", came, c.came)
|
|
}
|
|
if c.times > 0 && r.n != c.times {
|
|
t.Errorf("read the plan %d times, want %d: it answered before or after what it waited for", r.n, c.times)
|
|
}
|
|
if want := c.reads[min(r.n, len(c.reads))-1]; p.Revision != want.Revision {
|
|
t.Errorf("answered revision %d, not the plan as it was last read (%d)", p.Revision, want.Revision)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// What a wait is asked for is checked before anything is read: what it waits until, and a bound no longer than
|
|
// a call is answered within.
|
|
func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) {
|
|
for _, bad := range []struct{ until, bound string }{{"landed", ""}, {"", "1h"}, {"", "-3s"}, {"", "soon"}} {
|
|
if _, _, err := planWaitAsked(bad.until, bad.bound); err == nil {
|
|
t.Errorf("until %q, bound %q was taken", bad.until, bad.bound)
|
|
}
|
|
}
|
|
until, bound, err := planWaitAsked("", "")
|
|
if err != nil || until != "moved" || bound != maxPlanWait {
|
|
t.Fatalf("the defaults are %q %s %v, want moved and %s", until, bound, err, maxPlanWait)
|
|
}
|
|
if _, bound, _ := planWaitAsked("tier", "12"); bound != 12*time.Second {
|
|
t.Errorf("a bare number is seconds; read %s", bound)
|
|
}
|
|
}
|
|
|
|
// **The answer is data, with its prose as a field beside it** (novox/hq issue 229): the verb's `answer` carries
|
|
// the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string.
|
|
func TestAWaitForAPlanAnswersAsData(t *testing.T) {
|
|
p := followedPlan(7, inventory.PlanRolling, 1)
|
|
st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0]
|
|
doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2")
|
|
stdout, _ := json.Marshal(doc)
|
|
a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true)
|
|
data, ok := a.Answer.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("the answer is not data: %v", a.Answer)
|
|
}
|
|
if data["came"] != "tier" || data["revision"] != float64(7) {
|
|
t.Errorf("the data does not say what ended the wait: %v", data)
|
|
}
|
|
if plan, _ := data["plan"].(map[string]any); plan["state"] != "rolling" || plan["tier"] != float64(1) {
|
|
t.Errorf("the data does not carry the plan: %v", data["plan"])
|
|
}
|
|
if _, text := data["text"]; text {
|
|
t.Errorf("the prose is in the data too: %v", data)
|
|
}
|
|
if strings.Contains(a.Output, "{") || !strings.Contains(a.Output, "plan-1") || !strings.Contains(a.Output, "tier") {
|
|
t.Errorf("the output is not the line a person reads: %q", a.Output)
|
|
}
|
|
}
|
|
|
|
// The verb composes the wait, and asks for a window as long as its bound.
|
|
func TestThePlansVerbComposesAWait(t *testing.T) {
|
|
argv, err := argvFor("plans", map[string]any{"wait": "plan-1", "until": "done", "bound": "15s"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := strings.Join(argv, " "); got != "plans wait plan-1 --until done --bound 15s" {
|
|
t.Fatalf("composed %q", got)
|
|
}
|
|
if _, err := argvFor("plans", map[string]any{"until": "done"}); err == nil {
|
|
t.Error("until without wait was taken")
|
|
}
|
|
if _, err := argvFor("plans", map[string]any{"wait": "plan-1", "bound": "1h"}); err == nil {
|
|
t.Error("a bound past what a call can wait was taken")
|
|
}
|
|
}
|
|
|
|
// Live, with the store: `plans wait` run as the verb runs it answers when the plan reaches its next tier, as
|
|
// data, and says the bound passed when nothing moved (novox/hq issue 229).
|
|
func TestAPlanFollowedFromTheStoreAnswersWhenItMoves(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
// Read every 50ms rather than every second, so the wait answers as soon as the plan moves and the bound
|
|
// below passes in a fraction of a second (novox/hq issue 591); the move still waits 1.5s, long enough
|
|
// for the wait to have read the plan before it.
|
|
was := planWaitEvery
|
|
planWaitEvery = 50 * time.Millisecond
|
|
t.Cleanup(func() { planWaitEvery = was })
|
|
now := time.Now().UTC()
|
|
walk := inventory.Plan{ID: "plan-229", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c229c229",
|
|
Created: now, State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}},
|
|
Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}}
|
|
if err := open.inventory.SavePlan(ctx, &walk); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
moved := make(chan error, 1)
|
|
go func() {
|
|
time.Sleep(1500 * time.Millisecond)
|
|
p, err := open.inventory.PlanByID(ctx, walk.ID)
|
|
if err == nil {
|
|
p.Tier, p.State = 1, inventory.PlanRolling
|
|
err = open.inventory.SavePlan(ctx, &p)
|
|
}
|
|
moved <- err
|
|
}()
|
|
began := time.Now()
|
|
var got planWaited
|
|
out := printed(t, func() error {
|
|
return plansCommand(ctx, []string{"wait", walk.ID, "--until", "tier", "--bound", "15s"})
|
|
})
|
|
if err := <-moved; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
|
t.Fatalf("plans wait did not answer data: %v: %q", err, out)
|
|
}
|
|
if got.Came != "tier" || got.Plan.Tier != 1 || got.Plan.State != inventory.PlanRolling {
|
|
t.Fatalf("the wait answered %+v", got)
|
|
}
|
|
if waited := time.Since(began); waited > 3*time.Second {
|
|
t.Errorf("it answered after %s, not when the plan moved", waited)
|
|
}
|
|
out = printed(t, func() error {
|
|
return plansCommand(ctx, []string{"wait", walk.ID, "--bound", "300ms"})
|
|
})
|
|
if err := json.Unmarshal([]byte(out), &got); err != nil || got.Came != "bound" || got.Until != "moved" {
|
|
t.Fatalf("a plan that did not move answered %+v (%v)", got, err)
|
|
}
|
|
}
|
|
|
|
// The verb's schema says the longest bound in words, and the words are the value (review 75 on #266): the
|
|
// catalogue cannot read maxPlanWait, so this holds them together.
|
|
func TestThePlansSchemaSaysTheLongestBoundThereIs(t *testing.T) {
|
|
v, ok := controllerVerb("plans")
|
|
if !ok {
|
|
t.Fatal("no plans verb")
|
|
}
|
|
props, _ := v.Input["properties"].(map[string]any)
|
|
bound, _ := props["bound"].(map[string]any)
|
|
said, _ := bound["description"].(string)
|
|
if !strings.Contains(said, maxPlanWait.String()+", the default, is the longest") {
|
|
t.Fatalf("the schema says %q; the longest bound is %s", said, maxPlanWait)
|
|
}
|
|
}
|