Once chain_moved records the kind, RecordBuild and every build read go through the chain (reuse key and published added); before that the tables are read as before. The serving controller copies the rows at its start, holding the lease: idempotent per key, rows counted against keys, the largest value measured, a refusal (urgent condition, nothing switched) on a count that differs or a value over the bus's largest, and a re-read when the table took a row meanwhile. ForTest attaches the chain in memory with builds moved; ForTestBefore leaves it unmoved. Build requests, gates and the other kinds are still in their tables.
273 lines
11 KiB
Go
273 lines
11 KiB
Go
//go:build integration
|
|
|
|
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/chain"
|
|
)
|
|
|
|
// A build row as the store held it before the move: written by SQL, the only hand-written state these tests have, for
|
|
// it stands for what the mesh held before this release.
|
|
func aBuildRow(t *testing.T, inv *Inventory, id, module, commit, failed string) {
|
|
t.Helper()
|
|
inv.ExecForTest(t,
|
|
`insert into build (id, repository, ref, module, commit_hash, built_on, failed, made, source_path, manifest,
|
|
built_against, built_contexts, asked, source_fingerprint, build_sources)
|
|
values ($1::text, 'novox/' || $2::text, 'main', $2::text, $3::text, 'ace', $4::text,
|
|
case when $4::text = ''
|
|
then ('[{"name":"app","kind":"image","reference":"artifact-store://' || $2::text || '@sha256:' || $1::text || '"}]')::jsonb
|
|
else '[]'::jsonb end,
|
|
'modules/' || $2::text, ('{"module":"' || $2::text || '"}')::jsonb,
|
|
'["artifact-store://base@sha256:aa"]'::jsonb, '[{"repository":"novox/other","ref":"main"}]'::jsonb,
|
|
now() - interval '1 hour', 'fp-' || $1::text,
|
|
('[{"paths":["modules/' || $2::text || '/module.json"]}]')::jsonb)`,
|
|
id, module, commit, failed)
|
|
}
|
|
|
|
func rowsInTheTable(t *testing.T, inv *Inventory, table string) string {
|
|
t.Helper()
|
|
rows := inv.QueryForTest(t, `select count(*) from `+table)
|
|
return rows[0][0]
|
|
}
|
|
|
|
// **The move copies every build row into the builds bucket, and a second run writes nothing** (ADR 0408 §8, issue
|
|
// 1090): each row under its id, whole; the kind recorded in chain_moved; and from then on a build is read from the
|
|
// bucket and written to it, the table taking no more.
|
|
func TestTheMoveCopiesTheBuildRowsAndAgainWritesNothing(t *testing.T) {
|
|
inv, finish := ForTestBefore(t, 105)
|
|
finish()
|
|
ctx := context.Background()
|
|
aBuildRow(t, inv, "build-1", "notes", "c1", "")
|
|
aBuildRow(t, inv, "build-2", "notes", "c2", "")
|
|
aBuildRow(t, inv, "build-3", "wiki", "", "compile error")
|
|
|
|
// Before the move the tables are read, and the bucket is empty.
|
|
if before, err := inv.Builds(ctx, "", 10); err != nil || len(before) != 3 {
|
|
t.Fatalf("before the move the tables hold %d builds: %v", len(before), err)
|
|
}
|
|
if moved, _ := inv.ChainMoved(ctx); len(moved) != 0 {
|
|
t.Fatalf("moved before the move: %v", moved)
|
|
}
|
|
|
|
reports, err := inv.MoveChain(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(reports) != 1 || reports[0].Kind != KindBuilds || reports[0].Rows != 3 || reports[0].Written != 3 || reports[0].Largest == 0 {
|
|
t.Fatalf("the move reported %+v", reports)
|
|
}
|
|
moved, err := inv.ChainMoved(ctx)
|
|
if err != nil || moved[KindBuilds] != 3 {
|
|
t.Fatalf("chain_moved says %v: %v", moved, err)
|
|
}
|
|
kept, found, err := inv.chain.Builds.Get(ctx, "build-2")
|
|
if err != nil || !found {
|
|
t.Fatalf("build-2 is not in the bucket: %v %v", found, err)
|
|
}
|
|
if v := kept.Value; v.Module != "notes" || v.Commit != "c2" || v.Repository != "novox/notes" || v.Path != "modules/notes" ||
|
|
v.SourceFingerprint != "fp-build-2" || len(v.Made) != 1 || len(v.Against) != 1 || len(v.Read) != 1 || len(v.Sources) != 1 ||
|
|
v.Published == nil || v.Published.Artifact != "sha256:build-2" || v.Asked.IsZero() || v.At.IsZero() || len(v.Manifest) == 0 {
|
|
t.Fatalf("build-2 was moved as %+v", v)
|
|
}
|
|
|
|
// From now on the bucket is what is read and written.
|
|
if err := inv.RecordBuild(ctx, aBuild("build-4", "notes", "")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := rowsInTheTable(t, inv, "build"); n != "3" {
|
|
t.Fatalf("the table took a build after the move: %s rows", n)
|
|
}
|
|
after, err := inv.Builds(ctx, "notes", 10)
|
|
if err != nil || len(after) != 3 {
|
|
t.Fatalf("notes has %d builds on the bus: %v", len(after), err)
|
|
}
|
|
|
|
// A second run finds the kind moved and writes nothing: every key still has the one value it was made with.
|
|
again, err := inv.MoveChain(ctx, nil)
|
|
if err != nil || len(again) != 1 || !again[0].Already || again[0].Written != 0 {
|
|
t.Fatalf("the second move %+v: %v", again, err)
|
|
}
|
|
for _, id := range []string{"build-1", "build-2", "build-3", "build-4"} {
|
|
if history, err := inv.chain.Builds.History(ctx, chainBuildKey(id)); err != nil || len(history) != 1 {
|
|
t.Fatalf("%s has %d values: %v", id, len(history), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **A move that stopped half way is finished by the next start, and never lays a row over a newer bucket value** (ADR
|
|
// 0408 §8): the keys that exist are present, the others are written.
|
|
func TestAControllerRestartedMidMoveFinishesItAndOverwritesNothing(t *testing.T) {
|
|
inv, finish := ForTestBefore(t, 105)
|
|
finish()
|
|
ctx := context.Background()
|
|
aBuildRow(t, inv, "build-1", "notes", "c1", "")
|
|
aBuildRow(t, inv, "build-2", "notes", "c2", "")
|
|
aBuildRow(t, inv, "build-3", "wiki", "c3", "")
|
|
// An earlier start wrote build-1, and the bucket has had a newer value since.
|
|
newer := chain.Build{ID: "build-1", Repository: "novox/notes", Module: "notes", Commit: "newer"}
|
|
if _, err := inv.chain.Builds.Create(ctx, "build-1", newer); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reports, err := inv.MoveChain(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reports[0].Written != 2 || reports[0].Present != 1 || reports[0].Rows != 3 {
|
|
t.Fatalf("the move reported %+v", reports[0])
|
|
}
|
|
got, _, _ := inv.chain.Builds.Get(ctx, "build-1")
|
|
if !reflect.DeepEqual(got.Value, newer) {
|
|
t.Fatalf("the newer value was laid over: %+v", got.Value)
|
|
}
|
|
if moved, _ := inv.ChainMoved(ctx); moved[KindBuilds] != 3 {
|
|
t.Fatalf("chain_moved says %v", moved)
|
|
}
|
|
}
|
|
|
|
// **A value over the bus's largest message stops the move with nothing written and nothing switched** (ADR 0408 §6):
|
|
// a Refusal, no key in the bucket, no kind recorded, and the tables still what is read.
|
|
func TestTheMoveRefusesAValueOverTheLargestMessage(t *testing.T) {
|
|
inv, finish := ForTestBefore(t, 105)
|
|
finish()
|
|
ctx := context.Background()
|
|
aBuildRow(t, inv, "build-1", "notes", "c1", "")
|
|
inv.ExecForTest(t, `insert into build (id, repository, module, commit_hash, manifest)
|
|
values ('build-big', 'novox/wiki', 'wiki', 'c9', ('{"x":"' || repeat('a', 1100000) || '"}')::jsonb)`)
|
|
_, err := inv.MoveChain(ctx, nil)
|
|
var refusal *Refusal
|
|
if !errors.As(err, &refusal) || refusal.Kind != KindBuilds {
|
|
t.Fatalf("the move answered %v", err)
|
|
}
|
|
if keys, _ := inv.chain.Builds.Keys(ctx); len(keys) != 0 {
|
|
t.Fatalf("the bucket holds %v after a refused move", keys)
|
|
}
|
|
if moved, _ := inv.ChainMoved(ctx); len(moved) != 0 {
|
|
t.Fatalf("a refused move recorded %v", moved)
|
|
}
|
|
if builds, err := inv.Builds(ctx, "", 10); err != nil || len(builds) != 2 {
|
|
t.Fatalf("the tables are no longer read: %d builds, %v", len(builds), err)
|
|
}
|
|
}
|
|
|
|
// losing is a bucket that says it wrote a key and kept nothing: the move's count of keys against rows must see it.
|
|
type losing struct {
|
|
chain.Backend
|
|
lose string
|
|
}
|
|
|
|
func (l losing) Create(ctx context.Context, key string, value []byte) (uint64, error) {
|
|
if key == l.lose {
|
|
return 1, nil
|
|
}
|
|
return l.Backend.Create(ctx, key, value)
|
|
}
|
|
|
|
// **The move refuses on a count that differs** (ADR 0408 §8): a row whose key is not in the bucket afterwards stops it,
|
|
// nothing is recorded, and the tables are still read.
|
|
func TestTheMoveRefusesOnACountThatDiffers(t *testing.T) {
|
|
inv, finish := ForTestBefore(t, 105)
|
|
finish()
|
|
ctx := context.Background()
|
|
aBuildRow(t, inv, "build-1", "notes", "c1", "")
|
|
aBuildRow(t, inv, "build-2", "notes", "c2", "")
|
|
inv.UseChain(chain.New(losing{chain.NewMemory(), "build-2"}, chain.NewMemory(), chain.NewMemory(),
|
|
func(context.Context) error { return nil }))
|
|
_, err := inv.MoveChain(ctx, nil)
|
|
var refusal *Refusal
|
|
if !errors.As(err, &refusal) {
|
|
t.Fatalf("the move answered %v", err)
|
|
}
|
|
if moved, _ := inv.ChainMoved(ctx); len(moved) != 0 {
|
|
t.Fatalf("a refused move recorded %v", moved)
|
|
}
|
|
}
|
|
|
|
// **Without the lease the move writes nothing**, and fails the way the bus being away does — tried again, not refused:
|
|
// the tables are still read.
|
|
func TestTheMoveWithoutTheLeaseWritesNothing(t *testing.T) {
|
|
inv, finish := ForTestBefore(t, 105)
|
|
finish()
|
|
ctx := context.Background()
|
|
aBuildRow(t, inv, "build-1", "notes", "c1", "")
|
|
mem := chain.NewMemory()
|
|
inv.UseChain(chain.New(mem, chain.NewMemory(), chain.NewMemory(),
|
|
func(context.Context) error { return errors.New("another controller holds the lease") }))
|
|
_, err := inv.MoveChain(ctx, nil)
|
|
var refusal *Refusal
|
|
if err == nil || errors.As(err, &refusal) || !errors.Is(err, chain.ErrNotHeld) {
|
|
t.Fatalf("the move answered %v", err)
|
|
}
|
|
if mem.Writes() != 0 {
|
|
t.Fatalf("the bucket took %d writes without the lease", mem.Writes())
|
|
}
|
|
if moved, _ := inv.ChainMoved(ctx); len(moved) != 0 {
|
|
t.Fatalf("recorded %v", moved)
|
|
}
|
|
}
|
|
|
|
// **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)
|
|
}
|
|
}
|
|
if n := rowsInTheTable(t, inv, "build"); n != "0" {
|
|
t.Fatalf("the table took %s builds", n)
|
|
}
|
|
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)
|
|
}
|
|
}
|