Archived
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/assign-says-why-a-module-is-not-there ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
A merge asks for a new module's build, and assign answered "no module of that name" until the build registered it, which read as a module nobody registered (hq issue 325). Keep every build request, tell a build in flight, a module known and not built, and an unknown name apart, and make an assignment made while the build runs when the build registers the module.
330 lines
13 KiB
Go
330 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// novox/hq issue 325: an assignment of a module the catalogue does not hold says which case it is in — a
|
|
// build in flight (kept pending), known and not built (said, with build), or unknown (refused, with the
|
|
// closest names) — and a pending assignment is made when its build registers the module, or ends with why.
|
|
|
|
// aCatalogueMesh is aMesh with one module held from the catalogue, so a merge of it is acted on.
|
|
func aCatalogueMesh(t *testing.T) *stores {
|
|
t.Helper()
|
|
open := aMesh(t)
|
|
if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: "networkmanager", Version: "1"},
|
|
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/networkmanager", Ref: "main",
|
|
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return open
|
|
}
|
|
|
|
// mergeAdding is the catalogue's merge adding one module's directory, acted on as the bus hands it over.
|
|
func mergeAdding(t *testing.T, open *stores, dir, commit string) {
|
|
t.Helper()
|
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: commit,
|
|
Paths: []string{dir + "/module.json"}, ModuleDirs: []string{dir}, ModuleDirsSaid: true}
|
|
if err := (following{open: open}).SourceMoved(t.Context(), m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// outcome is a build's outcome as the build seat announces it: registered as module, or failed.
|
|
func outcome(id, dir, commit, module, failed string) link.BuildResult {
|
|
r := link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: dir, Ref: "main", Commit: commit,
|
|
On: "anchor", Failed: failed, Source: &link.SourceOnSeat{Repository: "novox/mesh-catalog", Seat: "git"}}
|
|
if failed == "" {
|
|
r.Module = module
|
|
r.Manifest, _ = json.Marshal(catalogue.Manifest{Module: module, Version: "1"})
|
|
}
|
|
return r
|
|
}
|
|
|
|
func assignedTo(t *testing.T, open *stores, node string) []string {
|
|
t.Helper()
|
|
got, err := open.inventory.Assigned(t.Context(), node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
func pendingOf(t *testing.T, open *stores) []inventory.PendingAssignment {
|
|
t.Helper()
|
|
got, err := open.inventory.Pending(t.Context(), time.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// A build in flight: the merge asked for it, the assignment is kept pending and said so with the build, and
|
|
// the build's outcome makes it. Status says it while it waits.
|
|
func TestAnAssignmentWaitsForTheBuildInFlight(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
asksWithPaths(t)
|
|
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
|
|
|
said, err := assign(ctx, open, "laptop", "sensors")
|
|
if err != nil {
|
|
t.Fatalf("an assignment while its build runs was refused: %v", err)
|
|
}
|
|
t.Logf("assign laptop sensors, while its build runs:\n%s", said)
|
|
for _, want := range []string{"being built from novox/mesh-catalog@3da80a4b (modules/sensors)",
|
|
"build b-modules/sensors", "kept as pending", "`unassign laptop sensors`"} {
|
|
if !strings.Contains(said, want) {
|
|
t.Fatalf("the answer does not say %q:\n%s", want, said)
|
|
}
|
|
}
|
|
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
|
t.Fatal("a module not registered was assigned")
|
|
}
|
|
pending := pendingOf(t, open)
|
|
if len(pending) != 1 || pending[0].State != inventory.PendingWaiting || pending[0].Build != "b-modules/sensors" {
|
|
t.Fatalf("pending: %+v", pending)
|
|
}
|
|
// Asked twice, it is one pending assignment.
|
|
if again, err := assign(ctx, open, "laptop", "sensors"); err != nil || !strings.Contains(again, "already waits") {
|
|
t.Fatalf("a second assignment: %q %v", again, err)
|
|
}
|
|
|
|
asked, err := theThreeQuestions(ctx, open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked.well() {
|
|
t.Fatal("status called the mesh well while an assignment waits")
|
|
}
|
|
body, err := statusAsJSON(asked)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc meshStatus
|
|
if err := json.Unmarshal(body, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(doc.Pending) != 1 || doc.Pending[0].State != "waiting" || doc.Pending[0].Module != "sensors" {
|
|
t.Fatalf("status says pending %+v", doc.Pending)
|
|
}
|
|
|
|
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
|
|
"3da80a4b00aa", "sensors", "")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
|
t.Fatal("the build registered sensors and the pending assignment was not made")
|
|
}
|
|
pending = pendingOf(t, open)
|
|
if len(pending) != 1 || pending[0].State != inventory.PendingApplied ||
|
|
!strings.Contains(pending[0].Note, "`push laptop` sends it if not") {
|
|
t.Fatalf("pending after the build: %+v", pending)
|
|
}
|
|
}
|
|
|
|
// The build of a pending assignment fails: it expires, saying the build's words, and nothing is assigned.
|
|
func TestAPendingAssignmentExpiresWhenItsBuildFails(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
asksWithPaths(t)
|
|
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
|
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
|
|
"3da80a4b00aa", "", "go build: undefined: sensorsRead")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending := pendingOf(t, open)
|
|
if len(pending) != 1 || pending[0].State != inventory.PendingExpired {
|
|
t.Fatalf("pending after a failed build: %+v", pending)
|
|
}
|
|
for _, want := range []string{"expired", "build b-modules/sensors of modules/sensors failed",
|
|
"undefined: sensorsRead", "nothing was assigned", "build \"true\""} {
|
|
if !strings.Contains(pending[0].Note, want) {
|
|
t.Fatalf("the expiry does not say %q: %s", want, pending[0].Note)
|
|
}
|
|
}
|
|
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
|
t.Fatal("a failed build's module was assigned")
|
|
}
|
|
asked, err := theThreeQuestions(ctx, open)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked.well() {
|
|
t.Fatal("status called the mesh well on the day a pending assignment expired")
|
|
}
|
|
|
|
// Known and not built now: said, and refused without build.
|
|
_, err = assign(ctx, open, "laptop", "sensors")
|
|
if !errors.Is(err, inventory.ErrNoSuchModule) {
|
|
t.Fatalf("a module whose build failed: %v", err)
|
|
}
|
|
for _, want := range []string{"sensors is known and not registered", "its last build, b-modules/sensors",
|
|
"failed: go build: undefined: sensorsRead", "build \"true\"", "repository novox/mesh-catalog, path modules/sensors"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Known and not built, asked with build: the build is asked from where the last one was, and the assignment
|
|
// kept pending on the new build.
|
|
func TestAssignWithBuildAsksForAKnownModule(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: "build-old", Repository: "novox/mesh-catalog",
|
|
Seat: "git", Path: "modules/sensors", Ref: "main", For: "build", At: time.Now().Add(-time.Hour)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-old", Repository: "novox/mesh-catalog", Ref: "main",
|
|
Path: "modules/sensors", On: "anchor", Failed: "no space left on device"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
asked := asksWithPaths(t)
|
|
said, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*asked) != 1 || (*asked)[0] != [3]string{"novox/mesh-catalog", "modules/sensors", "main"} {
|
|
t.Fatalf("asked %v", *asked)
|
|
}
|
|
for _, want := range []string{"known and not registered", "no space left on device",
|
|
"build b-modules/sensors of novox/mesh-catalog@main (modules/sensors) is asked for now", "kept as pending"} {
|
|
if !strings.Contains(said, want) {
|
|
t.Fatalf("the answer does not say %q:\n%s", want, said)
|
|
}
|
|
}
|
|
pending := pendingOf(t, open)
|
|
if len(pending) != 1 || pending[0].Build != "b-modules/sensors" {
|
|
t.Fatalf("pending: %+v", pending)
|
|
}
|
|
}
|
|
|
|
// Unknown: refused as before, as no module of that name, with the closest names the mesh holds; build asks
|
|
// for nothing, since the mesh does not know where it is.
|
|
func TestAnUnknownModuleIsRefusedWithTheClosestNames(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
register(t, open, catalogue.Manifest{Module: "sensord", Version: "1"})
|
|
asked := asksWithPaths(t)
|
|
_, err := assignWith(ctx, open, "laptop", assignOptions{Build: true}, "sensors")
|
|
if !errors.Is(err, inventory.ErrNoSuchModule) {
|
|
t.Fatalf("an unknown module: %v", err)
|
|
}
|
|
for _, want := range []string{"no module of that name: sensors", "no module, build request or merge by that name",
|
|
"the closest it holds: sensord", "asks for nothing here"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("the refusal does not say %q:\n%v", want, err)
|
|
}
|
|
}
|
|
if len(*asked) != 0 || len(pendingOf(t, open)) != 0 {
|
|
t.Fatalf("an unknown module asked %v, pending %+v", *asked, pendingOf(t, open))
|
|
}
|
|
}
|
|
|
|
// Several modules in one act, one of them not registered: nothing is assigned and nothing kept pending, and
|
|
// each missing one is said.
|
|
func TestAnActWithAModuleNotRegisteredIsRefusedWhole(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
asksWithPaths(t)
|
|
register(t, open, catalogue.Manifest{Module: "known", Version: "1"})
|
|
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
|
_, err := assign(ctx, open, "laptop", "known", "sensors")
|
|
if err == nil || !strings.Contains(err.Error(), "nothing was assigned") || !strings.Contains(err.Error(), "being built") {
|
|
t.Fatalf("an act naming a module in flight: %v", err)
|
|
}
|
|
if slices.Contains(assignedTo(t, open, "laptop"), "known") || len(pendingOf(t, open)) != 0 {
|
|
t.Fatal("an act refused whole was made in part")
|
|
}
|
|
}
|
|
|
|
// unassign withdraws a pending assignment; the build goes on and registers the module assigned nowhere.
|
|
func TestUnassignWithdrawsAPendingAssignment(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
asksWithPaths(t)
|
|
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
|
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
said, err := unassign(ctx, open, "laptop", "sensors")
|
|
if err != nil || !strings.Contains(said, "withdrawn") {
|
|
t.Fatalf("unassign of a pending assignment: %q %v", said, err)
|
|
}
|
|
if err := (builds{open.inventory, open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors",
|
|
"3da80a4b00aa", "sensors", "")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
|
t.Fatal("a withdrawn assignment was made")
|
|
}
|
|
}
|
|
|
|
// A build that says nothing within the bound: the pending assignment expires at the next look, saying so,
|
|
// rather than waiting for ever; and a build heard by another process meanwhile is made at the next look.
|
|
func TestAPendingAssignmentNeverWaitsSilently(t *testing.T) {
|
|
open := aCatalogueMesh(t)
|
|
ctx := t.Context()
|
|
inv := open.inventory
|
|
for _, r := range []inventory.BuildRequest{
|
|
{ID: "build-lost", Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/lost", Ref: "main",
|
|
For: "merge", At: time.Now().Add(-buildRequestBound / 2)},
|
|
{ID: "build-heard", Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/heard", Ref: "main",
|
|
For: "merge", At: time.Now()},
|
|
} {
|
|
if err := inv.RecordBuildRequest(ctx, r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, m := range []string{"lost", "heard"} {
|
|
if _, err := assign(ctx, open, "laptop", m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// Heard by a process that kept no pending assignment: registered, and nothing made it.
|
|
register(t, open, catalogue.Manifest{Module: "heard", Version: "1"})
|
|
|
|
said := settleStalePending(ctx, open, time.Now().Add(buildRequestBound))
|
|
if len(said) != 2 {
|
|
t.Fatalf("settled %v", said)
|
|
}
|
|
if !slices.Contains(assignedTo(t, open, "laptop"), "heard") {
|
|
t.Fatal("a module registered meanwhile was not assigned at the next look")
|
|
}
|
|
byModule := map[string]inventory.PendingAssignment{}
|
|
for _, p := range pendingOf(t, open) {
|
|
byModule[p.Module] = p
|
|
}
|
|
if p := byModule["lost"]; p.State != inventory.PendingExpired || !strings.Contains(p.Note, "no outcome of build build-lost") {
|
|
t.Fatalf("a build that said nothing: %+v", p)
|
|
}
|
|
if p := byModule["heard"]; p.State != inventory.PendingApplied {
|
|
t.Fatalf("a module registered meanwhile: %+v", p)
|
|
}
|
|
}
|
|
|
|
// The seat's assign passes build on; unassign takes none.
|
|
func TestTheSeatsAssignPassesBuild(t *testing.T) {
|
|
argv, err := argvFor("assign", map[string]any{"node": "g14", "module": "sensors", "build": "true"})
|
|
if err != nil || !slices.Equal(argv, []string{"assign", "g14", "sensors", "--build"}) {
|
|
t.Fatalf("assign with build: %v %v", argv, err)
|
|
}
|
|
if _, err := argvFor("unassign", map[string]any{"node": "g14", "module": "sensors", "build": "true"}); err == nil {
|
|
t.Fatal("unassign took build")
|
|
}
|
|
}
|