ADR 0408 section 4 says no key of the chain is removed, a delete marker included. The Backend loses Remove and the Chain RemoveAssignment. An unassignment, and the end of a value only pulled in by another, write the value again with ended set, by compare-and-set; assigning it again writes a fresh standing value over it. A value with ended or a state is no assignment (Assignment.Stands) to the assignment lists, the joins, forgetting a module, seat holdings, the given values, the move and the assignment-changed event.
392 lines
16 KiB
Go
392 lines
16 KiB
Go
package chain
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
)
|
|
|
|
func at(h int) time.Time { return time.Date(2026, 10, 18, h, 0, 0, 0, time.UTC) }
|
|
|
|
// **A build is kept whole and read back with its reuse key and what it published** (ADR 0408 §1): its module,
|
|
// repository, commit, reuse key, and an artifact by digest or a package release by name and version.
|
|
func TestABuildIsKeptWholeAndReadBackWithItsReuseKeyAndWhatItPublished(t *testing.T) {
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
artifact := Build{ID: "b1", Repository: "mesh-catalog", Module: "notes", Commit: "c1", ReuseKey: "rk-1",
|
|
Published: &Published{Artifact: "sha256:aa"}, Asked: at(1), At: at(2),
|
|
Made: []Artifact{{Name: "notes", Kind: "image", Reference: "sha256:aa"}}}
|
|
pkg := Build{ID: "b2", Repository: "mesh-sdk", Module: "sdk", Commit: "c2", ReuseKey: "rk-2",
|
|
Published: &Published{Package: &Package{Name: "mesh-sdk", Version: "1.4.0"}}}
|
|
for _, b := range []Build{artifact, pkg} {
|
|
if _, err := c.Builds.Create(ctx, b.ID, b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
got, ok, err := c.Builds.Get(ctx, "b1")
|
|
if err != nil || !ok {
|
|
t.Fatalf("read b1: %v %v", ok, err)
|
|
}
|
|
if got.Value.Module != "notes" || got.Value.Repository != "mesh-catalog" || got.Value.Commit != "c1" ||
|
|
got.Value.ReuseKey != "rk-1" || got.Value.Published.Artifact != "sha256:aa" || len(got.Value.Made) != 1 {
|
|
t.Fatalf("the build came back as %+v", got.Value)
|
|
}
|
|
got, _, _ = c.Builds.Get(ctx, "b2")
|
|
if p := got.Value.Published; p == nil || p.Package == nil || p.Package.Name != "mesh-sdk" || p.Package.Version != "1.4.0" {
|
|
t.Fatalf("the package came back as %+v", p)
|
|
}
|
|
if _, ok, _ := c.Builds.Get(ctx, "nope"); ok {
|
|
t.Fatal("a key nobody wrote holds a build")
|
|
}
|
|
}
|
|
|
|
// **A write against a revision that moved is refused and nothing is laid over it** (ADR 0408 §2).
|
|
func TestAWriteAgainstAMovedRevisionIsRefusedAndNothingIsLaidOver(t *testing.T) {
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
first, err := c.Assignments.Create(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.Assignments.Create(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes"}); !errors.Is(err, ErrExists) {
|
|
t.Fatalf("a create of a key that holds a value answered %v", err)
|
|
}
|
|
second, err := c.Assignments.Update(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c2"}, first)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A writer that read the first revision writes now.
|
|
if _, err := c.Assignments.Update(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "stale"}, first); !errors.Is(err, ErrMoved) {
|
|
t.Fatalf("a write at a moved revision answered %v", err)
|
|
}
|
|
got, _, _ := c.Assignments.Get(ctx, "anchor.notes")
|
|
if got.Value.Commit != "c2" || got.Revision != second {
|
|
t.Fatalf("the key that moved was laid over: %+v at %d", got.Value, got.Revision)
|
|
}
|
|
}
|
|
|
|
// **No key of the chain is ever removed** (ADR 0408 §4): the Backend has no delete and no purge, so nothing that holds
|
|
// one can remove a key; and an assignment of a module taken off its node is kept, with its earlier values.
|
|
func TestNoKeyOfTheChainIsEverRemoved(t *testing.T) {
|
|
var backend Backend = NewMemory()
|
|
if _, ok := backend.(interface {
|
|
Delete(context.Context, string, uint64) error
|
|
}); ok {
|
|
t.Fatal("the backend can delete")
|
|
}
|
|
if _, ok := backend.(interface {
|
|
Purge(context.Context, string) error
|
|
}); ok {
|
|
t.Fatal("the backend can purge")
|
|
}
|
|
if _, ok := any(InMemory(nil).Builds).(interface {
|
|
Delete(context.Context, string) error
|
|
}); ok {
|
|
t.Fatal("a bucket of the chain can delete")
|
|
}
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
rev, _ := c.Assignments.Create(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c1"})
|
|
if _, err := c.Assignments.Update(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Commit: "c1"}, rev); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys, err := c.Assignments.Keys(ctx)
|
|
if err != nil || len(keys) != 1 || keys[0] != "anchor.notes" {
|
|
t.Fatalf("keys %v %v", keys, err)
|
|
}
|
|
history, err := c.Assignments.History(ctx, "anchor.notes")
|
|
if err != nil || len(history) != 2 {
|
|
t.Fatalf("the key keeps %d values: %v", len(history), err)
|
|
}
|
|
}
|
|
|
|
// **Each key keeps up to sixty-four earlier values** (ADR 0408 §5), oldest first.
|
|
func TestAKeyKeepsItsEarlierValuesUpToSixtyFour(t *testing.T) {
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
rev, _ := c.Rollouts.Create(ctx, "r1", Rollout{ID: "r1", State: "n0"})
|
|
for i := 1; i <= 70; i++ {
|
|
var err error
|
|
if rev, err = c.Rollouts.Update(ctx, "r1", Rollout{ID: "r1", State: "n" + string(rune('A'+i%26))}, rev); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
history, err := c.Rollouts.History(ctx, "r1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(history) != broker.ChainHistory {
|
|
t.Fatalf("the key keeps %d values and the bus keeps %d", len(history), broker.ChainHistory)
|
|
}
|
|
for i := 1; i < len(history); i++ {
|
|
if history[i].Revision <= history[i-1].Revision {
|
|
t.Fatal("the history is not oldest first")
|
|
}
|
|
}
|
|
}
|
|
|
|
// **A value over the largest message is refused, not cut, and the key is unchanged** (ADR 0408 §6).
|
|
func TestAValueOverTheLargestMessageIsRefusedNotCut(t *testing.T) {
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
small := Build{ID: "b1", Repository: "r", Commit: "c"}
|
|
rev, err := c.Builds.Create(ctx, "b1", small)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
huge := Build{ID: "b1", Repository: "r", Commit: "c", Manifest: json.RawMessage(`"` + strings.Repeat("x", 1<<20) + `"`)}
|
|
if _, err := c.Builds.Update(ctx, "b1", huge, rev); !errors.Is(err, ErrTooLarge) {
|
|
t.Fatalf("a value of a mebibyte answered %v", err)
|
|
}
|
|
if _, err := c.Builds.Create(ctx, "b2", huge); !errors.Is(err, ErrTooLarge) {
|
|
t.Fatalf("a created value of a mebibyte answered %v", err)
|
|
}
|
|
got, _, _ := c.Builds.Get(ctx, "b1")
|
|
if got.Revision != rev || len(got.Value.Manifest) != 0 {
|
|
t.Fatalf("the key was changed: %+v", got)
|
|
}
|
|
if _, ok, _ := c.Builds.Get(ctx, "b2"); ok {
|
|
t.Fatal("a refused create left a key")
|
|
}
|
|
// The largest that fits is written whole.
|
|
fits := Build{ID: "b3", Manifest: json.RawMessage(`"` + strings.Repeat("x", broker.ChainValueBytes-1024) + `"`)}
|
|
if _, err := c.Builds.Create(ctx, "b3", fits); err != nil {
|
|
t.Fatalf("a value under the largest was refused: %v", err)
|
|
}
|
|
got, _, _ = c.Builds.Get(ctx, "b3")
|
|
if len(got.Value.Manifest) != len(fits.Manifest) {
|
|
t.Fatal("a value under the largest was cut")
|
|
}
|
|
}
|
|
|
|
// **A rollout names every assignment it changed, with the build before and after** (ADR 0408 §1).
|
|
func TestARolloutNamesEveryAssignmentItChangedWithBuildBeforeAndAfter(t *testing.T) {
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
r := Rollout{ID: "r1", State: "rolling", Commits: []Commit{{Repository: "mesh-catalog", Commit: "c2"}},
|
|
Tiers: [][]string{{"notes"}}, Links: Links{Builds: []string{"b2"}, Changed: []Change{
|
|
{Node: "anchor", Module: "notes", Before: Ref{Build: "b1", Commit: "c1"}, After: Ref{Build: "b2", Commit: "c2"}},
|
|
{Node: "g14", Module: "notes", After: Ref{Build: "b2", Commit: "c2"}}}}}
|
|
if _, err := c.Rollouts.Create(ctx, "r1", r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _, _ := c.Rollouts.Get(ctx, "r1")
|
|
if len(got.Value.Changed) != 2 || got.Value.Changed[0].Before.Build != "b1" || got.Value.Changed[0].After.Build != "b2" ||
|
|
got.Value.Changed[1].Before.Build != "" || got.Value.Changed[1].After.Commit != "c2" {
|
|
t.Fatalf("the rollout came back as %+v", got.Value)
|
|
}
|
|
// And in the contract's words: `changed`, with `before` and `after` naming `build` and `commit`.
|
|
raw, _ := json.Marshal(got.Value)
|
|
for _, want := range []string{`"changed":[`, `"before":{"build":"b1","commit":"c1"}`, `"after":{"build":"b2","commit":"c2"}`,
|
|
`"builds":["b2"]`, `"commits":[`, `"tiers":[["notes"]]`} {
|
|
if !strings.Contains(string(raw), want) {
|
|
t.Errorf("the rollout's value lacks %s: %s", want, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **An assignment names its build, artifact or package, commit and rollout**, and keeps the fields its readers read
|
|
// under the names they read them by (ADR 0408 §3).
|
|
func TestAnAssignmentNamesBuildArtifactCommitAndRollout(t *testing.T) {
|
|
a := Assignment{Node: "anchor", Module: "notes", Assigned: at(3), RequiredBy: "wiki",
|
|
Provisioning: json.RawMessage(`{"plan":1}`), Build: "b2", Artifact: "sha256:bb", Commit: "c2", Rollout: "r1", Since: at(4)}
|
|
raw, _ := json.Marshal(a)
|
|
for _, want := range []string{`"node":"anchor"`, `"module":"notes"`, `"assigned":"2026-10-18T03:00:00Z"`,
|
|
`"required-by":"wiki"`, `"provisioning":{"plan":1}`, `"build":"b2"`, `"artifact":"sha256:bb"`, `"commit":"c2"`,
|
|
`"rollout":"r1"`, `"since":"2026-10-18T04:00:00Z"`} {
|
|
if !strings.Contains(string(raw), want) {
|
|
t.Errorf("the assignment's value lacks %s: %s", want, raw)
|
|
}
|
|
}
|
|
if a.Key() != "anchor.notes" {
|
|
t.Errorf("key %s", a.Key())
|
|
}
|
|
// An assignment of today carries none of the new fields, and says none: a reader of today reads the bytes it did.
|
|
raw, _ = json.Marshal(Assignment{Node: "anchor", Module: "notes", Assigned: at(3)})
|
|
if string(raw) != `{"node":"anchor","module":"notes","assigned":"2026-10-18T03:00:00Z"}` {
|
|
t.Errorf("an assignment without a chain is %s", raw)
|
|
}
|
|
// A package in place of an artifact.
|
|
raw, _ = json.Marshal(Assignment{Node: "a", Module: "m", Package: &Package{Name: "n", Version: "1"}})
|
|
if !strings.Contains(string(raw), `"package":{"name":"n","version":"1"}`) {
|
|
t.Errorf("the package is %s", raw)
|
|
}
|
|
}
|
|
|
|
// **The writer without the lease writes nothing** (ADR 0408 §2): it reads, and every create and update is refused with
|
|
// the bucket untouched.
|
|
func TestTheWriterWithoutTheLeaseWritesNothing(t *testing.T) {
|
|
ctx := context.Background()
|
|
var away error
|
|
builds, rollouts, assignments := NewMemory(), NewMemory(), NewMemory()
|
|
c := New(builds, rollouts, assignments, func(context.Context) error { return away })
|
|
rev, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
away = errors.New("another controller holds the lease")
|
|
if _, err := c.Builds.Create(ctx, "b2", Build{ID: "b2"}); !errors.Is(err, ErrNotHeld) {
|
|
t.Fatalf("a create without the lease answered %v", err)
|
|
}
|
|
if _, err := c.Builds.Update(ctx, "b1", Build{ID: "b1", Failed: "x"}, rev); !errors.Is(err, ErrNotHeld) {
|
|
t.Fatalf("an update without the lease answered %v", err)
|
|
}
|
|
if _, err := c.Rollouts.Create(ctx, "r", Rollout{ID: "r"}); !errors.Is(err, ErrNotHeld) {
|
|
t.Fatalf("a rollout written without the lease: %v", err)
|
|
}
|
|
if _, err := c.Assignments.Create(ctx, "a.b", Assignment{}); !errors.Is(err, ErrNotHeld) {
|
|
t.Fatalf("an assignment written without the lease: %v", err)
|
|
}
|
|
if builds.Writes() != 1 || rollouts.Writes() != 0 || assignments.Writes() != 0 {
|
|
t.Fatalf("writes %d %d %d", builds.Writes(), rollouts.Writes(), assignments.Writes())
|
|
}
|
|
// Reads still answer.
|
|
if _, ok, err := c.Builds.Get(ctx, "b1"); err != nil || !ok {
|
|
t.Fatalf("a read without the lease: %v %v", ok, err)
|
|
}
|
|
if all, err := c.Builds.All(ctx); err != nil || len(all) != 1 {
|
|
t.Fatalf("all: %v %v", all, err)
|
|
}
|
|
// And a controller with no lease function at all holds nothing.
|
|
if _, err := New(NewMemory(), NewMemory(), NewMemory(), nil).Builds.Create(ctx, "b", Build{}); !errors.Is(err, ErrNotHeld) {
|
|
t.Fatalf("a chain with no lease wrote: %v", err)
|
|
}
|
|
}
|
|
|
|
// **A bucket that is away is an error, never an empty bucket** (ADR 0227 rule 4).
|
|
func TestABucketThatIsAwayIsAnErrorNotAnEmptyOne(t *testing.T) {
|
|
ctx := context.Background()
|
|
m := NewMemory()
|
|
c := New(m, NewMemory(), NewMemory(), func(context.Context) error { return nil })
|
|
if _, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
away := errors.New("the bus is away")
|
|
m.SetFail(away)
|
|
if all, err := c.Builds.All(ctx); !errors.Is(err, away) || all != nil {
|
|
t.Fatalf("all answered %v %v", all, err)
|
|
}
|
|
if _, _, err := c.Builds.Get(ctx, "b1"); !errors.Is(err, away) {
|
|
t.Fatalf("get answered %v", err)
|
|
}
|
|
}
|
|
|
|
// A watch hands over every key's value, says it has, and then each write.
|
|
func TestAWatchHandsOverTheValuesThenEachWrite(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
c := InMemory(nil)
|
|
if _, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w, err := c.Builds.Watch(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next := func() Entry {
|
|
select {
|
|
case e := <-w:
|
|
return e
|
|
case <-ctx.Done():
|
|
t.Fatal("the watch said nothing")
|
|
return Entry{}
|
|
}
|
|
}
|
|
if e := next(); e.Key != "b1" || e.Caught {
|
|
t.Fatalf("first %+v", e)
|
|
}
|
|
if e := next(); !e.Caught {
|
|
t.Fatalf("second %+v", e)
|
|
}
|
|
if _, err := c.Builds.Create(ctx, "b2", Build{ID: "b2"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e := next(); e.Key != "b2" {
|
|
t.Fatalf("third %+v", e)
|
|
}
|
|
}
|
|
|
|
// **A key the bus does not take is refused, never rewritten into another** (ADR 0408 §2): a write names the key loudly.
|
|
func TestAKeyTheBusTakesNoSpellingOfIsRefused(t *testing.T) {
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
for _, key := range []string{"", "build one", "build*", ".hidden", "trailing.", "a>b"} {
|
|
if _, err := c.Builds.Create(ctx, key, Build{ID: key}); !errors.Is(err, ErrBadKey) {
|
|
t.Errorf("the key %q answered %v", key, err)
|
|
}
|
|
}
|
|
for _, key := range []string{"build-1700000000000000000", "anchor.notes", "a/b_c=d"} {
|
|
if _, err := c.Builds.Create(ctx, key, Build{ID: key}); err != nil {
|
|
t.Errorf("the key %q answered %v", key, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An empty bucket is read as empty, and the read ends.
|
|
func TestAnEmptyBucketIsReadAsEmpty(t *testing.T) {
|
|
all, err := InMemory(nil).Assignments.All(context.Background())
|
|
if err != nil || len(all) != 0 {
|
|
t.Fatalf("%v %v", all, err)
|
|
}
|
|
}
|
|
|
|
// **An assignment that has ended still stands in the bucket as a value that says so, and nothing in the chain can remove a
|
|
// key** (ADR 0408 §4, issue 1093): the value with `ended` is no assignment to a reader, and the earlier value stays in
|
|
// history.
|
|
func TestAnEndedAssignmentIsNoAssignmentAndStaysInTheBucket(t *testing.T) {
|
|
ctx := context.Background()
|
|
c := InMemory(nil)
|
|
rev, err := c.Assignments.Create(ctx, "anchor.notes", Assignment{Node: "anchor", Module: "notes", Assigned: at(1), Commit: "c1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
standing, _, _ := c.Assignments.Get(ctx, "anchor.notes")
|
|
if !standing.Value.Stands() {
|
|
t.Fatal("a value that has not ended does not stand")
|
|
}
|
|
ended := standing.Value
|
|
ended.Ended = at(2)
|
|
if _, err := c.Assignments.Update(ctx, "anchor.notes", ended, rev); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, found, _ := c.Assignments.Get(ctx, "anchor.notes")
|
|
if !found || got.Value.Stands() || got.Value.Commit != "c1" || got.Value.Ended.IsZero() {
|
|
t.Fatalf("the ended value is %+v (found %v)", got.Value, found)
|
|
}
|
|
if history, _ := c.Assignments.History(ctx, "anchor.notes"); len(history) != 2 || !history[0].Value.Stands() {
|
|
t.Fatalf("the earlier values are %+v", history)
|
|
}
|
|
if (Assignment{Node: "a", Module: "m", Assigned: at(1), State: "pending"}).Stands() {
|
|
t.Fatal("a pending value stands")
|
|
}
|
|
raw, _ := json.Marshal(ended)
|
|
if !strings.Contains(string(raw), `"ended":"2026-10-18T02:00:00Z"`) {
|
|
t.Fatalf("the ended value is %s", raw)
|
|
}
|
|
// And a value that stands says nothing of either, so a reader of today reads the bytes it did.
|
|
raw, _ = json.Marshal(Assignment{Node: "a", Module: "m", Assigned: at(1)})
|
|
if strings.Contains(string(raw), "ended") || strings.Contains(string(raw), "state") {
|
|
t.Fatalf("a standing value says %s", raw)
|
|
}
|
|
// No bucket, and no chain, has a removal.
|
|
type remover interface {
|
|
Remove(context.Context, string, uint64) error
|
|
}
|
|
for name, x := range map[string]any{"builds": c.Builds, "rollouts": c.Rollouts, "assignments": c.Assignments, "chain": c,
|
|
"a backend": NewMemory()} {
|
|
if _, has := x.(remover); has {
|
|
t.Errorf("%s can remove a key", name)
|
|
}
|
|
if _, has := x.(interface {
|
|
RemoveAssignment(context.Context, string, uint64) error
|
|
}); has {
|
|
t.Errorf("%s can remove an assignment", name)
|
|
}
|
|
}
|
|
}
|