The controller asks no build of anybody (ADR 0501 §5, ADR 0525 clause 6, ADR 0503 §3). Every module of a walk is taken from the builder's done key; a key not done yet waits, named in the plan, and never fails it. - C2b/C2c: internal/builder and cmd/mesh-controller's asks go (askABuild, onlyTheBuildersOwn, buildOneAsked, takeIn from an outcome, buildAgentsOwn, the builder's say and its fallback, the daemon hearing an outcome). The server follows the retired seats' built subjects only to let go of them, because the installer's carried user list names them. - C2d: replay, queue, cancel, clear, kill, pause, resume, delivery-check, check-here and merge-gate are refused by name. - C3: the check queues (CHECKS_READY, CHECKS_OTHER) and their workers, the checks-running and plans-building buckets and their grants, the build queue and its hold (plans_building.go, queue.go, link/holds.go, builds_nats.go, checks_nats.go). - C4: merge_gate*.go, the pull request's repo-check path, the Checked event and its layers; a pull request asks the controller nothing but to tell mesh-config what its head declares. - C5: the builds bucket is written only by key_takein. - C10 (help and refusals): the terminal's help lists only what is served. Integration tests of the plan's tiers, sends, stops and retries now run on a stand-in builder with a done key for every module (walk_keys_scaffold_integration_test.go); the tests of the asks, the queue, the merge gate and the replays are deleted.
233 lines
9.8 KiB
Go
233 lines
9.8 KiB
Go
//go:build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"github.com/novox/mesh-controller/internal/chain"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/lease"
|
|
)
|
|
|
|
// **Acceptance 10 of design 47: one read of an assignment names the commit, the build and the rollout that put it there,
|
|
// and one read of the rollout lists the assignments it changed** (novox/hq ADR 0408, issue 1095). It enters by the live
|
|
// trigger — a merge heard, its batch cut into a plan when the window closed, its build answered through the build seat's
|
|
// path, the plan advanced — and writes no key and no row by hand. Only the machine's end of the send is the test's: the send
|
|
// is recorded as the controller records one it made, and the gate's facts are the store's.
|
|
func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRolloutListsItsAssignments(t *testing.T) {
|
|
open := windowed(t)
|
|
everyHeadWaits(t)
|
|
asked := asksRecorded(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
if _, err := assign(ctx, open, "anchor", "notes"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var sent [][]string
|
|
wasSend := sendRollout
|
|
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
|
sent = append(sent, append([]string(nil), names...))
|
|
current, err := open.inventory.CurrentBuilds(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, node := range names {
|
|
digest := fmt.Sprintf("d-%s-%d", node, len(sent))
|
|
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest,
|
|
map[string]string{"notes": current["notes"].Commit}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
t.Cleanup(func() { sendRollout = wasSend })
|
|
wasGather := gatherGateFacts
|
|
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
|
f := gateFacts{now: time.Now(), reports: map[string]inventory.Reported{}, engines: map[string]string{},
|
|
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
|
|
reports, err := open.inventory.LastReports(ctx)
|
|
if err != nil {
|
|
return f, err
|
|
}
|
|
for _, r := range reports {
|
|
f.reports[r.Node] = r
|
|
}
|
|
return f, nil
|
|
}
|
|
t.Cleanup(func() { gatherGateFacts = wasGather })
|
|
|
|
// A merge of notes is heard, and its window closes: the batch is cut into the walk.
|
|
hear(t, open, catalogueMerge("c-notes", "notes", t0), t0.Add(time.Second))
|
|
cutAt(t, open, t0.Add(time.Second+90*time.Second))
|
|
ws, _ := walks(t, open)
|
|
if len(ws) != 1 {
|
|
t.Fatalf("%d walk(s) after the cut", len(ws))
|
|
}
|
|
rollout := ws[0].ID
|
|
|
|
// The walk asks its first tier (at the cut, or at its next step); the build seat answers, and the controller takes the
|
|
// outcome in as it does.
|
|
asking, err := inv.PlanByID(ctx, rollout)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for try := 0; try < 6 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == "" || asking.Modules["notes"].State == "asked"); try++ {
|
|
advancePlans(ctx, open)
|
|
if asking, err = inv.PlanByID(ctx, rollout); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
buildID := asking.Modules["notes"].Build
|
|
if buildID == "" || len(*asked) != 1 || !strings.HasSuffix((*asked)[0], "#"+buildID) {
|
|
t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID)
|
|
}
|
|
|
|
// The walk sends the tier to its first machine.
|
|
for try := 0; try < 3 && len(sent) == 0; try++ {
|
|
advancePlans(ctx, open)
|
|
}
|
|
if !slices.Equal(slices.Concat(sent...), []string{"anchor"}) {
|
|
t.Fatalf("sent %v", sent)
|
|
}
|
|
|
|
// One read of the assignment.
|
|
on, err := inv.AssignmentsOn(ctx, "anchor")
|
|
a, ok := assignmentOf(on, "notes")
|
|
if err != nil || !ok {
|
|
t.Fatalf("the assignments of anchor: %+v %v", on, err)
|
|
}
|
|
// Beside it a module pulled in by the assignment, which no send of this walk put anywhere.
|
|
if wireguard, pulled := assignmentOf(on, "mesh-wireguard"); pulled && (wireguard.Build != "" || wireguard.Rollout != "") {
|
|
t.Fatalf("a module this rollout did not send names a build or a rollout: %+v", wireguard)
|
|
}
|
|
if a.Module != "notes" || a.Commit != "c-notes" || a.Build != buildID || a.Rollout != rollout ||
|
|
a.Since.IsZero() || a.Assigned.IsZero() || !a.Stands() {
|
|
t.Fatalf("the assignment names %+v, not commit c-notes, build %s, rollout %s", a, buildID, rollout)
|
|
}
|
|
// One read of the rollout.
|
|
p, err := inv.PlanByID(ctx, rollout)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantChange := chain.Change{Node: "anchor", Module: "notes", After: chain.Ref{Build: buildID, Commit: "c-notes"}}
|
|
if !slices.Contains(p.Changed, wantChange) || !slices.Contains(p.Builds, buildID) || len(p.Steps) != 1 ||
|
|
p.Steps[0].Node != "anchor" || p.Steps[0].Sent == nil {
|
|
t.Fatalf("the rollout lists changed %+v, builds %v, steps %+v; want %+v", p.Changed, p.Builds, p.Steps, wantChange)
|
|
}
|
|
// And one read of the build names the same commit.
|
|
built, found, err := inv.BuildByID(ctx, buildID)
|
|
if err != nil || !found || built.Commit != "c-notes" || built.Module != "notes" {
|
|
t.Fatalf("the build: %+v %v %v", built, found, err)
|
|
}
|
|
// The verbs say the links.
|
|
if lines := changedLines(p); len(lines) != 2 || !strings.Contains(lines[1], "anchor/notes") ||
|
|
!strings.Contains(lines[1], buildID) {
|
|
t.Fatalf("`plans` says %v", lines)
|
|
}
|
|
if lines := runLines(on); len(lines) != 2 || !strings.Contains(lines[1], buildID) || !strings.Contains(lines[1], rollout) {
|
|
t.Fatalf("`node show` says %v", lines)
|
|
}
|
|
}
|
|
|
|
// A machine sent again what it runs says nothing new, and a push by hand with no rollout fills what is missing and blanks
|
|
// nothing (issue 1095).
|
|
func TestASendOfWhatAMachineRunsAlreadyChangesNoLinkOfItsAssignment(t *testing.T) {
|
|
open := aMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
register(t, open, catalogue.Manifest{Module: "notes", Version: "1"})
|
|
if _, err := assign(ctx, open, "anchor", "notes"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-1", Module: "notes", Commit: "c1", Repository: "novox/notes"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node := nodeID(t, open, "anchor")
|
|
inRollout := inventory.WithRollout(ctx, "plan-1", map[string]inventory.RolloutModule{"notes": {Commit: "c1", Build: "build-1"}})
|
|
if err := inv.RecordSent(inRollout, node, "d1", map[string]string{"notes": "c1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Sent again by hand, with no rollout: the assignment still names the rollout that put the build there.
|
|
if err := inv.RecordSent(ctx, node, "d2", map[string]string{"notes": "c1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
on, _ := inv.AssignmentsOn(ctx, "anchor")
|
|
if a, _ := assignmentOf(on, "notes"); a.Rollout != "plan-1" || a.Build != "build-1" || a.Commit != "c1" {
|
|
t.Fatalf("the assignment names %+v", on)
|
|
}
|
|
// A declaration sent by hand with no builds known makes what the machine runs unknown: a time on the key, no commit.
|
|
if err := inv.RecordSent(ctx, node, "d3", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if on, _ = inv.AssignmentsOn(ctx, "anchor"); runOf(on, "notes").Commit != "" || runOf(on, "notes").SentUnknown.IsZero() {
|
|
t.Fatalf("a send of unknown builds left %+v", on)
|
|
}
|
|
// A module sent a commit no build is kept of is named by its commit alone.
|
|
if err := inv.RecordSent(ctx, node, "d4", map[string]string{"notes": "c2"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if on, _ = inv.AssignmentsOn(ctx, "anchor"); runOf(on, "notes").Commit != "c2" || runOf(on, "notes").Build != "" ||
|
|
runOf(on, "notes").Rollout != "" {
|
|
t.Fatalf("the assignment after a push of c2 by hand names %+v", on)
|
|
}
|
|
}
|
|
|
|
// assignmentOf is one module's assignment among those of a machine: assigning a module assigns what it requires beside it.
|
|
func assignmentOf(on []chain.Assignment, module string) (chain.Assignment, bool) {
|
|
for _, a := range on {
|
|
if a.Module == module {
|
|
return a, true
|
|
}
|
|
}
|
|
return chain.Assignment{}, false
|
|
}
|
|
|
|
func runOf(on []chain.Assignment, module string) chain.Assignment {
|
|
a, _ := assignmentOf(on, module)
|
|
return a
|
|
}
|
|
|
|
// **An assignment waiting for its build is one key of the assignments bucket** (ADR 0261, ADR 0408, issue 1094): entered
|
|
// through the `assign` verb for a module whose build is in flight, one read of `<node>.<module>` says state pending with the
|
|
// build it waits for, the build's outcome through the build seat's path makes the key stand with the wait carried as applied,
|
|
// and the table of pending assignments is read by nobody. No key is written by hand.
|
|
func TestAnAssignmentWaitingForItsBuildIsOneKeyOfTheAssignmentsBucket(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
asksWithPaths(t)
|
|
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
|
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
|
|
t.Fatalf("an assignment while its build runs was refused: %v", err)
|
|
}
|
|
waiting, found, err := inv.AssignmentValue(ctx, "laptop", "sensors")
|
|
if err != nil || !found || waiting.State != chain.StatePending || waiting.Stands() || !waiting.Assigned.IsZero() ||
|
|
waiting.Pending == nil || waiting.Pending.State != chain.PendingWaiting || waiting.Pending.Build != "b-modules/sensors" ||
|
|
!strings.HasPrefix(waiting.Pending.ID, "p-") {
|
|
t.Fatalf("one read of the key says %+v (found %v, %v)", waiting, found, err)
|
|
}
|
|
if got := assignedTo(t, open, "laptop"); slices.Contains(got, "sensors") {
|
|
t.Fatalf("a module only waiting is assigned: %v", got)
|
|
}
|
|
|
|
if err := (builds{inv: inv, open: open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors", "3da80a4b00aa", "sensors", "")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
made, found, err := inv.AssignmentValue(ctx, "laptop", "sensors")
|
|
if err != nil || !found || !made.Stands() || made.State != "" || made.Assigned.IsZero() || made.Pending == nil ||
|
|
made.Pending.State != chain.PendingApplied || made.Pending.ID != waiting.Pending.ID {
|
|
t.Fatalf("one read of the key after the build says %+v (found %v, %v)", made, found, err)
|
|
}
|
|
if !slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
|
t.Fatal("the build registered sensors and it was not assigned")
|
|
}
|
|
}
|