//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" "github.com/novox/mesh-controller/internal/link" ) // **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 < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); 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) } digest := "sha256:" + strings.Repeat("a", 64) result := outcome(buildID, "modules/notes", "c-notes", "notes", "") result.ReuseKey = "rk-notes" result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}} if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil { t.Fatal(err) } // 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.Artifact != digest || a.Since.IsZero() || a.Assigned.IsZero() || !a.Stands() { t.Fatalf("the assignment names %+v, not commit c-notes, build %s, artifact %s, rollout %s", a, buildID, digest, 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, with what it published and its reuse key. 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) } lately, err := inv.Builds(ctx, "notes", 1) if err != nil || len(lately) != 1 || lately[0].ReuseKey != "rk-notes" || lately[0].Published == nil || lately[0].Published.Artifact != digest { t.Fatalf("what the build published: %+v %v", lately, 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 `.` 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") } }