Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba685b17f5 | ||
|
|
f8daec01d3 | ||
|
|
5ad36127ee | ||
|
|
a1a6f6493d | ||
|
|
e9f060e0be | ||
|
|
d20b52cb19 |
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// planID is every id the mesh gives a plan: a merge's walk or batch (plan-<nanoseconds>) or a release
|
||||
// (release-<nanoseconds>).
|
||||
var planID = regexp.MustCompile(`^(plan|release)-[0-9]+$`)
|
||||
|
||||
// planIDArguments are the arguments of plans that name a plan. wait is among them for when it is declared
|
||||
// (novox/hq issue 229); an argument not declared is refused before this is asked.
|
||||
var planIDArguments = []string{"id", "wait", "stop", "close", "retry", "go"}
|
||||
|
||||
// plainPlanIDs refuses a plans call naming a plan by anything but a plan's id (novox/hq issue 561): checked
|
||||
// once, where the call becomes a command line, so nothing else a caller sent reaches a command or the store.
|
||||
func plainPlanIDs(given map[string]string) error {
|
||||
for _, arg := range planIDArguments {
|
||||
if id, named := given[arg]; named && !planID.MatchString(id) {
|
||||
return fmt.Errorf("plans %s: %q is not a plan's id (plan-<digits> or release-<digits>, as `plans` "+
|
||||
"lists them); nothing was done", arg, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A plan's id is checked once, where a call becomes a command line** (novox/hq issue 561): every argument of
|
||||
// plans that names a plan is refused unless it is an id the mesh gives a plan, so a quote never reaches a
|
||||
// command, nor the store behind it.
|
||||
func TestAPlanIdThatIsNoPlansIdIsRefusedBeforeACommandStarts(t *testing.T) {
|
||||
for _, arg := range []string{"id", "stop", "close", "retry", "go"} {
|
||||
for _, bad := range []string{"x' or '1'='1", "plan-1'", "plan-", "plan-1 2", "batch-1", "PLAN-1"} {
|
||||
args := map[string]any{arg: bad}
|
||||
if arg == "stop" || arg == "close" || arg == "go" {
|
||||
args["why"] = "a test"
|
||||
}
|
||||
argv, err := argvFor("plans", args)
|
||||
if err == nil {
|
||||
t.Errorf("plans %s %q was composed: %v", arg, bad, argv)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not a plan's id") {
|
||||
t.Errorf("plans %s %q was refused for another reason: %v", arg, bad, err)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"plan-1791813626034291775", "release-1791811938267404408"} {
|
||||
args := map[string]any{arg: good}
|
||||
if arg == "stop" || arg == "close" || arg == "go" {
|
||||
args["why"] = "a test"
|
||||
}
|
||||
if _, err := argvFor("plans", args); err != nil {
|
||||
t.Errorf("plans %s %q was refused: %v", arg, good, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Following a plan (novox/hq issue 229): `plans wait <id>` holds its answer until the plan moves, reaches its
|
||||
// next tier or ends, or until its bound passes, and says which. Before it, an agent following a rollout had
|
||||
// `plans` answer once, in prose, and wrote a loop of its own round the mesh's tools to be told the plan moved.
|
||||
//
|
||||
// **No wait is longer than a call is answered within** (link.LongestAnswer, inside the console's thirty
|
||||
// seconds): a longer one would answer "still running" and leave its caller to ask `calls` — the loop again.
|
||||
// So a caller following a plan to its end asks again; each call answers the moment the plan moves.
|
||||
|
||||
// maxPlanWait is the longest bound a wait takes, and the bound when none is given: what a call is answered
|
||||
// within, less the time a verb's command takes to start and read the store.
|
||||
var maxPlanWait = link.LongestAnswer - planWaitStart
|
||||
|
||||
// planWaitStart is what the verb's window allows beyond the bound: starting the command and opening the stores.
|
||||
// A store slow enough to push the read and the answer past it degrades as any long call does: the caller is
|
||||
// answered that the call is still running, and `calls` with its id says what came of it.
|
||||
const planWaitStart = 5 * time.Second
|
||||
|
||||
// planWaitEvery is how often a wait reads the plan again.
|
||||
const planWaitEvery = time.Second
|
||||
|
||||
// The things a wait waits until.
|
||||
var planWaitUntil = []string{"moved", "tier", "done"}
|
||||
|
||||
// planWaitAsked checks what a wait is asked for: until (moved when empty) and a bound (a duration, or a number
|
||||
// of seconds; maxPlanWait when empty).
|
||||
func planWaitAsked(until, bound string) (string, time.Duration, error) {
|
||||
if until == "" {
|
||||
until = "moved"
|
||||
}
|
||||
known := false
|
||||
for _, u := range planWaitUntil {
|
||||
known = known || u == until
|
||||
}
|
||||
if !known {
|
||||
return "", 0, fmt.Errorf("a wait is until %s, not %q", strings.Join(planWaitUntil, ", "), until)
|
||||
}
|
||||
d := maxPlanWait
|
||||
if bound = strings.TrimSpace(bound); bound != "" {
|
||||
var err error
|
||||
if secs, isNumber := strconv.Atoi(bound); isNumber == nil {
|
||||
d = time.Duration(secs) * time.Second
|
||||
} else if d, err = time.ParseDuration(bound); err != nil {
|
||||
return "", 0, fmt.Errorf("the bound %q is not a duration (such as 20s) or a number of seconds", bound)
|
||||
}
|
||||
if d <= 0 || d > maxPlanWait {
|
||||
return "", 0, fmt.Errorf("the bound is at most %s, the longest a call is answered within; %s was asked "+
|
||||
"— to follow a plan longer, ask again: each call answers the moment the plan moves", maxPlanWait, d)
|
||||
}
|
||||
}
|
||||
return until, d, nil
|
||||
}
|
||||
|
||||
// planEnded is a plan that will not move again: done, failed or superseded. A batch being assembled is not one.
|
||||
func planEnded(p inventory.Plan) bool { return !p.Open() && !p.Batch() }
|
||||
|
||||
// waitOnPlan reads the plan until what it waits for happens or the bound passes, and answers the plan as last
|
||||
// read and what ended the wait: moved (any save of it), tier (its tier changed), done (it ended — which ends
|
||||
// every wait, since nothing comes after it), or bound. Moved and tier are judged against the plan as first read.
|
||||
func waitOnPlan(ctx context.Context, read func(context.Context) (inventory.Plan, error), until string,
|
||||
bound, every time.Duration) (inventory.Plan, string, error) {
|
||||
first, err := read(ctx)
|
||||
if err != nil {
|
||||
return inventory.Plan{}, "", err
|
||||
}
|
||||
if planEnded(first) {
|
||||
return first, "done", nil
|
||||
}
|
||||
deadline := time.Now().Add(bound)
|
||||
tick := time.NewTicker(every)
|
||||
defer tick.Stop()
|
||||
last := first
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return last, "", ctx.Err()
|
||||
case <-tick.C:
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return last, "bound", nil
|
||||
}
|
||||
p, err := read(ctx)
|
||||
if err != nil {
|
||||
return last, "", err
|
||||
}
|
||||
last = p
|
||||
switch {
|
||||
case planEnded(p):
|
||||
return p, "done", nil
|
||||
case until == "moved" && p.Revision != first.Revision:
|
||||
return p, "moved", nil
|
||||
case until == "tier" && p.Tier != first.Tier:
|
||||
return p, "tier", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// planWaited is what `plans wait` answers: the plan, what ended the wait, and the line a person reads (`text`,
|
||||
// which the verb answers as its output and not again in the data).
|
||||
type planWaited struct {
|
||||
// Came is what ended the wait: moved, tier, done, or bound when nothing did.
|
||||
Came string `json:"came"`
|
||||
Until string `json:"until"`
|
||||
Waited string `json:"waited"`
|
||||
Plan planStatus `json:"plan"`
|
||||
// Revision is the plan's as read: a caller asking again with the same plan sees whether it moved since.
|
||||
Revision int64 `json:"revision"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func planWaitedDoc(p inventory.Plan, st planStatus, until, came string, waited time.Duration, line string) planWaited {
|
||||
said := map[string]string{
|
||||
"moved": "moved", "tier": "reached another tier", "done": "ended",
|
||||
"bound": "did not " + map[string]string{"moved": "move", "tier": "reach another tier", "done": "end"}[until],
|
||||
}[came]
|
||||
text := fmt.Sprintf("%s %s in %s (waited until %s)\n%s — %s\n", p.ID, said, waited.Round(time.Second), until,
|
||||
p.ID, line)
|
||||
return planWaited{Came: came, Until: until, Waited: waited.Round(time.Millisecond).String(), Plan: st,
|
||||
Revision: p.Revision, Text: text}
|
||||
}
|
||||
|
||||
// planWaitCommand is `plans wait <id>`: the plan followed until it moves, or the bound passes.
|
||||
func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsked, boundAsked string) error {
|
||||
until, bound, err := planWaitAsked(untilAsked, boundAsked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
began := time.Now()
|
||||
p, came, err := waitOnPlan(ctx, func(ctx context.Context) (inventory.Plan, error) {
|
||||
return inv.PlanByID(ctx, id)
|
||||
}, until, bound, planWaitEvery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Said as `plans <id>` says its first line, and as `status` says the plan.
|
||||
now, one := time.Now(), []inventory.Plan{p}
|
||||
bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one)
|
||||
line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}, waitsOf(ctx, inv, one))
|
||||
doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line)
|
||||
return json.NewEncoder(os.Stdout).Encode(doc)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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()
|
||||
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 > 10*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", "2"})
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1463,6 +1463,10 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
modules := set.String("modules", "", "or the modules it would change, comma-separated")
|
||||
moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+
|
||||
"comma-separated, as the forge's announcer says them (novox/hq issue 278)")
|
||||
// Following a plan (novox/hq issue 229): `plans wait <id>`.
|
||||
until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)")
|
||||
bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+
|
||||
"longest a call is answered within)")
|
||||
// Ending a plan by hand is a repair, and says why (novox/hq to-be 45 §7).
|
||||
why := addHandActFlags(set)
|
||||
positionals, err := parseAround(set, args)
|
||||
@@ -1482,6 +1486,9 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
now := time.Now()
|
||||
if len(positionals) == 2 && positionals[0] == "wait" {
|
||||
return planWaitCommand(ctx, inv, positionals[1], *until, *bound)
|
||||
}
|
||||
if len(positionals) == 1 {
|
||||
p, err := inv.PlanByID(ctx, positionals[0])
|
||||
if err != nil {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// TestReplay401 replays novox/hq issue 401 (2026-10-10): the mounts module's smb-credentials, an own secret
|
||||
// only an outside party issues, was given by nobody, and the send made a random value for it and placed it on
|
||||
// the machine; the module read it as a credential given and wrong. The outcome asserted (ADR 0228): the send
|
||||
// makes no value for an own secret issued outside the mesh that nobody gave and places none, and a value the
|
||||
// mesh made for it before is not placed either; once a person gives one, that value is placed. Written with
|
||||
// only what the controller had before its fix (mesh-controller #262) — register, assign, planFor,
|
||||
// declarationFor and the inventory's secret reads — so it is laid over the commit before. Needs the store
|
||||
// (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
|
||||
func TestReplay401(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
const made, outside = "/var/lib/mesh/mounts/password", "/var/lib/mesh/mounts/smb-credentials"
|
||||
register(t, open, catalogue.Manifest{Module: "mounts", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{
|
||||
"password": {Path: made, Taken: catalogue.TakenAtStart},
|
||||
"smb-credentials": {Path: outside, Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
|
||||
},
|
||||
Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "path": "/var/lib/mesh/mounts", "mode": "0700"},
|
||||
}})
|
||||
if _, err := assign(ctx, open, "laptop", "mounts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := func() []map[string]any {
|
||||
t.Helper()
|
||||
plan, settings, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
|
||||
if err != nil {
|
||||
t.Fatalf("the send to laptop did not compose: %v", err)
|
||||
}
|
||||
return declared.Resources
|
||||
}
|
||||
placed := func(resources []map[string]any, path string) map[string]any {
|
||||
for _, r := range resources {
|
||||
if r["path"] == path && r["sealed"] != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
first := sent()
|
||||
if placed(first, made) == nil {
|
||||
t.Fatal("the secret the mesh makes was not placed")
|
||||
}
|
||||
if got := placed(first, outside); got != nil {
|
||||
t.Errorf("the send placed a value for smb-credentials, which nobody gave: %v", got)
|
||||
}
|
||||
if _, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "mounts", "smb-credentials"); err != nil || held {
|
||||
t.Fatalf("the send made a value for a secret issued outside the mesh (held %v, %v)", held, err)
|
||||
}
|
||||
|
||||
// A value the mesh made for it before the rule is not one a person gave: it is not placed.
|
||||
if _, err := open.inventory.SecretForModule(ctx, "laptop", "mounts", "smb-credentials"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := placed(sent(), outside); got != nil {
|
||||
t.Fatalf("a value the mesh made for a secret issued outside was placed: %v", got)
|
||||
}
|
||||
|
||||
// Given by a person, it is placed, as given.
|
||||
if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "mounts", "smb-credentials", "the share's credentials"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "mounts", "smb-credentials")
|
||||
if err != nil || !held {
|
||||
t.Fatalf("the value given is not held (%v)", err)
|
||||
}
|
||||
if got := placed(sent(), outside); got == nil || got["sealed"] != given {
|
||||
t.Fatalf("the value given is placed as %v", got)
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
}
|
||||
return argv, nil
|
||||
case "plans":
|
||||
if err := plainPlanIDs(a.given); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r := str("repository"); r != "" {
|
||||
argv := []string{"plans", "--what-if", r}
|
||||
if p := str("paths"); p != "" {
|
||||
@@ -365,6 +368,21 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return argv, nil
|
||||
}
|
||||
}
|
||||
if id := str("wait"); id != "" {
|
||||
// Following a plan (novox/hq issue 229): checked here too, so a call asking what no wait can do is
|
||||
// refused before a command is started.
|
||||
if _, _, err := planWaitAsked(a.given["until"], a.given["bound"]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := []string{"plans", "wait", id}
|
||||
if u := str("until"); u != "" {
|
||||
argv = append(argv, "--until", u)
|
||||
}
|
||||
if b := str("bound"); b != "" {
|
||||
argv = append(argv, "--bound", b)
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
if id := str("id"); id != "" {
|
||||
return []string{"plans", id}, nil
|
||||
}
|
||||
@@ -985,12 +1003,14 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co
|
||||
// The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239.
|
||||
"delivery": true,
|
||||
// The build queue, each ask with its position among the waiting (novox/hq issue 455).
|
||||
"queue": true}
|
||||
"queue": true,
|
||||
// A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data.
|
||||
"plans": true}
|
||||
|
||||
// tableBesideVerbs are the JSON verbs whose document carries the table a person reads as its `text`: the
|
||||
// verb answers that table as its output, as it did before it answered data, and the rest as `answer` —
|
||||
// both from the one read, so they never disagree (novox/hq issue 455).
|
||||
var tableBesideVerbs = map[string]bool{"queue": true}
|
||||
var tableBesideVerbs = map[string]bool{"queue": true, "plans": true}
|
||||
|
||||
// overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the
|
||||
// command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs
|
||||
@@ -1264,6 +1284,16 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
// Whatever it did, `status` is composed again once it has.
|
||||
defer statusFrom.nudge()
|
||||
}
|
||||
if verb == "plans" && len(argv) > 1 && argv[1] == "wait" {
|
||||
// A wait holds its answer up to its bound (novox/hq issue 229), and is answered in full inside it.
|
||||
asked := ""
|
||||
if i := slices.Index(argv, "--bound"); i > 0 && i+1 < len(argv) {
|
||||
asked = argv[i+1]
|
||||
}
|
||||
if _, bound, err := planWaitAsked("", asked); err == nil {
|
||||
link.AnswersWithin(ctx, bound+planWaitStart)
|
||||
}
|
||||
}
|
||||
if answer, read := readHere(ctx, argv); read {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
@@ -116,9 +116,15 @@ var ControllerVerbs = []Verb{
|
||||
"limit": "how many builds to list (default 20); not with log",
|
||||
}, nil)},
|
||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id; " +
|
||||
"or, given wait, one plan followed until it moves.",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||
"wait": "a plan's id: wait until it moves, then answer it as data — the plan, and what ended the wait " +
|
||||
"(moved, tier, done, or bound); ask again to follow it further (novox/hq issue 229)",
|
||||
"until": "with wait: moved (any change, the default), tier (it reaches another tier) or done (it ends)",
|
||||
"bound": "with wait: how long to wait at most, as 15s or seconds — 20s, the default, is the longest, " +
|
||||
"inside what a call is answered within",
|
||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
||||
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
||||
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A plan's id is a value, never SQL** (novox/hq issue 561): the id reaches PlanByID from a caller of the
|
||||
// controller's plans verb, so an id holding a quote is read as a plain id — no error from the store, and no
|
||||
// other plan answered for it.
|
||||
func TestAPlanIdHoldingAQuoteIsReadAsAPlainId(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
p := Plan{ID: "plan-561", Repository: "novox/mesh-controller", Commit: "abc", Created: time.Now().UTC(),
|
||||
State: PlanBuilding, Tiers: [][]string{{"a"}}, Modules: map[string]*PlanModule{"a": {}}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{
|
||||
"x' or '1'='1", // every plan, when pasted into the query
|
||||
"plan-561' or id like '%", // the plan above, by a pattern
|
||||
"it's", // a quote alone: a syntax error, when pasted
|
||||
} {
|
||||
got, err := inv.PlanByID(ctx, id)
|
||||
if err == nil {
|
||||
t.Fatalf("PlanByID(%q) answered %s, a plan of another id", id, got.ID)
|
||||
}
|
||||
if want := "no plan " + id; err.Error() != want {
|
||||
t.Fatalf("PlanByID(%q) failed with %q, not as a plain id no plan has (%q)", id, err, want)
|
||||
}
|
||||
}
|
||||
// A plan whose id holds a quote is found by it, and by nothing else.
|
||||
quoted := p
|
||||
quoted.ID, quoted.Revision = "plan-'561'", 0
|
||||
if err := inv.SavePlan(ctx, "ed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := inv.PlanByID(ctx, quoted.ID); err != nil || got.ID != quoted.ID {
|
||||
t.Fatalf("the plan with a quote in its id was not read back: %v %+v", err, got.ID)
|
||||
}
|
||||
if recent, err := inv.RecentPlans(ctx, 1); err != nil || len(recent) != 1 || !strings.HasPrefix(recent[0].ID, "plan-") {
|
||||
t.Fatalf("RecentPlans with a parameter: %v %+v", err, recent)
|
||||
}
|
||||
}
|
||||
@@ -623,7 +623,7 @@ func (i *Inventory) PlansSince(ctx context.Context, since time.Time) ([]Plan, er
|
||||
|
||||
// RecentPlans is the last few plans, newest first, open or not — what the overview shows.
|
||||
func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) {
|
||||
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
|
||||
return i.plans(ctx, `order by created desc limit $1`, limit)
|
||||
}
|
||||
|
||||
// NewestMergeOf is the plan, in any state, of the newest merge into a repository's branch that the mesh
|
||||
@@ -638,9 +638,10 @@ func (i *Inventory) NewestMergeOf(ctx context.Context, repository, branch string
|
||||
return plans[0], true, nil
|
||||
}
|
||||
|
||||
// PlanByID is one plan.
|
||||
// PlanByID is one plan. The id is a parameter, never part of the query (novox/hq issue 561): it comes from a
|
||||
// caller of the plans verb.
|
||||
func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
||||
plans, err := i.plans(ctx, `where id = '`+id+`'`)
|
||||
plans, err := i.plans(ctx, `where id = $1`, id)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
|
||||
+47
-10
@@ -601,6 +601,24 @@ func Acknowledge(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// LongestAnswer is the longest a handler that waits on purpose may hold its caller's one answer
|
||||
// (AnswersWithin, novox/hq issue 229): inside the console's thirty seconds with room for the trip back,
|
||||
// and inside the bus's window for an answer (broker.ResponseTTL). A variable so a test need not wait.
|
||||
var LongestAnswer = 25 * time.Second
|
||||
|
||||
type answerWithinKey struct{}
|
||||
|
||||
// AnswersWithin asks that the call's caller be answered in full if the handler ends within d of the
|
||||
// call's start, rather than within AnswerWithin: for a handler that waits on purpose, with a bound —
|
||||
// `plans` waiting for a plan to move (novox/hq issue 229). Past d, or past LongestAnswer whichever is
|
||||
// shorter, its caller is told it is running, as for any call. Never shortens the window; without effect
|
||||
// outside a served call, and once the caller was answered.
|
||||
func AnswersWithin(ctx context.Context, d time.Duration) {
|
||||
if f, ok := ctx.Value(answerWithinKey{}).(func(time.Duration)); ok {
|
||||
f(d)
|
||||
}
|
||||
}
|
||||
|
||||
// running is the interim answer: what a caller reads when the call has not finished.
|
||||
func running(c *Call, within time.Duration, acknowledged bool, follow string) []byte {
|
||||
why := fmt.Sprintf("it has not finished in %s", within)
|
||||
@@ -645,6 +663,13 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply
|
||||
acknowledged := make(chan struct{})
|
||||
var once sync.Once
|
||||
ctx = context.WithValue(ctx, answerNowKey{}, func() { once.Do(func() { close(acknowledged) }) })
|
||||
longer := make(chan time.Duration, 1)
|
||||
ctx = context.WithValue(ctx, answerWithinKey{}, func(d time.Duration) {
|
||||
select {
|
||||
case longer <- d:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
type outcome struct {
|
||||
body []byte
|
||||
@@ -703,17 +728,29 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply
|
||||
}
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(AnswerWithin)
|
||||
within, start := AnswerWithin, time.Now()
|
||||
timer := time.NewTimer(within)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case o := <-done:
|
||||
l.finish(c, o.body, o.failed, false)
|
||||
say(o.body)
|
||||
return
|
||||
case <-acknowledged:
|
||||
say(running(c, AnswerWithin, true, l.Follow))
|
||||
case <-timer.C:
|
||||
say(running(c, AnswerWithin, false, l.Follow))
|
||||
waiting:
|
||||
for {
|
||||
select {
|
||||
case o := <-done:
|
||||
l.finish(c, o.body, o.failed, false)
|
||||
say(o.body)
|
||||
return
|
||||
case <-acknowledged:
|
||||
say(running(c, within, true, l.Follow))
|
||||
break waiting
|
||||
case d := <-longer:
|
||||
// A handler waiting on purpose (AnswersWithin): its window is stretched, never past LongestAnswer.
|
||||
if d = min(d, LongestAnswer); d > within {
|
||||
within = d
|
||||
timer.Reset(time.Until(start.Add(d)))
|
||||
}
|
||||
case <-timer.C:
|
||||
say(running(c, within, false, l.Follow))
|
||||
break waiting
|
||||
}
|
||||
}
|
||||
o := <-done
|
||||
l.finish(c, o.body, o.failed, true)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A handler that waits on purpose is answered in full when it ends inside the window it asked for**
|
||||
// (novox/hq issue 229): `plans` asked to wait for a plan holds its caller's one answer until the plan
|
||||
// moves or its bound passes, rather than being answered "still running" at AnswerWithin.
|
||||
func TestAHandlerThatWaitsOnPurposeIsAnsweredInFullInsideItsWindow(t *testing.T) {
|
||||
shortWindow(t, 20*time.Millisecond)
|
||||
l, a := NewCallLog(), newAnswers(t)
|
||||
l.serveCall("mesh-controller", "plans", json.RawMessage(`{"wait":"plan-1"}`), "_INBOX.x.3",
|
||||
func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
AnswersWithin(ctx, 200*time.Millisecond)
|
||||
time.Sleep(80 * time.Millisecond) // past AnswerWithin, inside what it asked for
|
||||
return "plan-1 moved", nil
|
||||
}, a.respond, nil)
|
||||
if got := a.only()["result"]; got != "plan-1 moved" {
|
||||
t.Fatalf("a waiting handler inside its window was answered %v, not in full", got)
|
||||
}
|
||||
}
|
||||
|
||||
// What a handler may ask for is bounded: past LongestAnswer its caller is told it is running, as any
|
||||
// call is, so no answer is ever later than the console waits.
|
||||
func TestAWaitIsNeverLongerThanTheLongestAnswer(t *testing.T) {
|
||||
shortWindow(t, 10*time.Millisecond)
|
||||
was := LongestAnswer
|
||||
LongestAnswer = 30 * time.Millisecond
|
||||
t.Cleanup(func() { LongestAnswer = was })
|
||||
l, a := NewCallLog(), newAnswers(t)
|
||||
release := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
l.serveCall("mesh-controller", "plans", nil, "_INBOX.x.4", func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
AnswersWithin(ctx, time.Hour)
|
||||
<-release
|
||||
return "late", nil
|
||||
}, a.respond, nil)
|
||||
close(finished)
|
||||
}()
|
||||
select {
|
||||
case <-a.sent:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("a handler asking for an hour held its caller past LongestAnswer")
|
||||
}
|
||||
if got, _ := a.only()["result"].(map[string]any); got["running"] != true {
|
||||
t.Fatalf("past LongestAnswer the caller was answered %v, not that it is running", a.only())
|
||||
}
|
||||
close(release)
|
||||
<-finished
|
||||
}
|
||||
Vendored
+2
-1
@@ -7,12 +7,14 @@
|
||||
./cmd/mesh-controller TestAStopNamesWhatStaysOnTheMachinesAndWhatStillCarriesIt
|
||||
./cmd/mesh-controller TestAStopWithoutAMergeLeavesItOutOfABatch
|
||||
./cmd/mesh-controller TestSavingPlansAndMovingMergesIsOneAct
|
||||
./cmd/mesh-controller TestAPlanFollowedFromTheStoreAnswersWhenItMoves
|
||||
./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered
|
||||
./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend
|
||||
./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore
|
||||
./internal/inventory TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain
|
||||
./internal/inventory TestAnIssuedValueIsSealedAgainAfterAKeyChangeOrAnAcceptance
|
||||
./internal/inventory TestAConditionsLooksAreKeptBesideTheBucket
|
||||
./internal/inventory TestAPlanIdHoldingAQuoteIsReadAsAPlainId
|
||||
./cmd/mesh-controller TestTheConditionsVerbReadsTheLooksTheBucketNoLongerHolds
|
||||
./internal/inventory TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid
|
||||
./cmd/mesh-controller TestAWalkLetGoAndStoppedByVerbsIsSaidStartedAndEnded
|
||||
@@ -29,4 +31,3 @@
|
||||
./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb
|
||||
./cmd/mesh-controller TestTheSendMakesNoValueForAnOutsideSecretNobodyGave
|
||||
./cmd/mesh-controller TestAQuestionComposesWithoutAnOutsideSecretNobodyGave
|
||||
./cmd/mesh-controller TestReplay401
|
||||
|
||||
Reference in New Issue
Block a user