Files
mesh-controller/internal/inventory/chain_builds_test.go
T
jschoubben 275ddd76e8 Keep rollouts and the merges they answer in the rollouts bucket (issue 1092)
Once chain_moved records the kind, a plan is a key of the rollouts bucket under its id and the open batch is the same key in state assembling. A save refuses a copy another save moved since (the plan's own Revision, kept in its value, counts saves) and keeps the merges the record holds, so a copy read before a merge moved cannot lay it back. The merges a batch or walk answers are the record's held field, with the columns the table had; a merge no plan answers stays, flagged, in the record that held it last, or in the record kept for those. A merge moved to a plan is written to the receiving record first and then taken from the giving one; one found in both is read from the one it entered last and repaired at the next cut. Plans and merges are read from a view kept by one watch of the bucket. A bus that does not answer, a lease lost and a key moved are now not-now like the store being away, so a merge whose plan could not be written is held and handed over again, never acknowledged. The start moves release_plan and batched_merge into the bucket, one kind, counted against both tables.
2026-10-18 03:06:50 +02:00

132 lines
5.5 KiB
Go

package inventory
import (
"context"
"encoding/json"
"fmt"
"reflect"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/chain"
)
// **A build keeps its reuse key and what it published, and comes back whole from the bucket** (novox/hq ADR 0408 §1,
// issue 1090): an artifact by the digest of what it made, a package release by name and version.
func TestABuildKeepsItsReuseKeyAndWhatItPublishedWhenKeptOnTheBus(t *testing.T) {
at := time.Date(2026, 10, 18, 3, 0, 0, 0, time.UTC)
b := Build{ID: "build-1", Repository: "novox/notes", Ref: "main", Module: "notes", Commit: "c1", On: "ace", Path: "modules/notes",
Manifest: []byte(`{"module":"notes"}`), Against: []string{"artifact-store://base@sha256:aa"},
Read: []ReadRepository{{Repository: "novox/other", Ref: "main"}},
Sources: []BuildSource{{Paths: []string{"modules/notes/module.json"}}},
SourceFingerprint: "fp", Asked: at, At: at.Add(time.Minute), ReuseKey: "rk-1",
Made: []Artifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes@sha256:bb"},
{Name: "sdk", Kind: "package", Reference: "mesh-sdk@1.4.0"}}}
kept := toChainBuild(b)
if kept.ReuseKey != "rk-1" {
t.Errorf("the reuse key is %q", kept.ReuseKey)
}
if kept.Published == nil || kept.Published.Artifact != "sha256:bb" || kept.Published.Package == nil ||
kept.Published.Package.Name != "mesh-sdk" || kept.Published.Package.Version != "1.4.0" {
t.Errorf("it published %+v", kept.Published)
}
// Through the bucket's bytes and back: nothing is lost.
raw, err := json.Marshal(kept)
if err != nil {
t.Fatal(err)
}
var back chain.Build
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
got := fromChainBuild(back)
b.Published = kept.Published
if !reflect.DeepEqual(got, b) {
t.Fatalf("the build came back as\n %+v\nnot\n %+v", got, b)
}
}
func TestWhatABuildPublishedIsReadFromWhatItMade(t *testing.T) {
for name, tc := range map[string]struct {
made []Artifact
want *chain.Published
}{
"nothing": {nil, nil},
"an archive with no digest": {[]Artifact{{Name: "config", Kind: "archive", Reference: "…/blobs/sha256:…"}}, nil},
"an image": {[]Artifact{{Name: "a", Kind: "image", Reference: "artifact-store://a@sha256:11"}},
&chain.Published{Artifact: "sha256:11"}},
"the first digest stands": {[]Artifact{{Kind: "image", Reference: "x@sha256:11"}, {Kind: "archive", Reference: "y@sha256:22"}},
&chain.Published{Artifact: "sha256:11"}},
"a package": {[]Artifact{{Name: "sdk", Kind: "package", Reference: "mesh-sdk@2.0.1"}},
&chain.Published{Package: &chain.Package{Name: "mesh-sdk", Version: "2.0.1"}}},
"a package with no version": {[]Artifact{{Name: "sdk", Kind: "package", Reference: "mesh-sdk"}}, nil},
} {
if got := publishedOf(tc.made); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s: published %+v, not %+v", name, got, tc.want)
}
}
}
// **The same content hashes alike, however the record spells it** (issue 280's fingerprints, now over the bucket's bytes
// and not the store's jsonb text): key order and white space of a manifest change nothing.
func TestTheSameBuildHashesAlikeWhateverTheSpellingOfItsRecord(t *testing.T) {
a := Build{Manifest: []byte(`{"module": "notes", "version": "1"}`), Made: []Artifact{{Name: "n", Kind: "image", Reference: "r"}}}
b := Build{Manifest: []byte("{\"version\":\"1\",\n \"module\":\"notes\"}"), Made: []Artifact{{Name: "n", Kind: "image", Reference: "r"}}}
if chainFingerprint(a) != chainFingerprint(b) {
t.Fatal("two spellings of one manifest hash apart")
}
b.Made[0].Reference = "other"
if chainFingerprint(a) == chainFingerprint(b) {
t.Fatal("two artifacts hash alike")
}
}
// **Newest request first, as the store's ordering was**: the newest ask, then the newest record, then the newest id.
func TestBuildsAreOrderedNewestRequestFirst(t *testing.T) {
t0 := time.Date(2026, 10, 18, 3, 0, 0, 0, time.UTC)
builds := []Build{
{ID: "old", Asked: t0, At: t0.Add(time.Hour)},
{ID: "unasked", At: t0.Add(2 * time.Hour)},
{ID: "new", Asked: t0.Add(3 * time.Hour), At: t0.Add(3*time.Hour + time.Minute)},
{ID: "same-a", Asked: t0.Add(4 * time.Hour), At: t0.Add(5 * time.Hour)},
{ID: "same-b", Asked: t0.Add(4 * time.Hour), At: t0.Add(5 * time.Hour)},
}
newestRequestOrder(builds)
var got []string
for _, b := range builds {
got = append(got, b.ID)
}
if want := []string{"same-b", "same-a", "new", "unasked", "old"}; !reflect.DeepEqual(got, want) {
t.Fatalf("ordered %v, not %v", got, want)
}
}
// **A bus that does not answer, a lease lost and a key written first are "not now"** (issue 1092): a merge or a report whose
// write failed so is held and handed over again, as it is for a store that is away.
func TestTheBusBeingAwayIsNotNowLikeTheStoreBeingAway(t *testing.T) {
for name, err := range map[string]error{
"a timeout": fmt.Errorf("the plan is not written: %w", nats.ErrTimeout),
"no responders": fmt.Errorf("build 1: %w", nats.ErrNoResponders),
"a closed bus": nats.ErrConnectionClosed,
"the lease lost": fmt.Errorf("%w: another controller holds it", chain.ErrNotHeld),
"a key moved": chain.ErrMoved,
"a deadline": context.DeadlineExceeded,
} {
if !Unreachable(err) {
t.Errorf("%s is read as an answer", name)
}
}
for name, err := range map[string]error{
"a value too large": chain.ErrTooLarge,
"a key not taken": chain.ErrBadKey,
"a plan moved": ErrPlanMoved,
"a key that exists": chain.ErrExists,
} {
if Unreachable(err) {
t.Errorf("%s is read as a bus being away: asking again gets the same answer", name)
}
}
}