216 lines
8.7 KiB
Go
216 lines
8.7 KiB
Go
package main
|
|
|
|
// The provisioner makes every plan from the provisions the node-engine's published compositions carry (novox/hq ADR
|
|
// 0593 §6, §8), with the plans and provisions it wrote when it composed them itself.
|
|
//
|
|
// The fixtures were captured on 2026-11-02 from the holder as it stood before (frombus.PlansHeldTwice over mesh-sdk's
|
|
// frombustest.Provisioned mesh: restic on n1 requiring postgres-database from postgres there, the vault on n2):
|
|
// testdata/<case>today-composed.json is what that composition answered at the node's wants and at its newest builds,
|
|
// testdata/<case>today-states.golden.json what the holder then wrote. "ahead-" has restic wanted at an older build
|
|
// that requires nothing, so its key is ahead of its want and carries its plan entry (issue 1126 review 1).
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
engineclient "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
|
catalogclient "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// today is a captured composition: the node, the builds its wants named, and the plans at them and at the newest.
|
|
type today struct {
|
|
Node string `json:"node"`
|
|
KeptAt map[string]string `json:"kept-at"`
|
|
AtWants struct {
|
|
Plans map[string]catalogue.Plan `json:"Plans"`
|
|
Held map[string]string `json:"Held"`
|
|
} `json:"at-wants"`
|
|
Newest struct {
|
|
Plans map[string]catalogue.Plan `json:"Plans"`
|
|
} `json:"newest"`
|
|
}
|
|
|
|
// fpsOf is frombustest's modules and the fingerprints they are built at (frombustest.FPs).
|
|
var fpsOf = map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
|
"store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
|
|
|
|
// published puts on the store what n1's engine publishes for the captured case: each placed module at its newest done
|
|
// build, and each wanted one at its want, each key carrying its plan.
|
|
func published(t *testing.T, s *memStore, c today) {
|
|
t.Helper()
|
|
put := func(module, fp string, plan catalogue.Plan, has bool) {
|
|
v := map[string]any{"v": 1, "node": c.Node, "module": module, "fp": fp, "resources": []any{}}
|
|
if has {
|
|
v["plan"] = plan
|
|
}
|
|
raw, _ := json.Marshal(v)
|
|
s.write(t, ComposedRead, mustKey(composedKey(c.Node, module, fp)), string(raw))
|
|
}
|
|
for module, fp := range fpsOf {
|
|
plan, has := c.Newest.Plans[module]
|
|
put(module, fp, plan, has)
|
|
}
|
|
for module, fp := range c.KeptAt {
|
|
plan, has := c.AtWants.Plans[module]
|
|
put(module, fp, plan, has)
|
|
}
|
|
s.write(t, ComposedRead, mustKey(engineclient.HaveNodeKey(c.Node)), `{"v":1,"node":"`+c.Node+`","resources":[]}`)
|
|
}
|
|
|
|
// goldenMesh is the store as the provisioner reads it for frombustest's mesh: the indexer's modules, n1's placement, the
|
|
// vault's on n2, restic's want.
|
|
func goldenMesh(t *testing.T, c today) *memStore {
|
|
t.Helper()
|
|
s := newStore()
|
|
for module, fp := range fpsOf {
|
|
// A provider's manifest says whether it keeps entries: postgres's, as frombustest has it, keeps none.
|
|
raw := `{"module":"` + module + `","version":"1"}`
|
|
if module == "postgres" {
|
|
raw = `{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
|
|
`"serves":{"postgres-database":{"port":5432}}}`
|
|
}
|
|
digest := catalogclient.ManifestDigest([]byte(raw))
|
|
value, _ := catalogclient.EncodeManifest(catalog.ManifestsByDigestValue{Module: module, Manifest: raw})
|
|
s.write(t, ManifestsRead, mustKey(catalogclient.ManifestsKey(digest)), string(value))
|
|
s.write(t, ModulesRead, module, `{"v":1,"fp2":"`+fp+`","manifest":"`+digest+`"}`)
|
|
}
|
|
s.write(t, PlacementRead, "placement."+c.Node, `{"v":1,"node":"`+c.Node+`","decided":{},"modules":[{"module":"store"},`+
|
|
`{"module":"postgres"},{"module":"mesh-provisioner-client"},{"module":"restic"}]}`)
|
|
s.write(t, PlacementRead, "placement.n2", `{"v":1,"node":"n2","decided":{},"modules":[{"module":"mesh-vault"}]}`)
|
|
s.write(t, WantsRead, "want."+c.Node+".restic", `{"v":1,"fp":"`+c.KeptAt["restic"]+`","self":true}`)
|
|
return s
|
|
}
|
|
|
|
// The plans and provisions made from the published compositions are today's, value for value, with a key ahead of its
|
|
// want carrying its plan entry; a pass over the same compositions writes nothing.
|
|
func TestThePlansFromCompositionsAreTodays(t *testing.T) {
|
|
for _, name := range []string{"", "ahead-"} {
|
|
t.Run("case "+name, func(t *testing.T) {
|
|
var c today
|
|
readJSON(t, "testdata/"+name+"today-composed.json", &c)
|
|
var golden map[string]map[string]json.RawMessage
|
|
readJSON(t, "testdata/"+name+"today-states.golden.json", &golden)
|
|
s := goldenMesh(t, c)
|
|
published(t, s, c)
|
|
now := time.Date(2026, 11, 2, 18, 0, 0, 0, time.UTC)
|
|
h := &Holder{Store: s, Asks: &router{}, Logf: t.Logf, Now: func() time.Time { return now }}
|
|
h.Compose = FromCompositions(s)
|
|
out, err := h.ComposeNode(context.Background(), c.Node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out.Problems)+len(out.Held) > 0 {
|
|
t.Errorf("problems %v, held %v", out.Problems, out.Held)
|
|
}
|
|
for state, want := range golden {
|
|
keys, _ := s.Keys(state)
|
|
if len(keys) != len(want) {
|
|
t.Errorf("%s holds %v, today %d keys", state, keys, len(want))
|
|
}
|
|
for key, raw := range want {
|
|
e, _ := s.Get(state, key)
|
|
if e == nil || !sameBytes(t, e.Value, raw) {
|
|
t.Errorf("%s %s:\n got %s\nwant %s", state, key, valueOf(e), raw)
|
|
}
|
|
}
|
|
}
|
|
again, err := h.ComposeNode(context.Background(), c.Node)
|
|
if err != nil || len(again.Plans)+len(again.Gone)+len(again.Changed) > 0 {
|
|
t.Errorf("a second pass over the same compositions wrote %+v (%v)", again, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A composition the engine refused at the wanted build holds its module, its last plan kept and its words in `refused`;
|
|
// one the engine has not published yet holds it too, saying so; a module whose newest build is refused keeps its
|
|
// plan at its want.
|
|
func TestARefusedOrMissingCompositionHoldsItsModule(t *testing.T) {
|
|
var c today
|
|
readJSON(t, "testdata/today-composed.json", &c)
|
|
s := goldenMesh(t, c)
|
|
published(t, s, c)
|
|
h := &Holder{Store: s, Asks: &router{}, Logf: t.Logf, Now: time.Now}
|
|
h.Compose = FromCompositions(s)
|
|
if _, err := h.ComposeNode(context.Background(), c.Node); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan, _ := s.Get(PlansState, "n1.restic")
|
|
key := mustKey(composedKey("n1", "restic", fpsOf["restic"]))
|
|
s.write(t, ComposedRead, key, `{"v":1,"node":"n1","module":"restic","fp":"`+fpsOf["restic"]+`","resources":[],`+
|
|
`"refused":"restic requires postgres-database and no module offers it"}`)
|
|
out, err := h.ComposeNode(context.Background(), c.Node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out.Held["restic"], "no module offers it") {
|
|
t.Errorf("the refused module is held as %v", out.Held)
|
|
}
|
|
if e, _ := s.Get(PlansState, "n1.restic"); e == nil || string(e.Value) != string(plan.Value) {
|
|
t.Errorf("the held module's plan was not kept: %s", valueOf(e))
|
|
}
|
|
if e, _ := s.Get(RefusedState, "n1.restic"); e == nil || !strings.Contains(string(e.Value), "no module offers it") {
|
|
t.Errorf("refused says %s", valueOf(e))
|
|
}
|
|
if p, _ := s.Get(ProvisionsState, "n1.restic.postgres-database"); p == nil || strings.Contains(string(p.Value), "retiring") {
|
|
t.Errorf("the held module's key: %s", valueOf(p))
|
|
}
|
|
// Not published yet: the want moved to a build the engine has not composed.
|
|
other := "src2:" + strings.Repeat("9", 64)
|
|
s.write(t, WantsRead, "want.n1.restic", `{"v":1,"fp":"`+other+`","self":true}`)
|
|
out, err = h.ComposeNode(context.Background(), c.Node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out.Held["restic"], "not published") {
|
|
t.Errorf("the unpublished composition holds restic as %v", out.Held)
|
|
}
|
|
if e, _ := s.Get(PlansState, "n1.restic"); e == nil || string(e.Value) != string(plan.Value) {
|
|
t.Errorf("the plan was not kept while unpublished: %s", valueOf(e))
|
|
}
|
|
}
|
|
|
|
func readJSON(t *testing.T, path string, v any) {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(raw, v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func sameBytes(t *testing.T, a, b []byte) bool {
|
|
t.Helper()
|
|
var x, y any
|
|
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
|
return false
|
|
}
|
|
ra, _ := json.Marshal(x)
|
|
rb, _ := json.Marshal(y)
|
|
return string(ra) == string(rb)
|
|
}
|
|
|
|
func valueOf(e *Entry) string {
|
|
if e == nil {
|
|
return "(none)"
|
|
}
|
|
return string(e.Value)
|
|
}
|
|
|
|
// composedKey is a module's key in the node-engine's composed state, from the fingerprint the test names it by.
|
|
func composedKey(node, module, fp string) (string, error) {
|
|
part, err := catalogclient.KeyPart(fp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return engineclient.ComposedKey(node, module, part)
|
|
}
|