internal/chain holds typed Build, Rollout and Assignment and a Backend with no delete or purge, in memory and on the bus. The builds and rollouts buckets are asserted with history 64, no age and values up to just under a mebibyte; the assignments bucket gets the same, keeping its marker age because a stream cannot be made to disallow message ages. Migration 0104 makes the one-row chain_moved switch. The controller keeps no consolidated schema, so 0104 is the whole schema change. Nothing reads the buckets yet.
313 lines
13 KiB
Go
313 lines
13 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()
|
|
held := true
|
|
builds, rollouts, assignments := NewMemory(), NewMemory(), NewMemory()
|
|
c := New(builds, rollouts, assignments, func() bool { return held })
|
|
rev, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
held = false
|
|
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() bool { return true })
|
|
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)
|
|
}
|
|
}
|