//go:build integration package inventory import ( "context" "errors" "reflect" "slices" "strings" "testing" "time" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/chain" ) // **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, chain.BuildKey("notes", "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, chain.BuildKey("notes", "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, chain.RequestKey("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) } } 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) } // A build and a commit put in the value by another write, after the send: a send that carries no builds (a hand send, as this // one) forgets what each assignment of its machine runs (issue 1109), so they are written once it is recorded. 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) } 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.