Every method of builds, build requests, rollouts, merges and assignments reads and writes the chain alone: the else branches that read the tables, the movers and MoveChain with their tests, chain_moved reads, ChainReads and the flags that switched a kind, the store's assignments mirror (KeepAssignmentPlans needs none of it: it uses only the AssignmentMirrored type, mirrors.go, and AssignmentValues, provisioning.go) and its tick, the move code of the controller's start, and ForTest's chain_moved row. The listed store tests of the move go with their functions; the tests of the readers, requests, assignments and plans that lived beside them stay in chain_store_test.go. Tests that replayed old data migrations over the build, assignment and pin tables are removed with the tables. The static test that no plan is written but through SavePlansMoving now reads the plan's chain path.
347 lines
16 KiB
Go
347 lines
16 KiB
Go
//go:build integration
|
|
|
|
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
)
|
|
|
|
// **Once builds are on the bus every reader of them reads the bucket** (issue 1090): a build recorded is the key of its
|
|
// id, recorded again it stays one, and the lists the planner reads are made from the bucket — the table takes none.
|
|
func TestBuildsOnTheBusAreReadBackByEveryReader(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := context.Background()
|
|
first := aBuild("build-1", "notes", "")
|
|
first.Commit = "c1"
|
|
first.Against = []string{"artifact-store://base@sha256:aa"}
|
|
first.ReuseKey = "rk-1"
|
|
second := aBuild("build-2", "notes", "")
|
|
second.Commit = "c2"
|
|
failed := aBuild("build-3", "notes", "compile error")
|
|
other := aBuild("build-4", "wiki", "")
|
|
for _, b := range []Build{first, second, failed, other, first} {
|
|
if err := inv.RecordBuild(ctx, b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
kept, found, err := inv.chain.Builds.Get(ctx, "build-1")
|
|
if err != nil || !found || kept.Value.ReuseKey != "rk-1" {
|
|
t.Fatalf("build-1 on the bus: %+v %v %v", kept.Value, found, err)
|
|
}
|
|
if history, _ := inv.chain.Builds.History(ctx, "build-1"); len(history) != 1 {
|
|
t.Fatalf("a build recorded twice has %d values", len(history))
|
|
}
|
|
list, err := inv.Builds(ctx, "notes", 10)
|
|
if err != nil || len(list) != 3 {
|
|
t.Fatalf("notes has %d builds: %v", len(list), err)
|
|
}
|
|
one, found, err := inv.BuildByID(ctx, "build-2")
|
|
if err != nil || !found || one.Commit != "c2" || one.Module != "notes" {
|
|
t.Fatalf("build-2 reads as %+v %v %v", one, found, err)
|
|
}
|
|
if _, found, _ := inv.BuildByID(ctx, "build-nope"); found {
|
|
t.Fatal("a build nobody recorded was found")
|
|
}
|
|
if id, err := inv.BuildOf(ctx, "notes", "c1"); err != nil || id != "build-1" {
|
|
t.Fatalf("the build of notes at c1 is %q: %v", id, err)
|
|
}
|
|
held, err := inv.Held(ctx)
|
|
if err != nil || held["notes/config"] == "" || held["wiki/config"] == "" {
|
|
t.Fatalf("held %v: %v", held, err)
|
|
}
|
|
against, err := inv.BuiltAgainst(ctx)
|
|
if err != nil || len(against["notes"]) != 0 {
|
|
// The newest successful build of notes, build-2, stood on nothing.
|
|
t.Fatalf("built against %v: %v", against, err)
|
|
}
|
|
announced, err := inv.Announceable(ctx)
|
|
if err != nil || len(announced) != 3 {
|
|
t.Fatalf("announceable %d: %v", len(announced), err)
|
|
}
|
|
prints, err := inv.Fingerprints(ctx)
|
|
if err != nil || prints["notes"]["c1"] == "" || prints["notes"]["c2"] == "" || prints["wiki"]["c0ffeebuild-4"] == "" {
|
|
t.Fatalf("fingerprints %v: %v", prints, err)
|
|
}
|
|
}
|
|
|
|
// A build request row as the store held it before the move.
|
|
|
|
// **A request is the build record in state asked, and its outcome takes its place and keeps it** (ADR 0408 §1, issue
|
|
// 1091): no outcome to read until it comes, the marks of a request that nobody answered kept in the record, and none
|
|
// after the outcome is heard. The table of requests takes none.
|
|
func TestARequestIsTheBuildInStateAskedAndItsOutcomeTakesItsPlace(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := context.Background()
|
|
ask := BuildRequest{ID: "build-1", Repository: "novox/notes", Path: "modules/notes", Ref: "main", For: "build", AtTerminal: true}
|
|
if err := inv.RecordBuildRequest(ctx, ask); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordBuildRequest(ctx, ask); err != nil {
|
|
t.Fatalf("a request kept twice: %v", err)
|
|
}
|
|
if builds, err := inv.Builds(ctx, "", 10); err != nil || len(builds) != 0 {
|
|
t.Fatalf("a request nobody answered is a build: %+v %v", builds, err)
|
|
}
|
|
if _, found, _ := inv.BuildByID(ctx, "build-1"); found {
|
|
t.Fatal("a request nobody answered has an outcome")
|
|
}
|
|
got, found, err := inv.BuildRequestByID(ctx, "build-1")
|
|
if err != nil || !found || got.Repository != "novox/notes" || got.For != "build" || !got.AtTerminal || got.At.IsZero() {
|
|
t.Fatalf("the request reads back as %+v %v %v", got, found, err)
|
|
}
|
|
if err := inv.MarkOutcomeUnknown(ctx, "build-1", "stopped waiting"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
asked, err := inv.RequestsNamed(ctx, "notes")
|
|
if err != nil || len(asked) != 1 || asked[0].Heard || asked[0].OutcomeUnknown != "stopped waiting" {
|
|
t.Fatalf("the request is %+v: %v", asked, err)
|
|
}
|
|
if names, err := inv.RequestedNames(ctx); err != nil || !reflect.DeepEqual(names, []string{"notes"}) {
|
|
t.Fatalf("requested names %v: %v", names, err)
|
|
}
|
|
|
|
// The outcome comes: the same key, now a build, with the request beside it.
|
|
built := aBuild("build-1", "notes", "")
|
|
if err := inv.RecordBuild(ctx, built); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if one, found, err := inv.BuildByID(ctx, "build-1"); err != nil || !found || one.Module != "notes" {
|
|
t.Fatalf("the outcome reads as %+v %v %v", one, found, err)
|
|
}
|
|
answered, err := inv.RequestsNamed(ctx, "notes")
|
|
if err != nil || len(answered) != 1 || !answered[0].Heard || answered[0].Module != "notes" || answered[0].Failed != "" {
|
|
t.Fatalf("the request answered is %+v: %v", answered, err)
|
|
}
|
|
if again, _, _ := inv.BuildRequestByID(ctx, "build-1"); again.For != "build" || again.OutcomeUnknown != "stopped waiting" {
|
|
t.Fatalf("the request was lost with the outcome: %+v", again)
|
|
}
|
|
// Heard: the marks of an unanswered request change nothing.
|
|
if err := inv.MarkNotAsked(ctx, "build-1", "late"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again, _, _ := inv.BuildRequestByID(ctx, "build-1"); again.NotAsked != "" {
|
|
t.Fatalf("an answered request was marked not asked: %+v", again)
|
|
}
|
|
if history, _ := inv.chain.Builds.History(ctx, "build-1"); len(history) != 3 {
|
|
// asked, marked unknown, answered.
|
|
t.Fatalf("the record has %d values", len(history))
|
|
}
|
|
// An outcome heard before its request is kept, and the request lands beside it.
|
|
if err := inv.RecordBuild(ctx, aBuild("build-2", "wiki", "")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RecordBuildRequest(ctx, BuildRequest{ID: "build-2", Repository: "novox/wiki", Path: "modules/wiki", For: "plan"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if late, found, _ := inv.BuildRequestByID(ctx, "build-2"); !found || late.For != "plan" {
|
|
t.Fatalf("the request kept after its outcome reads %+v", late)
|
|
}
|
|
// A request older than the time they are kept is no longer listed.
|
|
if err := inv.RecordBuildRequest(ctx, BuildRequest{ID: "build-old", Repository: "novox/old", Path: "modules/old",
|
|
At: time.Now().Add(-2 * KeptFor)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if names, _ := inv.RequestedNames(ctx); slices.Contains(names, "old") {
|
|
t.Fatalf("a request past its time is listed: %v", names)
|
|
}
|
|
}
|
|
|
|
// **The move carries the build requests**: an answered one beside its outcome, an unanswered one as state asked, and a
|
|
// request never handed over as state asked marked to be asked again once (issue 981 note 7 Q5), which is claimed before it
|
|
// is asked and so asked at most once.
|
|
|
|
// **An assignment is a key of the assignments bucket; an unassignment writes `ended` and keeps the key and its values; a
|
|
// repeat is absorbed** (ADR 0408, issue 1093): the readers of the store — what a machine runs, where a module runs, whether a module
|
|
// may be forgotten, what is behind — read the bucket, the generation rises with each change, and a seat held by the
|
|
// assignment goes with it.
|
|
func TestAnAssignmentIsAKeyOfTheBucketAndAnUnassignmentEndsIt(t *testing.T) {
|
|
inv, node := aNodeWithModules(t, "postgres", "web")
|
|
ctx := t.Context()
|
|
if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
start := generationNow(t, inv)
|
|
if added, err := inv.Assign(ctx, node, "postgres"); err != nil || !added {
|
|
t.Fatalf("assigned %v: %v", added, err)
|
|
}
|
|
if added, err := inv.Assign(ctx, node, "postgres"); err != nil || added {
|
|
t.Fatalf("assigned again %v: %v", added, err)
|
|
}
|
|
kept, found, err := inv.chain.Assignments.Get(ctx, "anchor.postgres")
|
|
if err != nil || !found || kept.Value.Node != "anchor" || kept.Value.Module != "postgres" || kept.Value.Assigned.IsZero() {
|
|
t.Fatalf("the assignment on the bus: %+v %v %v", kept.Value, found, err)
|
|
}
|
|
if generationNow(t, inv) <= start {
|
|
t.Fatal("an assignment left the generation where it was")
|
|
}
|
|
if _, err := inv.Assign(ctx, node, "no-such-module"); !errors.Is(err, ErrNoSuchModule) {
|
|
t.Fatalf("a module the mesh does not know: %v", err)
|
|
}
|
|
if on, err := inv.Assigned(ctx, node); err != nil || !reflect.DeepEqual(on, []string{"postgres"}) {
|
|
t.Fatalf("assigned on %s: %v %v", node, on, err)
|
|
}
|
|
if running, err := inv.Running(ctx, "postgres"); err != nil || !reflect.DeepEqual(running, []string{node}) {
|
|
t.Fatalf("running: %v %v", running, err)
|
|
}
|
|
entries, err := inv.Catalogued(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
switch e.Manifest.Module {
|
|
case "postgres":
|
|
if !reflect.DeepEqual(e.On, []string{node}) {
|
|
t.Errorf("postgres is on %v", e.On)
|
|
}
|
|
case "web":
|
|
if len(e.On) != 0 {
|
|
t.Errorf("web is on %v", e.On)
|
|
}
|
|
}
|
|
}
|
|
if err := inv.ForgetModule(ctx, "postgres"); !errors.Is(err, ErrStillAssigned) {
|
|
t.Fatalf("a module still assigned was forgotten: %v", err)
|
|
}
|
|
// A seat is held by an assignment, or by nothing.
|
|
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, node, "web"); err == nil {
|
|
t.Fatal("a seat was handed to a module that is not assigned")
|
|
}
|
|
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, node, "postgres"); err != nil {
|
|
t.Fatalf("a seat handed to an assignment: %v", err)
|
|
}
|
|
|
|
before := generationNow(t, inv)
|
|
if err := inv.Unassign(ctx, node, "postgres"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if generationNow(t, inv) <= before {
|
|
t.Fatal("an unassignment left the generation where it was")
|
|
}
|
|
// No key is removed (ADR 0408 §4): the value says it ended, and the one before it is in the key's history.
|
|
gone, found, err := inv.chain.Assignments.Get(ctx, "anchor.postgres")
|
|
if err != nil || !found || gone.Value.Ended.IsZero() || gone.Value.Stands() || gone.Value.Assigned.IsZero() {
|
|
t.Fatalf("the unassigned key: %+v %v %v", gone.Value, found, err)
|
|
}
|
|
if history, _ := inv.chain.Assignments.History(ctx, "anchor.postgres"); len(history) != 2 || !history[0].Value.Stands() ||
|
|
history[1].Value.Stands() {
|
|
t.Fatalf("the earlier values of the key: %+v", history)
|
|
}
|
|
if on, _ := inv.Assigned(ctx, node); len(on) != 0 {
|
|
t.Fatalf("an ended assignment is still assigned: %v", on)
|
|
}
|
|
if running, _ := inv.Running(ctx, "postgres"); len(running) != 0 {
|
|
t.Fatalf("an ended assignment is running: %v", running)
|
|
}
|
|
if held, err := inv.Holdings(ctx); err != nil || len(held) != 0 {
|
|
t.Fatalf("the seat the assignment held is held by %+v: %v", held, err)
|
|
}
|
|
if err := inv.Unassign(ctx, node, "postgres"); err == nil || err.Error() != "postgres is not assigned to anchor" {
|
|
t.Fatalf("an unassignment of nothing: %v", err)
|
|
}
|
|
if err := inv.ForgetModule(ctx, "postgres"); err != nil {
|
|
t.Fatalf("a module nothing is assigned could not be forgotten: %v", err)
|
|
}
|
|
// Made again after it ended: a fresh value over the ended one, which stays in history.
|
|
if added, err := inv.Assign(ctx, node, "web"); err != nil || !added {
|
|
t.Fatal(added, err)
|
|
}
|
|
if added, err := inv.Assign(ctx, node, "web"); err != nil || added {
|
|
t.Fatal(added, err)
|
|
}
|
|
if err := inv.Unassign(ctx, node, "web"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if added, err := inv.Assign(ctx, node, "web"); err != nil || !added {
|
|
t.Fatalf("assigned again after it ended: %v %v", added, err)
|
|
}
|
|
again, _, _ := inv.chain.Assignments.Get(ctx, "anchor.web")
|
|
if !again.Value.Stands() || !again.Value.Ended.IsZero() {
|
|
t.Fatalf("the assignment made again is %+v", again.Value)
|
|
}
|
|
if history, _ := inv.chain.Assignments.History(ctx, "anchor.web"); len(history) != 3 || history[1].Value.Stands() {
|
|
t.Fatalf("the values of the key: %+v", history)
|
|
}
|
|
}
|
|
|
|
// **The provisioner's plans are kept in the assignments' values, one field at a time** (issue 1093, design 57 P6): the
|
|
// plan of a module the machine was sent is written into its assignment, the build and commit another write put there stay,
|
|
// a module only pulled in by another has a value of its own that ends when it is no longer pulled in, and nothing here
|
|
// ends an assignment.
|
|
|
|
// **The provisioner's plans are kept in the assignments' values, one field at a time** (issue 1093, design 57 P6): the
|
|
// plan of a module the machine was sent is written into its assignment, the build and commit another write put there stay,
|
|
// a module only pulled in by another has a value of its own that ends when it is no longer pulled in, and nothing here
|
|
// ends an assignment.
|
|
func TestThePlansAreKeptInTheAssignmentsValuesAndNothingElseOfThemMoves(t *testing.T) {
|
|
inv, node := aNodeWithModules(t, "dunst", "web")
|
|
ctx := t.Context()
|
|
for _, m := range []string{"dunst", "web"} {
|
|
if _, err := inv.Assign(ctx, node, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// A build and a commit put in the value by another write.
|
|
cur, _, _ := inv.chain.Assignments.Get(ctx, "anchor.dunst")
|
|
cur.Value.Build, cur.Value.Commit = "build-1", "c1"
|
|
if _, err := inv.chain.Assignments.Update(ctx, "anchor.dunst", cur.Value, cur.Revision); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record, _ := inv.NodeByName(ctx, node)
|
|
digest := "sha256:" + strings.Repeat("d", 64)
|
|
plan := Provisioning{Templates: TemplatesArchive{Source: "http://store:5000/v2/dunst/files/blobs/" + digest,
|
|
Digest: digest}, Files: []ProvisionedFile{{ID: "configuration", Path: "/home/op/.config/dunst/dunstrc",
|
|
Template: "dunstrc", OwnerAtRuntime: "owned", Takes: "reload-on"}}}
|
|
raw := []byte(`{"dunst":` + mustJSON(t, plan) + `,"notify":` + strings.Replace(mustJSON(t, plan), `{`, `{"required-by":"desk",`, 1) + `}`)
|
|
if err := inv.RecordSentWith(ctx, record.ID, "first", nil, 0, 0, Send{Digest: "first", Provisioning: raw}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inv.SetProvisioningPlans(inv.SentProvisioningPlans)
|
|
written, removed, err := inv.KeepAssignmentPlans(ctx)
|
|
if err != nil || written != 2 || removed != 0 {
|
|
t.Fatalf("kept: %d written, %d ended: %v", written, removed, err)
|
|
}
|
|
dunst, _, _ := inv.chain.Assignments.Get(ctx, "anchor.dunst")
|
|
if dunst.Value.Provisioning == nil || dunst.Value.Build != "build-1" || dunst.Value.Commit != "c1" || dunst.Value.Assigned.IsZero() {
|
|
t.Fatalf("dunst's value is %+v", dunst.Value)
|
|
}
|
|
if web, _, _ := inv.chain.Assignments.Get(ctx, "anchor.web"); web.Value.Provisioning != nil {
|
|
t.Fatalf("web has a plan: %+v", web.Value)
|
|
}
|
|
notify, found, _ := inv.chain.Assignments.Get(ctx, "anchor.notify")
|
|
if !found || notify.Value.RequiredBy != "desk" || !notify.Value.Assigned.IsZero() || notify.Value.Provisioning == nil {
|
|
t.Fatalf("the pulled-in module's value is %+v %v", notify.Value, found)
|
|
}
|
|
if on, _ := inv.Assigned(ctx, node); !reflect.DeepEqual(on, []string{"dunst", "web"}) {
|
|
t.Fatalf("a pulled-in module is assigned: %v", on)
|
|
}
|
|
if written, removed, err := inv.KeepAssignmentPlans(ctx); err != nil || written != 0 || removed != 0 {
|
|
t.Fatalf("a second pass: %d written, %d ended: %v", written, removed, err)
|
|
}
|
|
// Sent again without the pulled-in module: its value ends, the assignments stay.
|
|
only := []byte(`{"dunst":` + mustJSON(t, plan) + `}`)
|
|
if err := inv.RecordSentWith(ctx, record.ID, "second", nil, 0, 0, Send{Digest: "second", Provisioning: only}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, removed, err := inv.KeepAssignmentPlans(ctx); err != nil || removed != 1 {
|
|
t.Fatalf("ended %d: %v", removed, err)
|
|
}
|
|
if gone, found, _ := inv.chain.Assignments.Get(ctx, "anchor.notify"); !found || gone.Value.Stands() || gone.Value.Ended.IsZero() {
|
|
t.Fatalf("a module no longer pulled in has the value %+v, which should say it ended", gone.Value)
|
|
}
|
|
if on, _ := inv.Assigned(ctx, node); !reflect.DeepEqual(on, []string{"dunst", "web"}) {
|
|
t.Fatalf("assigned: %v", on)
|
|
}
|
|
}
|
|
|
|
// **The move carries every assignment with what its machine was last sent, and keeps the plan the mirror put in the value**
|
|
// (ADR 0408 §8, issue 1093): the commit, the build of it and what it published, since when; a value that names its build
|
|
// already is left as it is.
|