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.
132 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|