Merge branch 'b7m/registry' into b7/registry

This commit is contained in:
2026-11-05 16:30:38 +01:00
52 changed files with 1013 additions and 610 deletions
@@ -17,8 +17,10 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/sdk/trunk"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
@@ -26,6 +28,9 @@ import (
"mesh-delivery-forge-bridge/internal/groups"
)
// gateMergesState is the gate's state merges, which the tools read (reads: mesh-delivery-gate.merges in the manifest).
const gateMergesState = "mesh-delivery-gate.merges"
// Tool names.
const (
GroupsTool = "bridge_groups"
@@ -125,18 +130,18 @@ func MergedOf(raws map[string][]byte, repository, merge string, trees bridge.Tre
return nil, fmt.Errorf("%q is not owner/name", repository)
}
for _, key := range slices.Sorted(maps.Keys(raws)) {
m, err := delivery.DecodeMerge(raws[key])
m, err := meshdeliveryclient.DecodeMerge(raws[key])
if err != nil {
continue
}
at := slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool {
return delivery.SameRepository(p.Repository, repository) && p.Commit == merge
at := slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool {
return trunk.SameRepository(p.Repository, repository) && p.Commit == merge
})
if at < 0 {
continue
}
p := m.Pulls[at]
msg, err := bridge.MergedMessage(owner, repo, p.Number, m.Group, p.Head.Head, merge, trees, files)
msg, err := bridge.MergedMessage(owner, repo, int(p.Number), m.Group, p.Head, merge, trees, files)
if err != nil {
return nil, fmt.Errorf("the merged message of %s at %s cannot be built: %w", repository, merge, err)
}
@@ -144,7 +149,7 @@ func MergedOf(raws map[string][]byte, repository, merge string, trees bridge.Tre
if err != nil {
return nil, err
}
if _, approved := m.Approved[delivery.ApprovedKey(f.Repository, f.Number)]; approved && len(m.Approved) >= 2 {
if _, approved := m.Approved[meshdeliveryclient.ApprovedKey(f.Repository, f.Number)]; approved && len(m.Approved) >= 2 {
f.Group, f.Members = m.Group, groups.MembersOf(m)
}
out, err := forgemsg.EncodeForge(f)
@@ -253,7 +258,7 @@ func askCaller(_ context.Context, key string, body any) (json.RawMessage, error)
}
func main() {
merges, performed, asks := stdio.State(delivery.GateModule+".merges"), stdio.State(groups.PerformedState), stdio.State(groups.EndAsksState)
merges, performed, asks := stdio.State(gateMergesState), stdio.State(groups.PerformedState), stdio.State(groups.EndAsksState)
tools := Tools(askEnd(asks, 25*time.Second), time.Now,
func() ([]groups.Row, []string, error) { return read(merges, performed, asks) },
func(repository, merge string) (json.RawMessage, error) {
@@ -8,8 +8,9 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-forge-bridge/internal/bridge"
@@ -22,19 +23,19 @@ import (
func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
enc := func(group string, state2 delivery.MergeState, c string) []byte {
v, err := delivery.EncodeMerge(delivery.Merge{Group: group, Ask: "ask", Approved: map[string]string{"novox/a@1": h1, "novox/b@2": h2},
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 1, Head: h1}, State: delivery.MergeMerged, Commit: c1},
{Head: delivery.Head{Repository: "novox/b", Number: 2, Head: h2}, State: state2, Commit: c}}})
enc := func(group string, state2 meshdeliveryclient.MergeState, c string) []byte {
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: group, Ask: "ask", Approved: map[string]string{"novox/a@1": h1, "novox/b@2": h2},
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 1, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1},
{Repository: "novox/b", Number: 2, Head: h2, State: string(state2), Commit: c}}})
if err != nil {
t.Fatal(err)
}
return v
}
lone, _ := delivery.EncodeMerge(delivery.Merge{Group: "solo", Ask: "ask", Approved: map[string]string{"novox/a@5": h1},
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 5, Head: h1}, State: delivery.MergeMerged, Commit: c1}}})
merges := map[string][]byte{"ended": enc("ended", delivery.MergeMerged, c2), "ready": enc("ready", delivery.MergeMerged, c2),
"gathered": enc("gathered", delivery.MergeMerging, ""), "solo": lone, "bad": []byte("{")}
lone, _ := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "solo", Ask: "ask", Approved: map[string]string{"novox/a@5": h1},
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 5, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1}}})
merges := map[string][]byte{"ended": enc("ended", meshdeliveryclient.MergeMerged, c2), "ready": enc("ready", meshdeliveryclient.MergeMerged, c2),
"gathered": enc("gathered", meshdeliveryclient.MergeMerging, ""), "solo": lone, "bad": []byte("{")}
performed := map[string]bool{}
for _, c := range []string{c1, c2} {
performed[groups.PublishedKey("novox/a", 1, c)], performed[groups.PublishedKey("novox/b", 2, c)] = true, true
@@ -98,7 +99,7 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
if a := all["ready"].LastEndAsk; a == nil || a.By != "lead@shanks" || a.Why != "lost" || !a.Written || a.Answer == "" || all["ended"].LastEndAsk != nil {
t.Errorf("ready's last end ask = %+v, ended's %+v: want ready's ask and its answer, none for ended", a, all["ended"].LastEndAsk)
}
if r := all["gathered"]; !r.Open || !strings.Contains(r.Words, "gathered") || r.Members[1].State != string(delivery.MergeMerging) {
if r := all["gathered"]; !r.Open || !strings.Contains(r.Words, "gathered") || r.Members[1].State != string(meshdeliveryclient.MergeMerging) {
t.Errorf("gathered = %+v", r)
}
if open := rowsOf(map[string]any{"open": true}); len(open) != 2 || open["ended"].Group != "" {
@@ -146,11 +147,11 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
}
return f
}
lone, _ := delivery.EncodeMerge(delivery.Merge{Group: "solo", Ask: "a", Approved: map[string]string{"novox/a@5": h1},
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 5, Head: h1}, State: delivery.MergeMerged, Commit: c1}}})
group, _ := delivery.EncodeMerge(delivery.Merge{Group: "feat/g", Ask: "a", Approved: map[string]string{"novox/a@6": h1, "novox/b@7": h2},
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 6, Head: h1}, State: delivery.MergeMerged, Commit: c1},
{Head: delivery.Head{Repository: "novox/b", Number: 7, Head: h2}, State: delivery.MergeMerging}}})
lone, _ := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "solo", Ask: "a", Approved: map[string]string{"novox/a@5": h1},
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 5, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1}}})
group, _ := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "feat/g", Ask: "a", Approved: map[string]string{"novox/a@6": h1, "novox/b@7": h2},
Pulls: []seat.MergesByGroupValuePullsItem{{Repository: "novox/a", Number: 6, Head: h1, State: string(meshdeliveryclient.MergeMerged), Commit: c1},
{Repository: "novox/b", Number: 7, Head: h2, State: string(meshdeliveryclient.MergeMerging)}}})
for _, c := range []struct {
name string
raws map[string][]byte
@@ -162,7 +163,7 @@ func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
{name: "a lone pull request", raws: map[string][]byte{"solo": lone}, repository: "novox/a", merge: c1, want: published("a", 5, h1, "solo", c1)},
{name: "a member of a group", raws: map[string][]byte{"feat~g": group}, repository: "novox/a", merge: c1, want: func() forgemsg.Forge {
f := published("a", 6, h1, "feat/g", c1)
f.Group, f.Members = "feat/g", []delivery.Head{{Repository: "novox/a", Number: 6, Head: h1}, {Repository: "novox/b", Number: 7, Head: h2}}
f.Group, f.Members = "feat/g", []meshdeliveryclient.Head{{Repository: "novox/a", Number: 6, Head: h1}, {Repository: "novox/b", Number: 7, Head: h2}}
return f
}()},
{name: "a merge the gate does not hold", raws: map[string][]byte{"solo": lone, "bad": []byte("{")}, repository: "novox/a", merge: c2, fail: "hold no merge"},
@@ -9,15 +9,13 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-forge-bridge/internal/bridge"
)
// performedBucket is the module's state bucket performed, `<module>_<name>` as heads is: for each key of the gate's
// buckets the bridge has acted on, the revision it acted at (manifest state entry performed, novox/hq ADR 0437 §8).
// Its keys are `owed.<owed key>` and `merges.<merges key>`; its value is the revision in decimal.
const performedBucket = delivery.ForgeBridgeModule + "_performed"
const performedBucket = forgeBridgeModule + "_performed"
// kvRevisions is the bucket performed as the performer's revisions.
type kvRevisions struct{ kv jetstream.KeyValue }
@@ -9,8 +9,9 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const (
@@ -52,13 +53,13 @@ func newPerformer(f *forgeFake) (*performer, *fakePub, memRevisions, *bytes.Buff
}
// The values are the SDK's own encoding (mesh-sdk go/delivery EncodeOwed, EncodeMerge), the keys its OwedKey.
func owedKV(t *testing.T, repository string, number int, act string, o delivery.Owed) (string, []byte) {
func owedKV(t *testing.T, repository string, number int, act string, o seat.OwedByRepositoryNumberHeadActValue) (string, []byte) {
t.Helper()
key, err := delivery.OwedKey(repository, number, headA, act)
key, err := meshdeliveryclient.OwedKey(repository, number, headA, act)
if err != nil {
t.Fatal(err)
}
v, err := delivery.EncodeOwed(o)
v, err := meshdeliveryclient.EncodeOwed(o)
if err != nil {
t.Fatal(err)
}
@@ -66,10 +67,11 @@ func owedKV(t *testing.T, repository string, number int, act string, o delivery.
}
func TestAnOwedStatusIsSetOnceForARevision(t *testing.T) { // 1341/AC1
t.Run("the names are the manifest's", theNamesAreTheManifests)
f := &forgeFake{}
p, _, _, _ := newPerformer(f)
key, v := owedKV(t, "novox/mesh-delivery", 12, "status=mesh/merge-gate",
delivery.Owed{Act: delivery.OwedStatus, Context: "mesh/merge-gate", State: "success", Text: "all green", Target: "https://forge.example/p/12"})
seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: "mesh/merge-gate", State: "success", Text: "all green", Target: "https://forge.example/p/12"})
p.owed(context.Background(), key, 7, v)
if len(f.calls) != 1 || f.calls[0].key != "gitea.gitea_commit_status" ||
f.calls[0].body != `{"context":"mesh/merge-gate","description":"all green","owner":"novox","repo":"mesh-delivery","sha":"`+headA+`","state":"success","target_url":"https://forge.example/p/12"}` {
@@ -90,11 +92,11 @@ func TestViewNoteAndCommentEachCallTheirToolOnce(t *testing.T) { // 1341/AC2
p, _, _, _ := newPerformer(f)
for _, c := range []struct {
act string
o delivery.Owed
o seat.OwedByRepositoryNumberHeadActValue
}{
{"view", delivery.Owed{Act: delivery.OwedView, Text: "the view"}},
{"note", delivery.Owed{Act: delivery.OwedNote, Text: "a line", Ref: "mesh-plan"}},
{"comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "hello"}},
{"view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "the view"}},
{"note", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedNote, Text: "a line", Ref: "mesh-plan"}},
{"comment.1", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "hello"}},
} {
key, v := owedKV(t, "novox/mesh-delivery", 12, c.act, c.o)
p.owed(context.Background(), key, 1, v)
@@ -112,7 +114,7 @@ func TestViewNoteAndCommentEachCallTheirToolOnce(t *testing.T) { // 1341/AC2
func TestARefusedActIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1341/AC3
f := &forgeFake{refuse: map[string]string{"gitea.gitea_add_comment": "the pull request is locked"}}
p, pub, _, _ := newPerformer(f)
key, v := owedKV(t, "novox/mesh-delivery", 12, "comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "hello"})
key, v := owedKV(t, "novox/mesh-delivery", 12, "comment.1", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "hello"})
p.owed(context.Background(), key, 3, v)
p.owed(context.Background(), key, 3, v)
if len(f.calls) != 1 {
@@ -147,28 +149,28 @@ func TestAnOwedValueThatCannotBeReadIsLoggedAndNotRetried(t *testing.T) {
}
}
func mergeKV(t *testing.T, m delivery.Merge) []byte {
func mergeKV(t *testing.T, m seat.MergesByGroupValue) []byte {
t.Helper()
v, err := delivery.EncodeMerge(m)
v, err := meshdeliveryclient.EncodeMerge(m)
if err != nil {
t.Fatal(err)
}
return v
}
func twoMembers(a, b delivery.MergeState) delivery.Merge {
return delivery.Merge{Group: "g", Ask: "merge.g.0123456789ab",
Approved: map[string]string{delivery.ApprovedKey("novox/mesh-sdk", 4): headB, delivery.ApprovedKey("novox/mesh-delivery", 12): headA},
Pulls: []delivery.MergePull{
{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, State: a, Commit: map[bool]string{true: "c1"}[a == delivery.MergeMerged]},
{Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 4, Head: headB}, State: b, Commit: map[bool]string{true: "c2"}[b == delivery.MergeMerged]},
func twoMembers(a, b meshdeliveryclient.MergeState) seat.MergesByGroupValue {
return seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab",
Approved: map[string]string{meshdeliveryclient.ApprovedKey("novox/mesh-sdk", 4): headB, meshdeliveryclient.ApprovedKey("novox/mesh-delivery", 12): headA},
Pulls: []seat.MergesByGroupValuePullsItem{
{Repository: "novox/mesh-delivery", Number: 12, Head: headA, State: string(a), Commit: map[bool]string{true: "c1"}[a == meshdeliveryclient.MergeMerged]},
{Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: string(b), Commit: map[bool]string{true: "c2"}[b == meshdeliveryclient.MergeMerged]},
}}
}
func TestAMergeRevisionMergesOnlyTheMemberHanded(t *testing.T) { // 1342/AC1
f := &forgeFake{}
p, _, _, _ := newPerformer(f)
m := twoMembers(delivery.MergeMerging, delivery.MergeMerged)
m := twoMembers(meshdeliveryclient.MergeMerging, meshdeliveryclient.MergeMerged)
p.merges(context.Background(), "g", 5, mergeKV(t, m))
want := `{"approved_heads":["novox/mesh-delivery@` + headA + `","novox/mesh-sdk@` + headB + `"],"head":"` + headA + `","number":12,"owner":"novox","repo":"mesh-delivery"}`
if len(f.calls) != 1 || f.calls[0].key != "gitea.gitea_merge_pull_request" || f.calls[0].body != want {
@@ -183,7 +185,7 @@ func TestAMergeRevisionMergesOnlyTheMemberHanded(t *testing.T) { // 1342/AC1
func TestARefusedMergeIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1342/AC2
f := &forgeFake{refuse: map[string]string{"gitea.gitea_merge_pull_request": "novox/mesh-delivery#12 is not merged: its head moved"}}
p, pub, _, _ := newPerformer(f)
v := mergeKV(t, twoMembers(delivery.MergeMerging, delivery.MergeMerged))
v := mergeKV(t, twoMembers(meshdeliveryclient.MergeMerging, meshdeliveryclient.MergeMerged))
p.merges(context.Background(), "g", 5, v)
p.merges(context.Background(), "g", 5, v)
if len(f.calls) != 1 || len(pub.data) != 1 {
@@ -204,7 +206,7 @@ func TestARefusedMergeIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1342/
func TestAMergeWithTwoHandedStopsAtTheFirstRefusal(t *testing.T) { // ADR 0441 §3
f := &forgeFake{refuse: map[string]string{"gitea.gitea_merge_pull_request": "no"}}
p, pub, _, _ := newPerformer(f)
p.merges(context.Background(), "g", 1, mergeKV(t, twoMembers(delivery.MergeMerging, delivery.MergeMerging)))
p.merges(context.Background(), "g", 1, mergeKV(t, twoMembers(meshdeliveryclient.MergeMerging, meshdeliveryclient.MergeMerging)))
if len(f.calls) != 1 || len(pub.data) != 1 {
t.Errorf("calls %d messages %d, want the first refusal to stop the pass", len(f.calls), len(pub.data))
}
@@ -8,8 +8,10 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/trunk"
groupkeys "mesh-delivery-forge-bridge/internal/groups"
)
@@ -57,29 +59,29 @@ func kvMerges(kv jetstream.KeyValue) mergesRead {
// groupOf is the gate's merge of the group a merged message's branch names, when the message's pull request is one of
// its approved members and the group has more than one: a lone pull request is no group.
func (g *grouper) groupOf(ctx context.Context, f forgemsg.Forge) (delivery.Merge, uint64, bool) {
func (g *grouper) groupOf(ctx context.Context, f forgemsg.Forge) (seat.MergesByGroupValue, uint64, bool) {
if g == nil || g.merges == nil || f.Branch == "" {
return delivery.Merge{}, 0, false
return seat.MergesByGroupValue{}, 0, false
}
key, err := delivery.MergesKey(f.Branch)
key, err := meshdeliveryclient.MergesKey(f.Branch)
if err != nil {
return delivery.Merge{}, 0, false
return seat.MergesByGroupValue{}, 0, false
}
value, rev, found, err := g.merges(ctx, key)
if err != nil {
g.logger.Printf("%s#%d: the gate's merges.%s cannot be read, so its merged message names no group: %v", f.Repository, f.Number, key, err)
return delivery.Merge{}, 0, false
return seat.MergesByGroupValue{}, 0, false
}
if !found {
return delivery.Merge{}, 0, false
return seat.MergesByGroupValue{}, 0, false
}
m, err := delivery.DecodeMerge(value)
m, err := meshdeliveryclient.DecodeMerge(value)
if err != nil {
g.logger.Printf("%s#%d: the gate's merges.%s is not a merge, so its merged message names no group: %v", f.Repository, f.Number, key, err)
return delivery.Merge{}, 0, false
return seat.MergesByGroupValue{}, 0, false
}
if _, ok := m.Approved[delivery.ApprovedKey(f.Repository, f.Number)]; !ok || len(m.Approved) < 2 {
return delivery.Merge{}, 0, false
if _, ok := m.Approved[meshdeliveryclient.ApprovedKey(f.Repository, f.Number)]; !ok || len(m.Approved) < 2 {
return seat.MergesByGroupValue{}, 0, false
}
return m, rev, true
}
@@ -110,7 +112,7 @@ func (g *grouper) published(ctx context.Context, f forgemsg.Forge) {
g.logger.Printf("%s#%d: that its merged message is written is not kept, so the group's end waits for the next act: %v", f.Repository, f.Number, err)
return
}
key, err := delivery.MergesKey(f.Branch)
key, err := meshdeliveryclient.MergesKey(f.Branch)
if err != nil {
return
}
@@ -130,7 +132,7 @@ func (g *grouper) ended(ctx context.Context, key string, rev uint64, value []byt
}
func (g *grouper) end(ctx context.Context, key string, rev uint64, value []byte, again string) bool {
m, err := delivery.DecodeMerge(value)
m, err := meshdeliveryclient.DecodeMerge(value)
if err != nil || len(m.Approved) < 2 {
return false
}
@@ -141,8 +143,8 @@ func (g *grouper) end(ctx context.Context, key string, rev uint64, value []byte,
}
end := forgemsg.ForgeGroupEnd{Group: m.Group}
for _, member := range groupkeys.MembersOf(m) {
at := slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool {
return delivery.SameRepository(p.Repository, member.Repository) && p.Number == member.Number
at := slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool {
return trunk.SameRepository(p.Repository, member.Repository) && int(p.Number) == member.Number
})
if at < 0 || m.Pulls[at].Commit == "" {
return false // not every member is merged (a merge commit is written only for a merged pull): the group stays gathered
@@ -154,8 +156,8 @@ func (g *grouper) end(ctx context.Context, key string, rev uint64, value []byte,
}
// The members in the order the gate merged them, so the last commit names the end.
slices.SortStableFunc(end.Members, func(a, b forgemsg.GroupMerge) int {
return slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool { return p.Commit == a.Merge }) -
slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool { return p.Commit == b.Merge })
return slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool { return p.Commit == a.Merge }) -
slices.IndexFunc(m.Pulls, func(p seat.MergesByGroupValuePullsItem) bool { return p.Commit == b.Merge })
})
data, err := forgemsg.EncodeForgeGroupEnd(end)
if err != nil {
@@ -8,8 +8,9 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
groupkeys "mesh-delivery-forge-bridge/internal/groups"
)
@@ -29,19 +30,19 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { // 1585/T16, T1
ctx := context.Background()
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
merge := func(states ...delivery.MergePull) []byte {
v, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/take-in", Ask: "ask", Pulls: states,
merge := func(states ...seat.MergesByGroupValuePullsItem) []byte {
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "feat/take-in", Ask: "ask", Pulls: states,
Approved: map[string]string{"novox/mesh-sdk@37": h1, "novox/mesh-delivery@9": h2}})
if err != nil {
t.Fatal(err)
}
return v
}
sdk := func(state delivery.MergeState, commit, words string) delivery.MergePull {
return delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 37, Head: h1}, State: state, Commit: commit, Words: words}
sdk := func(state meshdeliveryclient.MergeState, commit, words string) seat.MergesByGroupValuePullsItem {
return seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-sdk", Number: 37, Head: h1, State: string(state), Commit: commit, Words: words}
}
dlv := func(state delivery.MergeState, commit string) delivery.MergePull {
return delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 9, Head: h2}, State: state, Commit: commit}
dlv := func(state meshdeliveryclient.MergeState, commit string) seat.MergesByGroupValuePullsItem {
return seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-delivery", Number: 9, Head: h2, State: string(state), Commit: commit}
}
gate := map[string][]byte{}
var logs bytes.Buffer
@@ -63,17 +64,17 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { // 1585/T16, T1
t.Errorf("a lone pull request names the group %q", f.Group)
}
// The first member: its message names the group and both members; the group is not ended.
gate["feat/take-in"] = merge(sdk(delivery.MergeMerged, c1, ""), dlv(delivery.MergeMerging, ""))
gate["feat/take-in"] = merge(sdk(meshdeliveryclient.MergeMerged, c1, ""), dlv(meshdeliveryclient.MergeMerging, ""))
data, f, ok := g.member(ctx, mergedOf("novox/mesh-sdk", 37, h1, c1))
if back, err := forgemsg.DecodeForge(data); !ok || err != nil || back.Group != "feat/take-in" || len(back.Members) != 2 {
t.Fatalf("a member's message: %+v %v", back, err)
}
g.published(ctx, f)
// A refusal of the other member is no end (ADR 0441 §3: the group merges on from it later).
gate["feat/take-in"] = merge(sdk(delivery.MergeMerged, c1, ""), delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 9, Head: h2}, State: delivery.MergeRefused, Words: "405"})
gate["feat/take-in"] = merge(sdk(meshdeliveryclient.MergeMerged, c1, ""), seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-delivery", Number: 9, Head: h2, State: string(meshdeliveryclient.MergeRefused), Words: "405"})
g.ended(ctx, "feat/take-in", 8, gate["feat/take-in"])
// Merged on at the gate before its merged message is written: still no end; then its message ends the group.
gate["feat/take-in"] = merge(sdk(delivery.MergeMerged, c1, ""), dlv(delivery.MergeMerged, c2))
gate["feat/take-in"] = merge(sdk(meshdeliveryclient.MergeMerged, c1, ""), dlv(meshdeliveryclient.MergeMerged, c2))
g.ended(ctx, "feat/take-in", 9, gate["feat/take-in"])
if len(pub.subjects) != 0 {
t.Fatalf("an end before every member's merged message is written: %v", pub.subjects)
@@ -97,12 +98,12 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { // 1585/T16, T1
}
// 1585/T19: an ask to say a group's end again is answered by the bridge from the gate's merges, with the ask's time on
// the message id; not written while a member is not merged, with the reason; not for a group the gate does not hold.
pull := func(repo string, n int, head string, state delivery.MergeState, commit string) delivery.MergePull {
return delivery.MergePull{Head: delivery.Head{Repository: repo, Number: n, Head: head}, State: state, Commit: commit}
pull := func(repo string, n int, head string, state meshdeliveryclient.MergeState, commit string) seat.MergesByGroupValuePullsItem {
return seat.MergesByGroupValuePullsItem{Repository: repo, Number: int64(n), Head: head, State: string(state), Commit: commit}
}
askMerge := func(second delivery.MergeState, commit2 string) []byte {
v, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/take-in", Ask: "ask", Approved: map[string]string{"novox/mesh-sdk@37": h1, "novox/mesh-delivery@9": h2},
Pulls: []delivery.MergePull{pull("novox/mesh-sdk", 37, h1, delivery.MergeMerged, c1), pull("novox/mesh-delivery", 9, h2, second, commit2)}})
askMerge := func(second meshdeliveryclient.MergeState, commit2 string) []byte {
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "feat/take-in", Ask: "ask", Approved: map[string]string{"novox/mesh-sdk@37": h1, "novox/mesh-delivery@9": h2},
Pulls: []seat.MergesByGroupValuePullsItem{pull("novox/mesh-sdk", 37, h1, meshdeliveryclient.MergeMerged, c1), pull("novox/mesh-delivery", 9, h2, second, commit2)}})
if err != nil {
t.Fatal(err)
}
@@ -116,9 +117,9 @@ func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { // 1585/T16, T1
written bool
answer string
}{
{"merged and published: written again", map[string][]byte{"feat/take-in": askMerge(delivery.MergeMerged, c2)}, true, true, "written again"},
{"a member's message not written", map[string][]byte{"feat/take-in": askMerge(delivery.MergeMerged, c2)}, false, false, "not written"},
{"a member not merged", map[string][]byte{"feat/take-in": askMerge(delivery.MergeMerging, "")}, true, false, "not written"},
{"merged and published: written again", map[string][]byte{"feat/take-in": askMerge(meshdeliveryclient.MergeMerged, c2)}, true, true, "written again"},
{"a member's message not written", map[string][]byte{"feat/take-in": askMerge(meshdeliveryclient.MergeMerged, c2)}, false, false, "not written"},
{"a member not merged", map[string][]byte{"feat/take-in": askMerge(meshdeliveryclient.MergeMerging, "")}, true, false, "not written"},
{"no merge at the gate", map[string][]byte{}, true, false, "holds no merge"},
} {
pub, done := &idPub{}, memRevisions{}
@@ -5,13 +5,11 @@ import (
"errors"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// headsBucket is the module's state bucket heads, `<module>_<name>` as the indexer's buckets are named: the head last
// written on FORGE for each pull request (manifest state entry heads, novox/hq ADR 0443 §5).
const headsBucket = delivery.ForgeBridgeModule + "_heads"
const headsBucket = forgeBridgeModule + "_heads"
// kvHeads is the bucket heads as the bridge's headStore.
type kvHeads struct{ kv jetstream.KeyValue }
@@ -20,8 +20,10 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/process"
)
@@ -232,8 +234,8 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
t.Fatal(err)
}
// Its other state bucket, and the gate's buckets the bridge reads, made by the controller from the gate's manifest
// (mesh-sdk go/delivery MergesBucket, OwedBucket with their histories).
for name, history := range map[string]uint8{performedBucket: 1, inputsBucket: 1, delivery.MergesBucket: delivery.MergesHistory, delivery.OwedBucket: delivery.OwedHistory} {
// (the gate's merges and owed with their histories).
for name, history := range map[string]uint8{performedBucket: 1, inputsBucket: 1, mergesBucket: 10, owedBucket: 5} {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil {
t.Fatal(err)
}
@@ -334,7 +336,7 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
if err != nil {
t.Fatal(err)
}
want := forgemsg.Forge{V: delivery.Version, Kind: "pull", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
want := forgemsg.Forge{V: keys.Version, Kind: "pull", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
Action: "updated", Dirs: []string{"modules/a"}, DirsKnown: true, Manifests: map[string]string{"modules/a": "{}"}}
if !forgemsg.SameForge(f, want) {
t.Errorf("message = %+v, want %+v", f, want)
@@ -465,7 +467,7 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
if err != nil {
t.Fatal(err)
}
want := forgemsg.Forge{V: delivery.Version, Kind: "merged", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
want := forgemsg.Forge{V: keys.Version, Kind: "merged", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
Merge: merge, Root: "r0", Trees: map[string]string{"": "t0", "modules/a": "t1"},
Manifests: map[string]string{"": string(manifest), "modules/a": string(manifest)},
// No mesh package: only the indexer computes what a fingerprint takes (ADR 0593 §2).
@@ -690,15 +692,15 @@ func TestBridgeMergesAGroupOneMemberAtATime(t *testing.T) { // 1342/AC3
t.Fatal(err)
}
kv, err := b.js.KeyValue(b.ctx, delivery.MergesBucket)
kv, err := b.js.KeyValue(b.ctx, mergesBucket)
if err != nil {
t.Fatal(err)
}
approved := map[string]string{delivery.ApprovedKey("novox/mesh-delivery", 12): head, delivery.ApprovedKey("novox/mesh-sdk", 4): headB}
pullA := delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: head}, State: delivery.MergeMerging}
pullB := delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 4, Head: headB}, State: delivery.MergeMerging}
put := func(pulls ...delivery.MergePull) {
v, err := delivery.EncodeMerge(delivery.Merge{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: pulls})
approved := map[string]string{meshdeliveryclient.ApprovedKey("novox/mesh-delivery", 12): head, meshdeliveryclient.ApprovedKey("novox/mesh-sdk", 4): headB}
pullA := seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-delivery", Number: 12, Head: head, State: string(meshdeliveryclient.MergeMerging)}
pullB := seat.MergesByGroupValuePullsItem{Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: string(meshdeliveryclient.MergeMerging)}
put := func(pulls ...seat.MergesByGroupValuePullsItem) {
v, err := meshdeliveryclient.EncodeMerge(seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: pulls})
if err != nil {
t.Fatal(err)
}
@@ -726,7 +728,7 @@ func TestBridgeMergesAGroupOneMemberAtATime(t *testing.T) { // 1342/AC3
// The gate hands the next member only after the bridge reported the one before merged (ADR 0441 §5).
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && len(forgeMerged(t, b)) < 1; time.Sleep(50 * time.Millisecond) {
}
pullA.State, pullA.Commit = delivery.MergeMerged, mergeA
pullA.State, pullA.Commit = string(meshdeliveryclient.MergeMerged), mergeA
put(pullA, pullB)
next("mesh-sdk")
var got []string
@@ -786,15 +788,15 @@ func TestBridgePerformsNoMeshCheckAct(t *testing.T) { // R2-1, ADR 0521
if err := b.ctl.Flush(); err != nil {
t.Fatal(err)
}
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: delivery.CheckerOwedBucket, History: delivery.CheckerOwedHistory})
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: "mesh-delivery-checker_owed", History: 5})
if err != nil {
t.Fatal(err)
}
key, err := delivery.CheckerOwedKey("novox/mesh-delivery", 12, head)
key, err := meshdeliveryclient.OwedKey("novox/mesh-delivery", 12, head, "status=mesh/check")
if err != nil {
t.Fatal(err)
}
v, err := delivery.EncodeCheckerOwed(delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.0123456789ab"})
v, err := meshdeliveryclient.EncodeOwed(seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.0123456789ab"})
if err != nil {
t.Fatal(err)
}
@@ -10,8 +10,6 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-forge-bridge/internal/bridge"
)
@@ -19,7 +17,7 @@ import (
// directory, its manifest and lock-file hashes, by the directory's git tree hash, so a merge reads the forge only for the
// directories it changed (issue 1126; internal/bridge kept.go). A tree hash names its bytes, so a value is never stale;
// a merge adds a value only for each directory it changed (a few kilobytes each).
const inputsBucket = delivery.ForgeBridgeModule + "_inputs"
const inputsBucket = forgeBridgeModule + "_inputs"
// keptTimeout bounds one read or write of the bucket: the bucket is a saving, never a reason to fail a merge.
const keptTimeout = 2 * time.Second
@@ -1,7 +1,7 @@
// Command mesh-delivery-forge-bridge is delivery's forge bridge (novox/hq ADR 0437 §4, issue 1235): it hears the forge
// module's pull request events and writes each as a pull message on its stream FORGE. It runs beside today's
// mesh-delivery and merges nothing. It serves no request subject and holds no timer: every reaction is an event
// (ADR 0437 §8); a refused act is tried again only on the next event touching its delivery.
// (ADR 0437 §8); a refused act is tried again only on the next event touching its meshdeliveryclient.
package main
import (
@@ -22,7 +22,6 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-forge-bridge/internal/bridge"
groupkeys "mesh-delivery-forge-bridge/internal/groups"
@@ -35,7 +34,7 @@ const eventsStream = "EVENTS"
// The bridge's own stream FORGE takes `mesh.stream.<module>.event.<event>` (mesh-control internal/broker
// modstreams.go ModuleStreamSubject).
func forgeSubject(event string) string {
return process.ModuleStreamSubject(delivery.ForgeBridgeModule, event)
return process.ModuleStreamSubject(forgeBridgeModule, event)
}
func main() {
@@ -158,7 +157,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
for _, w := range []struct {
bucket string
react func(ctx context.Context, key string, rev uint64, value []byte)
}{{delivery.OwedBucket, perf.owed}, {delivery.MergesBucket, func(ctx context.Context, key string, rev uint64, value []byte) {
}{{owedBucket, perf.owed}, {mergesBucket, func(ctx context.Context, key string, rev uint64, value []byte) {
perf.merges(ctx, key, rev, value)
groups.ended(ctx, key, rev, value)
}}} {
@@ -168,7 +167,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if kv == nil {
continue
}
if w.bucket == delivery.MergesBucket {
if w.bucket == mergesBucket {
groups.merges = kvMerges(kv)
}
go func() {
@@ -0,0 +1,16 @@
package main
import groupkeys "mesh-delivery-forge-bridge/internal/groups"
// The holders and buckets the forge bridge names, taken from its own manifest (module.json: its name, its `reads`, its
// states); a unit row checks them against it (TestTheNamesAreTheManifests). No contract names a holder
// (novox/hq ADR 0625 §4, ADR 0634 §1).
const (
forgeBridgeModule = groupkeys.Module
gateModule = "mesh-delivery-gate"
// mergesBucket and owedBucket are the gate's states merges and owed, read by the bridge (reads: mesh-delivery-gate.merges,
// mesh-delivery-gate.owed).
mergesBucket = gateModule + "_merges"
owedBucket = gateModule + "_owed"
)
@@ -0,0 +1,44 @@
package main
import (
"encoding/json"
"os"
"slices"
"testing"
)
// theNamesAreTheManifests (a subtest of TestAnOwedStatusIsSetOnceForARevision): the holders and buckets the bridge names
// are its manifest's (its module, its `reads`, its states), since no contract names a holder (novox/hq ADR 0634 §1).
func theNamesAreTheManifests(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Module string `json:"module"`
Reads []string `json:"reads"`
State []struct {
Name string `json:"name"`
} `json:"state"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m.Module != forgeBridgeModule {
t.Errorf("the manifest's module is %q, the bridge names itself %q", m.Module, forgeBridgeModule)
}
for _, read := range []string{gateModule + ".merges", gateModule + ".owed"} {
if !slices.Contains(m.Reads, read) {
t.Errorf("the bridge reads %s, its manifest's reads are %v", read, m.Reads)
}
}
var own []string
for _, s := range m.State {
own = append(own, forgeBridgeModule+"_"+s.Name)
}
for _, b := range []string{headsBucket, performedBucket, inputsBucket} {
if !slices.Contains(own, b) {
t.Errorf("the bridge keeps %s, its manifest's states are %v", b, own)
}
}
}
@@ -3,6 +3,9 @@ module mesh-delivery-forge-bridge
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
@@ -5,8 +5,9 @@ import (
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Call is a call of a forge tool: the tool by its manifest key and its arguments.
@@ -26,7 +27,7 @@ type Act struct {
Call Call
}
// The forge's tools, by manifest key; their arguments are the ones of mesh-catalog modules/gitea: delivery.ts
// The forge's tools, by manifest key; their arguments are the ones of mesh-catalog modules/gitea: meshdeliveryclient.ts
// commitStatusTool (owner, repo, sha, context, state, description, target_url), tools/index.ts gitea_delivery_view
// (owner, repo, number, body), gitea_note_append (owner, repo, sha, ref, line), gitea_add_comment (owner, repo,
// number, body), merge.ts gitea_merge_pull_request (owner, repo, number, head, approved_heads).
@@ -50,15 +51,15 @@ func ownerRepo(repository string) (owner, repo string) {
// it. A key that is no owed key, a value that is no owed act, a value whose act is not the key's, and a view or a comment
// on no pull request are errors, each saying why.
func OwedAct(key string, value []byte) (Act, error) {
repository, number, head, name, err := delivery.ParseOwedKey(key)
repository, number, head, name, err := meshdeliveryclient.ParseOwedKey(key)
if err != nil {
return Act{}, err
}
o, err := delivery.DecodeOwed(value)
o, err := meshdeliveryclient.DecodeOwed(value)
if err != nil {
return Act{}, fmt.Errorf("the owed act %s: %w", key, err)
}
kind, context, err := delivery.ParseOwedAct(name)
kind, context, err := meshdeliveryclient.ParseOwedAct(name)
if err != nil {
return Act{}, err
}
@@ -74,26 +75,26 @@ func OwedAct(key string, value []byte) (Act, error) {
return nil
}
switch kind {
case delivery.OwedStatus:
case meshdeliveryclient.OwedStatus:
if o.Context != context {
return Act{}, fmt.Errorf("the owed status %s says the context %q, not the %q its key names", key, o.Context, context)
}
if o.Context == delivery.StatusContextCheck {
if o.Context == meshdeliveryclient.StatusContextCheck {
// No tool writes mesh/check (R4e finding K1), and the bridge performs no act of it: the forge module writes it from
// the checker's state mesh-delivery-checker.owed, read by a watch (ADR 0521, finding R2-1).
return Act{}, fmt.Errorf("the owed status %s is %s, which the bridge does not perform: the forge module writes it from mesh-delivery-checker.owed",
key, delivery.StatusContextCheck)
key, meshdeliveryclient.StatusContextCheck)
}
a.Call = Call{Tool: commitStatusTool, Args: map[string]any{"owner": owner, "repo": repo, "sha": head, "context": o.Context,
"state": o.State, "description": o.Text, "target_url": o.Target}}
case delivery.OwedView:
case meshdeliveryclient.OwedView:
if err := needsPull(); err != nil {
return Act{}, err
}
a.Call = Call{Tool: deliveryViewTool, Args: map[string]any{"owner": owner, "repo": repo, "number": number, "body": o.Text}}
case delivery.OwedNote:
case meshdeliveryclient.OwedNote:
a.Call = Call{Tool: noteAppendTool, Args: map[string]any{"owner": owner, "repo": repo, "sha": head, "ref": o.Ref, "line": o.Text}}
case delivery.OwedComment:
case meshdeliveryclient.OwedComment:
if err := needsPull(); err != nil {
return Act{}, err
}
@@ -102,19 +103,19 @@ func OwedAct(key string, value []byte) (Act, error) {
return a, nil
}
// MergeActs turns the value of a merges key (mesh-sdk go/delivery EncodeMerge) into the merges to perform: one for each
// MergeActs turns the value of a merges key (the delivery client's EncodeMerge) into the merges to perform: one for each
// pull request in the state merging, in the order the value names them, at the head it expects and with the approved
// heads of every member of the group, `<owner>/<repo>@<full sha>` sorted (the merge tool's approved_heads). A pull
// request merged or refused already gets none. A value with no approved heads is an error: the forge's merge tool
// needs them for a group.
func MergeActs(value []byte) ([]Act, error) {
m, err := delivery.DecodeMerge(value)
m, err := meshdeliveryclient.DecodeMerge(value)
if err != nil {
return nil, err
}
var handed []delivery.MergePull
var handed []seat.MergesByGroupValuePullsItem
for _, p := range m.Pulls {
if p.State == delivery.MergeMerging {
if meshdeliveryclient.MergeState(p.State) == meshdeliveryclient.MergeMerging {
handed = append(handed, p)
}
}
@@ -136,8 +137,8 @@ func MergeActs(value []byte) ([]Act, error) {
var acts []Act
for _, p := range handed {
owner, repo := ownerRepo(p.Repository)
acts = append(acts, Act{Name: MergeAct, Repository: p.Repository, Number: p.Number, Head: p.Head.Head,
Call: Call{Tool: mergePullTool, Args: map[string]any{"owner": owner, "repo": repo, "number": p.Number, "head": p.Head.Head,
acts = append(acts, Act{Name: MergeAct, Repository: p.Repository, Number: int(p.Number), Head: p.Head,
Call: Call{Tool: mergePullTool, Args: map[string]any{"owner": owner, "repo": repo, "number": int(p.Number), "head": p.Head,
"approved_heads": approved}}})
}
return acts, nil
@@ -5,8 +5,10 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/keys"
)
const (
@@ -15,13 +17,13 @@ const (
)
// owedValue is an owed value as the SDK writes it (mesh-sdk go/delivery EncodeOwed), and its key as OwedKey spells it.
func owedValue(t *testing.T, repository string, number int, act string, o delivery.Owed) (string, []byte) {
func owedValue(t *testing.T, repository string, number int, act string, o seat.OwedByRepositoryNumberHeadActValue) (string, []byte) {
t.Helper()
key, err := delivery.OwedKey(repository, number, headA, act)
key, err := meshdeliveryclient.OwedKey(repository, number, headA, act)
if err != nil {
t.Fatal(err)
}
b, err := delivery.EncodeOwed(o)
b, err := meshdeliveryclient.EncodeOwed(o)
if err != nil {
t.Fatal(err)
}
@@ -30,7 +32,7 @@ func owedValue(t *testing.T, repository string, number int, act string, o delive
func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1, K1
key, v := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/merge-gate",
delivery.Owed{Act: delivery.OwedStatus, Context: "mesh/merge-gate", State: "pending", Text: "waiting", Target: "https://forge.example/novox/mesh-delivery/pulls/12"})
seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: "mesh/merge-gate", State: "pending", Text: "waiting", Target: "https://forge.example/novox/mesh-delivery/pulls/12"})
a, err := OwedAct(key, v)
if err != nil {
t.Fatal(err)
@@ -46,26 +48,26 @@ func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1, K1
// R4e findings K1 and R2-1 (issue 1301 S4; ADR 0503 §3, ADR 0521): no tool writes mesh/check, since a tool is told no
// caller and node-tools invokes every tool; the forge module writes it from the checker's owed state, and the bridge
// performs no mesh/check act.
ck, cv := checkerKV(t, delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.1"})
ck, cv := checkerKV(t, seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.1"})
if a, err := OwedAct(ck, cv); err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
t.Fatalf("OwedAct(mesh/check) = %+v, %v; want refused, naming where the forge module reads it", a, err)
}
// Nor through the gate's own owed bucket: the context is refused whichever key carries it.
gk, gv := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/check",
delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "success", Text: "pass", Target: "https://x.example"})
seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "success", Text: "pass", Target: "https://x.example"})
if a, err := OwedAct(gk, gv); err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
t.Fatalf("OwedAct(gate's mesh/check) = %+v, %v; want refused", a, err)
}
}
// checkerKV is the status the checker owes, as it writes it (mesh-sdk go/delivery CheckerOwedKey, EncodeCheckerOwed).
func checkerKV(t *testing.T, o delivery.Owed) (string, []byte) {
// checkerKV is the status the checker owes, as it writes it (the gate's owed key with the act status=mesh/check).
func checkerKV(t *testing.T, o seat.OwedByRepositoryNumberHeadActValue) (string, []byte) {
t.Helper()
key, err := delivery.CheckerOwedKey("novox/mesh-delivery", 12, headA)
key, err := meshdeliveryclient.OwedKey("novox/mesh-delivery", 12, headA, "status=mesh/check")
if err != nil {
t.Fatal(err)
}
v, err := delivery.EncodeCheckerOwed(o)
v, err := meshdeliveryclient.EncodeOwed(o)
if err != nil {
t.Fatal(err)
}
@@ -75,14 +77,14 @@ func checkerKV(t *testing.T, o delivery.Owed) (string, []byte) {
func TestOwedViewNoteAndCommentCalls(t *testing.T) { // 1341/AC2
cases := []struct {
act string
o delivery.Owed
o seat.OwedByRepositoryNumberHeadActValue
want Call
}{
{"view", delivery.Owed{Act: delivery.OwedView, Text: "the view"},
{"view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "the view"},
Call{Tool: "gitea.gitea_delivery_view", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "body": "the view"}}},
{"note", delivery.Owed{Act: delivery.OwedNote, Text: "a line", Ref: "mesh-plan"},
{"note", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedNote, Text: "a line", Ref: "mesh-plan"},
Call{Tool: "gitea.gitea_note_append", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "sha": headA, "ref": "mesh-plan", "line": "a line"}}},
{"comment.2", delivery.Owed{Act: delivery.OwedComment, Text: "hello"},
{"comment.2", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "hello"},
Call{Tool: "gitea.gitea_add_comment", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "body": "hello"}}},
}
for _, c := range cases {
@@ -98,11 +100,11 @@ func TestOwedViewNoteAndCommentCalls(t *testing.T) { // 1341/AC2
}
func TestOwedActWithoutAPullRequestNeedsOneOnlyWhereTheToolDoes(t *testing.T) {
key, v := owedValue(t, "novox/mesh-delivery", 0, "note", delivery.Owed{Act: delivery.OwedNote, Text: "l", Ref: "mesh-plan"})
key, v := owedValue(t, "novox/mesh-delivery", 0, "note", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedNote, Text: "l", Ref: "mesh-plan"})
if _, err := OwedAct(key, v); err != nil {
t.Errorf("a note on a commit no pull request names: %v", err)
}
key, v = owedValue(t, "novox/mesh-delivery", 0, "view", delivery.Owed{Act: delivery.OwedView, Text: "v"})
key, v = owedValue(t, "novox/mesh-delivery", 0, "view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "v"})
if _, err := OwedAct(key, v); err == nil {
t.Error("a view of no pull request was taken")
}
@@ -110,12 +112,12 @@ func TestOwedActWithoutAPullRequestNeedsOneOnlyWhereTheToolDoes(t *testing.T) {
func TestOwedActThatDisagreesWithItsKeyIsRefused(t *testing.T) {
// A dotted repository's key is unescaped, and its act names the repository whole.
dk, dv := owedValue(t, "novox/my.repo", 3, "comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "x"})
dk, dv := owedValue(t, "novox/my.repo", 3, "comment.1", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "x"})
if a, err := OwedAct(dk, dv); err != nil || a.Call.Args["repo"] != "my.repo" || a.Repository != "novox/my.repo" {
t.Errorf("a dotted repository's act = %+v, %v", a, err)
}
key, _ := owedValue(t, "novox/mesh-delivery", 12, "view", delivery.Owed{Act: delivery.OwedView, Text: "v"})
v, _ := delivery.EncodeOwed(delivery.Owed{Act: delivery.OwedComment, Text: "v"})
key, _ := owedValue(t, "novox/mesh-delivery", 12, "view", seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedView, Text: "v"})
v, _ := meshdeliveryclient.EncodeOwed(seat.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedComment, Text: "v"})
if _, err := OwedAct(key, v); err == nil {
t.Error("a comment under a view's key was taken")
}
@@ -127,9 +129,9 @@ func TestOwedActThatDisagreesWithItsKeyIsRefused(t *testing.T) {
}
}
func mergeValue(t *testing.T, m delivery.Merge) []byte {
func mergeValue(t *testing.T, m seat.MergesByGroupValue) []byte {
t.Helper()
b, err := delivery.EncodeMerge(m)
b, err := meshdeliveryclient.EncodeMerge(m)
if err != nil {
t.Fatal(err)
}
@@ -137,10 +139,10 @@ func mergeValue(t *testing.T, m delivery.Merge) []byte {
}
func TestMergeActsHandOnlyTheMemberHandedWithEveryApprovedHead(t *testing.T) { // 1342/AC1
approved := map[string]string{delivery.ApprovedKey("novox/mesh-sdk", 4): headB, delivery.ApprovedKey("novox/mesh-delivery", 12): headA}
v := mergeValue(t, delivery.Merge{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: []delivery.MergePull{
{Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 4, Head: headB}, State: delivery.MergeMerged, Commit: "c0"},
{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, State: delivery.MergeMerging},
approved := map[string]string{meshdeliveryclient.ApprovedKey("novox/mesh-sdk", 4): headB, meshdeliveryclient.ApprovedKey("novox/mesh-delivery", 12): headA}
v := mergeValue(t, seat.MergesByGroupValue{Group: "g", Ask: "merge.g.0123456789ab", Approved: approved, Pulls: []seat.MergesByGroupValuePullsItem{
{Repository: "novox/mesh-sdk", Number: 4, Head: headB, State: string(meshdeliveryclient.MergeMerged), Commit: "c0"},
{Repository: "novox/mesh-delivery", Number: 12, Head: headA, State: string(meshdeliveryclient.MergeMerging)},
}})
acts, err := MergeActs(v)
if err != nil {
@@ -158,8 +160,8 @@ func TestMergeActsHandOnlyTheMemberHandedWithEveryApprovedHead(t *testing.T) { /
t.Errorf("act = %+v", acts[0])
}
// A merge with no approved heads is refused: the forge's tool would refuse it with nothing to say which.
v = mergeValue(t, delivery.Merge{Group: "g", Ask: "a", Pulls: []delivery.MergePull{
{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, State: delivery.MergeMerging}}})
v = mergeValue(t, seat.MergesByGroupValue{Group: "g", Ask: "a", Pulls: []seat.MergesByGroupValuePullsItem{
{Repository: "novox/mesh-delivery", Number: 12, Head: headA, State: string(meshdeliveryclient.MergeMerging)}}})
if _, err := MergeActs(v); err == nil {
t.Error("a merge with no approved heads was taken")
}
@@ -178,7 +180,7 @@ func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3,
if err != nil {
t.Fatal(err)
}
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA,
want := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA,
Act: "merge", Words: "novox/mesh-delivery#12 is not merged: its head moved"}
if !forgemsg.SameForge(f, want) {
t.Errorf("message = %+v", f)
@@ -17,7 +17,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// ErrUnreadable is wrapped by every error that says an event cannot be turned into a message; it is never
@@ -118,7 +118,7 @@ func branchDeleted(subject string, data []byte) (Message, error) {
case branch == Trunk:
return Message{}, fmt.Errorf("%w: %s of the trunk %s", ErrNotHandled, subject, Trunk)
}
body, err := json.Marshal(BranchDeleted{V: delivery.Version, Kind: ForgeBranchDeleted, Repository: d.Owner + "/" + d.Repo, Branch: branch, Head: d.SHA})
body, err := json.Marshal(BranchDeleted{V: keys.Version, Kind: ForgeBranchDeleted, Repository: d.Owner + "/" + d.Repo, Branch: branch, Head: d.SHA})
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
@@ -7,7 +7,7 @@ import (
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/keys"
)
const head = "0123456789abcdef0123456789abcdef01234567"
@@ -59,7 +59,7 @@ func TestUpdatedBecomesAPullMessage(t *testing.T) { // 1235/AC2 (the conversion;
if err != nil {
t.Fatal(err)
}
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
want := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
Head: head, Action: forgemsg.PullUpdated, Dirs: []string{"modules/a"}, DirsKnown: true, Manifests: map[string]string{"modules/a": "{}"}}
if !forgemsg.SameForge(f, want) {
t.Errorf("message = %+v, want %+v", f, want)
@@ -105,7 +105,7 @@ func TestClosedBecomesAClosedPullMessage(t *testing.T) { // 1235/AC4
if err != nil {
t.Fatal(err)
}
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
want := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
Head: head, Action: forgemsg.PullClosed}
if !forgemsg.SameForge(f, want) {
t.Errorf("message = %+v, want %+v", f, want)
@@ -252,7 +252,7 @@ func TestMergeIntoMainBecomesAMergedMessage(t *testing.T) { // 1236/AC1
if err != nil {
t.Fatal(err)
}
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
want := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
Head: head, Merge: merge, Root: "r0", Trees: answer, Manifests: map[string]string{"modules/a": "{}", "modules/b": "{}"}}
if !forgemsg.SameForge(f, want) {
t.Errorf("message = %+v, want %+v", f, want)
@@ -272,7 +272,7 @@ func TestMergeIntoAnotherBranchIsAClosedPull(t *testing.T) { // 1236/AC2
if err != nil {
t.Fatal(err)
}
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
want := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
Head: head, Action: forgemsg.PullClosed}
if msg.Event != "forge.novox.mesh-delivery.pull" || !forgemsg.SameForge(f, want) {
t.Errorf("event %q message %+v, want a closed pull message", msg.Event, f)
@@ -304,7 +304,7 @@ func TestNoModuleJSONAnywhereWritesAMergedMessageWithNoTrees(t *testing.T) {
t.Fatal(err)
}
f, err := forgemsg.DecodeForge(msg.Data)
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
want := forgemsg.Forge{V: keys.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
Head: head, Merge: merge, Root: "r0"}
if err != nil || msg.Event != "forge.novox.mesh-delivery.merged" || !forgemsg.SameForge(f, want) || !strings.Contains(msg.Said, "no module.json") {
t.Errorf("event %q message %+v said %q, %v; want a merged message with no trees", msg.Event, f, msg.Said, err)
@@ -330,8 +330,8 @@ func TestOpenPullsAreUpdatedPullMessagesWithUnknownDirs(t *testing.T) { // 1237/
t.Fatalf("msgs %d skipped %v err %v", len(msgs), skipped, err)
}
for i, want := range []forgemsg.Forge{
{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: forgemsg.PullUpdated},
{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 300, Head: h2, Action: forgemsg.PullUpdated},
{V: keys.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: forgemsg.PullUpdated},
{V: keys.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 300, Head: h2, Action: forgemsg.PullUpdated},
} {
f, err := forgemsg.DecodeForge(msgs[i].Data)
if err != nil || !forgemsg.SameForge(f, want) {
@@ -16,17 +16,20 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
)
// The bridge's states the tools read and write.
const (
// Module is the forge bridge's own name (its manifest's module, checked by the bridge's names test).
Module = "mesh-delivery-forge-bridge"
// PerformedState is the bridge's state of what it performed and published.
PerformedState = "performed"
// EndAsksState is the state of asks to say a group's end again, keyed by the gate's merges key of the group.
EndAsksState = "end-asks"
// EndAsksBucket is its bucket on the bus.
EndAsksBucket = delivery.ForgeBridgeModule + "_" + EndAsksState
EndAsksBucket = Module + "_" + EndAsksState
)
// Key is the key of performed that says a group's end is written: `groups.<merges key>`, its value the revision of the
@@ -75,7 +78,7 @@ func NewEndAsk(group, why, by string, at time.Time) (string, []byte, error) {
if group == "" {
return "", nil, errors.New("name the group whose end is said again (group)")
}
key, err := delivery.MergesKey(group)
key, err := meshdeliveryclient.MergesKey(group)
if err != nil {
return "", nil, err
}
@@ -134,7 +137,7 @@ func Rows(merges map[string][]byte, performed map[string]bool, endedAt map[strin
lastAsk[key] = &a
}
for key, raw := range merges {
m, err := delivery.DecodeMerge(raw)
m, err := meshdeliveryclient.DecodeMerge(raw)
if err != nil {
unread = append(unread, key)
continue
@@ -145,9 +148,9 @@ func Rows(merges map[string][]byte, performed map[string]bool, endedAt map[strin
r := Row{Key: key, Group: m.Group}
allMerged := true
for _, p := range m.Pulls {
pub := p.Commit != "" && performed[PublishedKey(p.Repository, p.Number, p.Commit)]
r.Members = append(r.Members, RowMember{Repository: p.Repository, Number: p.Number, State: string(p.State), Commit: p.Commit, Published: pub})
if p.State != delivery.MergeMerged || !pub {
pub := p.Commit != "" && performed[PublishedKey(p.Repository, int(p.Number), p.Commit)]
r.Members = append(r.Members, RowMember{Repository: p.Repository, Number: int(p.Number), State: p.State, Commit: p.Commit, Published: pub})
if meshdeliveryclient.MergeState(p.State) != meshdeliveryclient.MergeMerged || !pub {
allMerged = false
}
}
@@ -175,8 +178,8 @@ func Rows(merges map[string][]byte, performed map[string]bool, endedAt map[strin
func sortRows(rows []Row) { sort.Slice(rows, func(i, j int) bool { return rows[i].Key < rows[j].Key }) }
// MembersOf are a merge's approved members with their heads, in the order of their keys.
func MembersOf(m delivery.Merge) []delivery.Head {
var out []delivery.Head
func MembersOf(m seat.MergesByGroupValue) []meshdeliveryclient.Head {
var out []meshdeliveryclient.Head
for _, k := range slices.Sorted(func(yield func(string) bool) {
for k := range m.Approved {
if !yield(k) {
@@ -192,7 +195,7 @@ func MembersOf(m delivery.Merge) []delivery.Head {
if err != nil {
continue
}
out = append(out, delivery.Head{Repository: k[:at], Number: n, Head: m.Approved[k]})
out = append(out, meshdeliveryclient.Head{Repository: k[:at], Number: n, Head: m.Approved[k]})
}
return out
}
@@ -10,7 +10,6 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -36,7 +35,7 @@ const (
func consumed(w *watch.Watchman, subject string, body []byte, logf func(string, ...any)) {
switch {
case subject == meshcatalog.SubjectManifestAccepted:
var a delivery.ManifestAccepted
var a meshcatalog.ManifestAcceptedEvent
if err := json.Unmarshal(body, &a); err != nil || a.Module == "" {
logf("a manifest-accepted that does not read is left: %v", err)
return
@@ -17,8 +17,13 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views/viewstest"
@@ -62,8 +67,8 @@ accounts {
// families (DLV-M5, ADR 0615): the watchman (n1) follows both, never their buckets.
func serveOwners(t *testing.T, ctx context.Context, js jetstream.JetStream, nc *nats.Conn) {
t.Helper()
viewstest.Serve(t, ctx, js, nc, delivery.AssignerModule, wantsState, delivery.AssignerModule+".assigner_read_wants", "n1_"+delivery.WatchmanModule)
viewstest.Serve(t, ctx, js, nc, delivery.IndexerModule, modulesState, delivery.IndexerModule+".indexer_read_modules", "n1_"+delivery.WatchmanModule)
viewstest.Serve(t, ctx, js, nc, watch.AssignerModule, wantsState, watch.AssignerModule+".assigner_read_wants", "n1_"+watch.WatchmanModule)
viewstest.Serve(t, ctx, js, nc, meshcatalogclient.Module, modulesState, meshcatalogclient.Module+".indexer_read_modules", "n1_"+watch.WatchmanModule)
}
// TestWatchmanWhole starts the program the way the node does (its entry, run, with its environment and a broker file)
@@ -111,12 +116,12 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
defer cancel()
kvs := map[string]jetstream.KeyValue{}
for _, c := range []jetstream.KeyValueConfig{
{Bucket: delivery.PresenceBucket, History: 20},
{Bucket: delivery.StuckBucket, History: 20},
{Bucket: delivery.PulseBucket, LimitMarkerTTL: time.Second},
{Bucket: delivery.StepsBucket("mesh-build-go"), History: 5},
{Bucket: delivery.AssignerWantsBucket, History: 20},
{Bucket: delivery.HaveBucket, History: 20},
{Bucket: watch.PresenceBucket, History: 20},
{Bucket: watch.StuckBucket, History: 20},
{Bucket: watch.PulseBucket, LimitMarkerTTL: time.Second},
{Bucket: watch.StepsBucket("mesh-build-go"), History: 5},
{Bucket: watch.AssignerWantsBucket, History: 20},
{Bucket: watch.HaveBucket, History: 20},
{Bucket: watch.FindingsBucket, History: 5},
{Bucket: firstViewsBucket, History: 1},
} {
@@ -174,11 +179,11 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
return e, err == nil
}
online := func(key string) (bool, bool) {
e, ok := has(delivery.PresenceBucket, key)
e, ok := has(watch.PresenceBucket, key)
if !ok {
return false, false
}
p, err := delivery.DecodePresence(e.Value())
p, err := meshwatchdogsclient.DecodeNodePresence(e.Value())
return p.Online, err == nil
}
until := func(what string, ok func() bool) {
@@ -191,14 +196,14 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
t.Fatalf("never saw: %s", what)
}
pulseOf := func(bucket, key string) string {
k, err := delivery.PulseKey(bucket, key)
k, err := meshwatchdogsclient.PulseKey(bucket, key)
if err != nil {
t.Fatal(err)
}
return k
}
stuckOf := func(bucket, key string) string {
k, err := delivery.StuckKey(bucket, key)
k, err := meshwatchdogsclient.StuckKey(bucket, key)
if err != nil {
t.Fatal(err)
}
@@ -214,12 +219,12 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
until("node n1 offline", func() bool { on, ok := online("n1"); return ok && !on })
// An ask put on a build seat gets a pulse; one no node takes lets the pulse expire, and that writes a stuck key.
askStream, _ := delivery.AskStream(delivery.AskGo)
steps := kvs[delivery.StepsBucket("mesh-build-go")]
askStream, _ := buildstep.AskStream(buildstep.AskGo)
steps := kvs[watch.StepsBucket("mesh-build-go")]
// A node takes an ask: it writes the step key running (and passed when the ask ends).
putStep := func(id, state string) {
t.Helper()
body, err := delivery.EncodeStep(delivery.Step{ID: id, Tool: "build_go_test", Node: "n1", State: state, Started: time.Now().UTC()})
body, err := buildstep.EncodeStep(buildstep.Step{ID: id, Tool: "build_go_test", Node: "n1", State: state, Started: time.Now().UTC()})
if err != nil {
t.Fatal(err)
}
@@ -228,16 +233,16 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
}
}
const ask1, ask2 = "m0000001aaaaaa", "m0000002bbbbbb"
putStep(ask1, delivery.StepRunning)
until("a pulse for the ask a node took", func() bool { _, ok := has(delivery.PulseBucket, pulseOf(askStream, ask1)); return ok })
var stuck delivery.Stuck
putStep(ask1, buildstep.StepRunning)
until("a pulse for the ask a node took", func() bool { _, ok := has(watch.PulseBucket, pulseOf(askStream, ask1)); return ok })
var stuck meshwatchdogsclient.Stuck
until("a stuck key for the ask no node ended", func() bool {
e, ok := has(delivery.StuckBucket, stuckOf(askStream, ask1))
e, ok := has(watch.StuckBucket, stuckOf(askStream, ask1))
if !ok {
return false
}
var err error
stuck, err = delivery.DecodeStuck(e.Value())
stuck, err = meshwatchdogsclient.DecodeStuck(e.Value())
return err == nil
})
if stuck.Moved || stuck.Bucket != askStream || stuck.Key != ask1 {
@@ -245,7 +250,7 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
}
// The node n1, gone offline and never back, is stuck as well.
until("a stuck key for node n1 that did not come back", func() bool {
_, ok := has(delivery.StuckBucket, stuckOf(delivery.PresenceBucket, "n1"))
_, ok := has(watch.StuckBucket, stuckOf(watch.PresenceBucket, "n1"))
return ok
})
@@ -253,18 +258,18 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8
n2 := connect("node.n2")
until("node n2 online", func() bool { on, ok := online("n2"); return ok && on })
n2.Close()
until("a pulse for node n2 gone offline", func() bool { _, ok := has(delivery.PulseBucket, pulseOf(delivery.PresenceBucket, "n2")); return ok })
until("a pulse for node n2 gone offline", func() bool { _, ok := has(watch.PulseBucket, pulseOf(watch.PresenceBucket, "n2")); return ok })
connect("node.n2")
until("node n2 online again", func() bool { on, ok := online("n2"); return ok && on })
until("the pulse of node n2 deleted", func() bool { _, ok := has(delivery.PulseBucket, pulseOf(delivery.PresenceBucket, "n2")); return !ok })
until("the pulse of node n2 deleted", func() bool { _, ok := has(watch.PulseBucket, pulseOf(watch.PresenceBucket, "n2")); return !ok })
// An ask a node takes at once is the same: no stuck key, however long the bound passes.
putStep(ask2, delivery.StepRunning)
until("a pulse for the second ask", func() bool { _, ok := has(delivery.PulseBucket, pulseOf(askStream, ask2)); return ok })
putStep(ask2, delivery.StepPassed)
until("the pulse of the second ask deleted", func() bool { _, ok := has(delivery.PulseBucket, pulseOf(askStream, ask2)); return !ok })
putStep(ask2, buildstep.StepRunning)
until("a pulse for the second ask", func() bool { _, ok := has(watch.PulseBucket, pulseOf(askStream, ask2)); return ok })
putStep(ask2, buildstep.StepPassed)
until("the pulse of the second ask deleted", func() bool { _, ok := has(watch.PulseBucket, pulseOf(askStream, ask2)); return !ok })
time.Sleep(6 * time.Second) // twice the bound and the marker time
for _, k := range []string{stuckOf(delivery.PresenceBucket, "n2"), stuckOf(askStream, ask2)} {
if _, ok := has(delivery.StuckBucket, k); ok {
for _, k := range []string{stuckOf(watch.PresenceBucket, "n2"), stuckOf(askStream, ask2)} {
if _, ok := has(watch.StuckBucket, k); ok {
t.Errorf("a stuck key %s was written for a move that happened before the bound", k)
}
}
@@ -362,12 +367,12 @@ func anAskRunningBeforeTheStartGetsItsPulse(t *testing.T) { // 1909
defer cancel()
kvs := map[string]jetstream.KeyValue{}
for _, c := range []jetstream.KeyValueConfig{
{Bucket: delivery.PresenceBucket, History: 20},
{Bucket: delivery.StuckBucket, History: 20},
{Bucket: delivery.PulseBucket, LimitMarkerTTL: time.Second},
{Bucket: delivery.StepsBucket("mesh-build-go"), History: 5},
{Bucket: delivery.AssignerWantsBucket, History: 20},
{Bucket: delivery.HaveBucket, History: 20},
{Bucket: watch.PresenceBucket, History: 20},
{Bucket: watch.StuckBucket, History: 20},
{Bucket: watch.PulseBucket, LimitMarkerTTL: time.Second},
{Bucket: watch.StepsBucket("mesh-build-go"), History: 5},
{Bucket: watch.AssignerWantsBucket, History: 20},
{Bucket: watch.HaveBucket, History: 20},
{Bucket: watch.FindingsBucket, History: 5},
{Bucket: firstViewsBucket, History: 1},
} {
@@ -379,11 +384,11 @@ func anAskRunningBeforeTheStartGetsItsPulse(t *testing.T) { // 1909
}
serveOwners(t, ctx, js, ctl)
const id = "m0000009ffffff"
body, err := delivery.EncodeStep(delivery.Step{ID: id, Tool: "build_go_test", Node: "n1", State: delivery.StepRunning, Started: time.Now().UTC()})
body, err := buildstep.EncodeStep(buildstep.Step{ID: id, Tool: "build_go_test", Node: "n1", State: buildstep.StepRunning, Started: time.Now().UTC()})
if err != nil {
t.Fatal(err)
}
if _, err := kvs[delivery.StepsBucket("mesh-build-go")].Put(ctx, "n1."+id, body); err != nil {
if _, err := kvs[watch.StepsBucket("mesh-build-go")].Put(ctx, "n1."+id, body); err != nil {
t.Fatal(err)
}
broker := filepath.Join(dir, "broker")
@@ -396,13 +401,13 @@ func anAskRunningBeforeTheStartGetsItsPulse(t *testing.T) { // 1909
done := make(chan error, 1)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
stream, _ := delivery.AskStream(delivery.AskGo)
pk, err := delivery.PulseKey(stream, id)
stream, _ := buildstep.AskStream(buildstep.AskGo)
pk, err := meshwatchdogsclient.PulseKey(stream, id)
if err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if _, err := kvs[delivery.PulseBucket].Get(ctx, pk); err == nil {
if _, err := kvs[watch.PulseBucket].Get(ctx, pk); err == nil {
return
}
}
@@ -449,12 +454,12 @@ func aWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) {
defer cancel()
kvs := map[string]jetstream.KeyValue{}
for _, c := range []jetstream.KeyValueConfig{
{Bucket: delivery.PresenceBucket, History: 20},
{Bucket: delivery.StuckBucket, History: 20},
{Bucket: delivery.PulseBucket, LimitMarkerTTL: time.Second},
{Bucket: delivery.StepsBucket("mesh-build-go"), History: 5},
{Bucket: delivery.AssignerWantsBucket, History: 20},
{Bucket: delivery.HaveBucket, History: 20},
{Bucket: watch.PresenceBucket, History: 20},
{Bucket: watch.StuckBucket, History: 20},
{Bucket: watch.PulseBucket, LimitMarkerTTL: time.Second},
{Bucket: watch.StepsBucket("mesh-build-go"), History: 5},
{Bucket: watch.AssignerWantsBucket, History: 20},
{Bucket: watch.HaveBucket, History: 20},
{Bucket: watch.FindingsBucket, History: 5},
{Bucket: firstViewsBucket, History: 1},
} {
@@ -465,27 +470,27 @@ func aWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) {
kvs[c.Bucket] = kv
}
serveOwners(t, ctx, js, ctl)
fpNew := delivery.SourcePrefix2 + strings.Repeat("b", 64)
fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64)
fpNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64)
fpOld := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64)
putWant := func(node, module, fp string) {
t.Helper()
body, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: "sha256:" + strings.Repeat("c", 64)})
body, err := meshwantsclient.EncodeWant(meshwantsclient.Want{Fp: fp, Composed: "sha256:" + strings.Repeat("c", 64)})
if err != nil {
t.Fatal(err)
}
key, _ := delivery.WantKey(node, module)
if _, err := kvs[delivery.AssignerWantsBucket].Put(ctx, key, body); err != nil {
key, _ := meshwantsclient.WantKey(node, module)
if _, err := kvs[watch.AssignerWantsBucket].Put(ctx, key, body); err != nil {
t.Fatal(err)
}
}
putHave := func(node, module, fp string) {
t.Helper()
body, err := delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
body, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
if err != nil {
t.Fatal(err)
}
key, _ := delivery.HaveKey(node, module)
if _, err := kvs[delivery.HaveBucket].Put(ctx, key, body); err != nil {
key, _ := nodeengineclient.HaveKey(node, module)
if _, err := kvs[watch.HaveBucket].Put(ctx, key, body); err != nil {
t.Fatal(err)
}
}
@@ -502,8 +507,8 @@ func aWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) {
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
pulse := func(node, module string) string {
wk, _ := delivery.WantKey(node, module)
pk, err := delivery.PulseKey(delivery.AssignerWantsBucket, wk)
wk, _ := meshwantsclient.WantKey(node, module)
pk, err := meshwatchdogsclient.PulseKey(watch.AssignerWantsBucket, wk)
if err != nil {
t.Fatal(err)
}
@@ -518,15 +523,15 @@ func aWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) {
}
t.Fatalf("never saw: %s", what)
}
has := func(key string) bool { _, err := kvs[delivery.PulseBucket].Get(ctx, key); return err == nil }
has := func(key string) bool { _, err := kvs[watch.PulseBucket].Get(ctx, key); return err == nil }
until("a pulse for the want that stood before the start", func() bool { return has(pulse("n2", "web")) })
putWant("n2", "mail", fpNew)
until("a pulse for a want written after the start", func() bool { return has(pulse("n2", "mail")) })
e, err := kvs[delivery.PulseBucket].Get(ctx, pulse("n2", "web"))
e, err := kvs[watch.PulseBucket].Get(ctx, pulse("n2", "web"))
if err != nil {
t.Fatal(err)
}
if p, err := delivery.DecodePulse(e.Value()); err != nil || !strings.Contains(p.Owes, "n2") {
if p, err := meshwatchdogsclient.DecodePulse(e.Value()); err != nil || !strings.Contains(p.Owes, "n2") {
t.Errorf("the pulse = %+v, %v; want it owed by the agent of n2", p, err)
}
putHave("n2", "web", fpNew)
@@ -535,9 +540,9 @@ func aWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) {
t.Errorf("the pulse of another want ended with web's have")
}
for _, m := range []string{"web"} {
wk, _ := delivery.WantKey("n2", m)
sk, _ := delivery.StuckKey(delivery.AssignerWantsBucket, wk)
if _, err := kvs[delivery.StuckBucket].Get(ctx, sk); err == nil {
wk, _ := meshwantsclient.WantKey("n2", m)
sk, _ := meshwatchdogsclient.StuckKey(watch.AssignerWantsBucket, wk)
if _, err := kvs[watch.StuckBucket].Get(ctx, sk); err == nil {
t.Errorf("a stuck key was written for the want of %s that was applied", m)
}
}
@@ -25,8 +25,10 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/provisions"
"mesh-delivery-watchman/internal/socket"
@@ -64,7 +66,7 @@ func (o *kvOut) PutFinding(key string, value []byte) error {
func (o *kvOut) DeleteFinding(key string) error { return o.findings.Delete(o.ctx, key) }
func (o *kvOut) PutPresence(key string, online bool) error {
value, err := delivery.EncodePresence(delivery.Presence{Online: online})
value, err := meshwatchdogsclient.EncodeNodePresence(meshwatchdogsclient.NodePresence{Online: online})
if err != nil {
return err
}
@@ -184,7 +186,7 @@ func createdOr(e jetstream.KeyValueEntry, at time.Time) time.Time {
// The watchman's own states of its first views (views.StatusState); its findings of a first view unanswered past its
// bound go to its findings bucket with the others (ADR 0615 §6). The owners' states it follows are the assigner's wants
// and the indexer's modules (DLV-M5).
var firstViewsBucket = delivery.WatchmanModule + "_" + views.StatusState
var firstViewsBucket = watch.WatchmanModule + "_" + views.StatusState
const (
wantsState = "wants"
@@ -219,12 +221,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
for _, b := range []struct {
name string
into *jetstream.KeyValue
}{{delivery.PresenceBucket, &kvs.presence}, {delivery.PulseBucket, &kvs.pulse}, {delivery.StuckBucket, &kvs.stuck}, {watch.FindingsBucket, &kvs.findings}} {
}{{watch.PresenceBucket, &kvs.presence}, {watch.PulseBucket, &kvs.pulse}, {watch.StuckBucket, &kvs.stuck}, {watch.FindingsBucket, &kvs.findings}} {
if *b.into, err = process.EssentialBucket(ctx, js, "watchman", b.name, "say who is online or what stopped moving"); err != nil {
return err
}
}
kvs.haves = process.OptionalBucket(ctx, js, delivery.HaveBucket, "a want makes no pulse, so a move that stops is not seen, until the watchman starts again", logger.Printf)
kvs.haves = process.OptionalBucket(ctx, js, watch.HaveBucket, "a want makes no pulse, so a move that stops is not seen, until the watchman starts again", logger.Printf)
// The assigner's wants and the indexer's modules are their owners' states, read by the first view of their read tools and
// then their views families, never their buckets (ADR 0615 §1, DLV-M5). An owner that is down never holds the start: each
// is read on a goroutine once its first view is answered, and until then the unanswered first view is the watchman's
@@ -237,15 +239,15 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// The module has one durable consumer of EVENTS: one hub pulls it for the views reader, the indexer's take-in events
// and the seats' events, started once every part is registered.
hub := process.NewEventHub(logger.Printf)
reader := &views.ProcessReader{Module: delivery.WatchmanModule, Call: calls.Call, Logf: logger.Printf, Hub: hub,
reader := &views.ProcessReader{Module: watch.WatchmanModule, Call: calls.Call, Logf: logger.Printf, Hub: hub,
Findings: func() views.FindingWriter {
return sdkfindings.Writer(ctx, func() jetstream.KeyValue { return kvs.findings })
}}
wants, err := reader.Follow(delivery.AssignerModule, wantsState, delivery.AssignerModule+".assigner_read_wants")
wants, err := reader.Follow(watch.AssignerModule, wantsState, watch.AssignerModule+".assigner_read_wants")
if err != nil {
return err
}
modules, err := reader.Follow(delivery.IndexerModule, modulesState, delivery.IndexerModule+".indexer_read_modules")
modules, err := reader.Follow(meshcatalogclient.Module, modulesState, meshcatalogclient.Module+".indexer_read_modules")
if err != nil {
return err
}
@@ -253,8 +255,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
w := watch.New(&kvOut{ctx: ctx, presence: kvs.presence, pulse: kvs.pulse, stuk: kvs.stuck, findings: kvs.findings}, time.Now)
w.Logf(logger.Printf)
// What the wants and the modules hold is ended by their own late read (watch.ReadLate), never by the start's end.
w.Awaits(delivery.AssignerWantsBucket)
w.Awaits(delivery.ModulesBucket)
w.Awaits(watch.AssignerWantsBucket)
w.Awaits(watch.ModulesBucket)
// A test shortens how long a move may take; the mesh never sets it.
if v := strings.TrimSpace(getenv("MESH_DELIVERY_PULSE_BOUND")); v != "" {
d, err := time.ParseDuration(v)
@@ -270,7 +272,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
each func(jetstream.KeyValueEntry)
}{
{kvs.presence, func(e jetstream.KeyValueEntry) {
if p, err := delivery.DecodePresence(e.Value()); err == nil && opOf(e) == watch.Put {
if p, err := meshwatchdogsclient.DecodeNodePresence(e.Value()); err == nil && opOf(e) == watch.Put {
w.SeedPresence(e.Key(), p.Online, e.Created())
}
}},
@@ -305,27 +307,27 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
var haveSeen jetstream.KeyWatcher
if kvs.haves != nil {
if haveSeen, err = settle(ctx, kvs.haves, func(e jetstream.KeyValueEntry) { w.Have(e.Key(), e.Value(), opOf(e)) }); err != nil {
process.NotWatched(logger.Printf, delivery.HaveBucket, err, "moves are not watched until the watchman starts again")
process.NotWatched(logger.Printf, watch.HaveBucket, err, "moves are not watched until the watchman starts again")
haveSeen = nil
}
}
// The builder's builds (ADR 0603 §8): a key queued behind a package is a wait, mirrored from the time it was written.
var buildSeen jetstream.KeyWatcher
if kv := process.OptionalBucket(ctx, js, delivery.BuilderBuildsBucket, "a build waiting for a package is not watched until the watchman starts again", logger.Printf); kv != nil {
if kv := process.OptionalBucket(ctx, js, watch.BuilderBuildsBucket, "a build waiting for a package is not watched until the watchman starts again", logger.Printf); kv != nil {
buildEach := func(e jetstream.KeyValueEntry) { w.Build(e.Key(), e.Value(), opOf(e), e.Created()) }
if buildSeen, err = settle(ctx, kv, buildEach); err != nil {
process.NotWatched(logger.Printf, delivery.BuilderBuildsBucket, err, "a build waiting for a package is not watched until the watchman starts again")
process.NotWatched(logger.Printf, watch.BuilderBuildsBucket, err, "a build waiting for a package is not watched until the watchman starts again")
buildSeen = nil
} else {
w.ReadWhole(delivery.BuilderBuildsBucket)
w.ReadWhole(watch.BuilderBuildsBucket)
}
}
// The build seats' steps: a step key `<node>.<ask id>` written is the ask taken (issue 1887 A8b). A seat whose build module
// keeps no steps bucket (yet) is said and left; the asks of its stream are mirrored all the same.
taken := map[string]jetstream.KeyWatcher{}
for _, lang := range delivery.AskLanguages {
module, _ := delivery.BuildModuleOf(lang)
kv, err := js.KeyValue(ctx, delivery.StepsBucket(module))
for _, lang := range buildstep.AskLanguages {
module, _ := buildstep.BuildModuleOf(lang)
kv, err := js.KeyValue(ctx, watch.StepsBucket(module))
if err != nil {
logger.Printf("the steps of %s cannot be read (%v): its asks are mirrored and never seen taken", module, err)
continue
@@ -337,7 +339,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
continue
}
taken[lang] = sw
if bucket, err := delivery.AskStream(lang); err == nil {
if bucket, err := buildstep.AskStream(lang); err == nil {
w.ReadWhole(bucket)
}
}
@@ -406,16 +408,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// bucket is there, bound once it appears when it is not; each change is heard on the watch, never polled.
healthEach := func(e jetstream.KeyValueEntry) { w.NodeHealth(e.Key(), e.Value(), opOf(e)) }
var healthWatch jetstream.KeyWatcher
if kv, err := js.KeyValue(ctx, delivery.NodeHealthBucket); err == nil {
if kv, err := js.KeyValue(ctx, watch.NodeHealthBucket); err == nil {
if healthWatch, err = settle(ctx, kv, healthEach); err != nil {
process.NotWatched(logger.Printf, delivery.NodeHealthBucket, err, "no node's own health is said until the watchman starts again")
process.NotWatched(logger.Printf, watch.NodeHealthBucket, err, "no node's own health is said until the watchman starts again")
}
} else {
process.AwaitBucket(ctx, js, conn, delivery.NodeHealthBucket, "no node's own health is said",
process.AwaitBucket(ctx, js, conn, watch.NodeHealthBucket, "no node's own health is said",
logger.Printf).Ready(func(kv jetstream.KeyValue) {
hw, err := settle(ctx, kv, healthEach)
if err != nil {
process.NotWatched(logger.Printf, delivery.NodeHealthBucket, err, "no node's own health is said until the watchman starts again")
process.NotWatched(logger.Printf, watch.NodeHealthBucket, err, "no node's own health is said until the watchman starts again")
return
}
go func() { _ = follow(ctx, hw, healthEach) }()
@@ -453,7 +455,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
w.TakeInCaughtUp()
}
}
if err := hub.Handle("mesh.mod."+delivery.IndexerModule+".event.take-in.*", func(m jetstream.Msg) { takeInHeard(w, m, logger.Printf) }); err != nil {
if err := hub.Handle("mesh.mod."+meshcatalogclient.Module+".event.take-in.*", func(m jetstream.Msg) { takeInHeard(w, m, logger.Printf) }); err != nil {
return err
}
}
@@ -473,13 +475,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
defer hub.Stop()
// The indexer's modules (ADR 0578 §1): a module whose value carries a refusal of its manifest is a finding.
verdictEach := func(e jetstream.KeyValueEntry) { w.ModuleVerdict(e.Key(), e.Value(), opOf(e)) }
go func() { errs <- readLate(ctx, w, modules, delivery.ModulesBucket, verdictEach) }()
go func() { errs <- readLate(ctx, w, modules, watch.ModulesBucket, verdictEach) }()
if haveSeen != nil {
go func() {
errs <- follow(ctx, haveSeen, func(e jetstream.KeyValueEntry) { w.Have(e.Key(), e.Value(), opOf(e)) })
}()
go func() {
errs <- readLate(ctx, w, wants, delivery.AssignerWantsBucket, func(e jetstream.KeyValueEntry) {
errs <- readLate(ctx, w, wants, watch.AssignerWantsBucket, func(e jetstream.KeyValueEntry) {
w.Want(e.Key(), e.Value(), opOf(e), createdOr(e, time.Now()))
})
}()
@@ -533,7 +535,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
const eventsStream = "EVENTS"
// pendingTool is the indexer's tool that answers the waits it holds (ADR 0633 §5), as the view asks it.
const pendingTool = delivery.IndexerModule + ".indexer_pending"
const pendingTool = meshcatalogclient.Module + ".indexer_pending"
// seedTakeIn asks the indexer the waits it holds now and starts each (Watchman.SeedTakeIn). An indexer that does not
// answer is said: the waits are then known only from the events heard after.
@@ -10,7 +10,6 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -82,11 +81,11 @@ func TestRunNeedsItsCredential(t *testing.T) { // 1135/AC6
// The watchman's want wait starts at the entry's time (createdOr): through mesh-sdk's views.ProcessReader it is
// the time the owner's bus wrote the key, never the watchman's start.
written := time.Date(2026, 11, 4, 9, 0, 0, 0, time.UTC)
r := &views.ProcessReader{Module: delivery.WatchmanModule, Call: func(string, any) (json.RawMessage, error) {
return json.Marshal(views.Answer{V: views.Version, Owner: delivery.AssignerModule, State: wantsState, Revision: 3, Created: written,
r := &views.ProcessReader{Module: watch.WatchmanModule, Call: func(string, any) (json.RawMessage, error) {
return json.Marshal(views.Answer{V: views.Version, Owner: watch.AssignerModule, State: wantsState, Revision: 3, Created: written,
At: written.Add(time.Hour), Values: map[string]json.RawMessage{"ace.mail": json.RawMessage(`{}`)}, Written: map[string]time.Time{"ace.mail": written}})
}}
p, err := r.Follow(delivery.AssignerModule, wantsState, delivery.AssignerModule+".assigner_read_wants")
p, err := r.Follow(watch.AssignerModule, wantsState, watch.AssignerModule+".assigner_read_wants")
if err != nil {
t.Fatal(err)
}
+7
View File
@@ -3,6 +3,13 @@ module mesh-delivery-watchman
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/placement/go/clients/meshwantsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/seats/meshbroker v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
@@ -7,7 +7,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
)
// **A refused or overdue creation of bus state is loud** (novox/hq ADR 0595 §4; design 25 § 12.2; DLV-W1). The watchman
@@ -60,7 +61,7 @@ func refusedKey(module, local string) string {
// Accepted is the indexer's manifest-accepted heard: each entry it asks for waits OverdueBound for the bus module's word,
// and every entry of the module this watchman waits on or holds a finding of that it no longer asks for is ended.
func (w *Watchman) Accepted(a delivery.ManifestAccepted) {
func (w *Watchman) Accepted(a meshcatalog.ManifestAcceptedEvent) {
w.mu.Lock()
defer w.mu.Unlock()
asked := map[string]bool{}
@@ -100,7 +101,7 @@ func (w *Watchman) Accepted(a delivery.ManifestAccepted) {
func (w *Watchman) markCreated(module, local string) {
pk := signalKey(rowBusCreated, busStateID(module, local))
owes, _ := json.Marshal(signalDue{Do: dueCreated})
value, err := delivery.EncodePulse(delivery.Pulse{Bucket: signalBucket, Key: strings.TrimPrefix(pk, signalBucket+"."), Owes: string(owes)})
value, err := meshwatchdogsclient.EncodePulse(meshwatchdogsclient.Pulse{Bucket: signalBucket, Key: strings.TrimPrefix(pk, signalBucket+"."), Owes: string(owes)})
if err == nil {
err = w.out.PutPulse(pk, value, 0)
}
@@ -7,9 +7,14 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-delivery/sdk/heard"
@@ -25,9 +30,9 @@ func mustEncode(t *testing.T, f Finding) []byte {
return v
}
func haveOf(t *testing.T, phase delivery.Phase, health delivery.Health, detail string) []byte {
func haveOf(t *testing.T, phase string, health string, detail string) []byte {
t.Helper()
b, err := delivery.EncodeHave(delivery.Have{FP: fpNew, Phase: phase, Health: health, Detail: detail})
b, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpNew, Phase: phase, Health: health, Detail: detail})
if err != nil {
t.Fatal(err)
}
@@ -136,15 +141,15 @@ func TestTheWatchmansFindings(t *testing.T) {
}, map[string]string{FindingKey(ScopeDelivery, "m.00000000000000000043", KindTakeInWaits): Warning}},
{"ADR 0578 §1: a module whose value carries a refusal of its manifest is a finding until it is accepted", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
value := func(verdict string, problems ...string) []byte {
b, err := delivery.EncodeModule(delivery.Module{FP2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64),
b, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64),
Repository: "novox/mesh-catalog", Commit: "c", Verdict: verdict, Problems: problems})
if err != nil {
t.Fatal(err)
}
return b
}
w.ModuleVerdict("nats", value(delivery.VerdictRefused, "removes a stream"), Put)
w.ModuleVerdict("power", value(delivery.VerdictRefused, "shows nothing"), Put)
w.ModuleVerdict("nats", value(meshcatalogclient.VerdictRefused, "removes a stream"), Put)
w.ModuleVerdict("power", value(meshcatalogclient.VerdictRefused, "shows nothing"), Put)
k := FindingKey(ScopeModule, "nats", KindManifestRefused)
if f := o.findings[k]; !strings.Contains(f.Summary, "refused the manifest of nats: removes a stream") {
t.Fatalf("findings = %v", o.findings)
@@ -152,7 +157,7 @@ func TestTheWatchmansFindings(t *testing.T) {
w.ModuleVerdict("nats", value(""), Put)
w.ModuleVerdict("power", nil, Delete)
// ADR 0579 §1: an accepted manifest a stricter judge outgrew is a warning until mended.
outgrown, _ := delivery.EncodeModule(delivery.Module{FP2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64),
outgrown, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64),
Repository: "novox/mesh-tools", Commit: "c", Warnings: []string{"says no credential form"}})
w.ModuleVerdict("node-tools", outgrown, Put)
if f := o.findings[FindingKey(ScopeModule, "node-tools", KindManifestOutgrown)]; f.Severity != Warning || !strings.Contains(f.Summary, "says no credential form") {
@@ -161,8 +166,8 @@ func TestTheWatchmansFindings(t *testing.T) {
w.ModuleVerdict("node-tools", value(""), Put)
// Clause 3 of the record on a release's Approve: a manifest that waits for a judge that knows its fields is its own
// warning (a refusal in the value, naming the fields), not manifest-refused, and goes when it is accepted.
waiting, _ := delivery.EncodeModule(delivery.Module{FP2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64),
Repository: "novox/mesh-catalog", Commit: "c", Verdict: delivery.VerdictRefused, Problems: []string{"waits"}, Unknown: []string{"core-credentials"}})
waiting, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: "src2:" + strings.Repeat("a", 64), Manifest: strings.Repeat("b", 64),
Repository: "novox/mesh-catalog", Commit: "c", Verdict: meshcatalogclient.VerdictRefused, Problems: []string{"waits"}, Unknown: []string{"core-credentials"}})
w.ModuleVerdict("nats", waiting, Put)
if f := o.findings[FindingKey(ScopeModule, "nats", KindManifestWaits)]; f.Severity != Warning || !strings.Contains(f.Summary, "core-credentials") {
t.Fatalf("findings = %v", o.findings)
@@ -173,10 +178,10 @@ func TestTheWatchmansFindings(t *testing.T) {
w.ModuleVerdict("nats", value(""), Put)
}, map[string]string{}},
{"DLV-W1, 1966/AC37-AC38 (ADR 0595 §4, design 25 § 12.2): an accepted entry the bus module says nothing of in five minutes is overdue until created; a refused one stands until created; a later verdict no longer asking ends both", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
accepted := func(locals ...string) delivery.ManifestAccepted {
a := delivery.ManifestAccepted{Module: "mesh-knowledge", Digest: strings.Repeat("d", 64)}
accepted := func(locals ...string) meshcatalog.ManifestAcceptedEvent {
a := meshcatalog.ManifestAcceptedEvent{Module: "mesh-knowledge", Digest: strings.Repeat("d", 64)}
for _, l := range locals {
a.BusState = append(a.BusState, catalogue.BusStateEntry{Name: l, Storage: catalogue.StorageLedger})
a.BusState = append(a.BusState, meshcatalog.ManifestAcceptedEventBusStateItem{Name: l, Storage: "ledger"})
}
return a
}
@@ -282,7 +287,7 @@ func TestTheWatchmansFindings(t *testing.T) {
}, map[string]string{}},
{"ADR 0601 §4: what a node keeps of a module as it runs, for a manifest that does not say it yet, is a warning until the have says none", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
have := func(kept ...string) []byte {
b, err := delivery.EncodeHave(delivery.Have{FP: "src2:" + strings.Repeat("a", 64), Phase: delivery.PhaseRunning, Health: delivery.HealthOK, Kept: kept})
b, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: "src2:" + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK, Kept: kept})
if err != nil {
t.Fatal(err)
}
@@ -310,7 +315,7 @@ func TestTheWatchmansFindings(t *testing.T) {
if f := o.findings[k]; f.Kind != KindStuck || !strings.Contains(f.Summary, "the ask ask1 to end on n1") {
t.Fatalf("findings = %v", o.findings)
}
w.Step("go", "n1.ask1", endedStep("ask1", delivery.StepPassed, t0), Put)
w.Step("go", "n1.ask1", endedStep("ask1", buildstep.StepPassed, t0), Put)
}, map[string]string{}},
{"WM2 S1: a node offline past its bound is silent, and back online it is not", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.Connect("node.ace")
@@ -406,7 +411,7 @@ func TestTheWatchmansFindings(t *testing.T) {
}, map[string]string{}},
{"WM3 S21: a heard machine's have failed for one apply interval is apply-failing, naming the package manager; applied again it ends", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.Connect("node.ace")
w.Have("ace.web", haveOf(t, delivery.PhaseFailed, delivery.HealthUnknown, "refused: the package manager (pacman) does not answer"), Put)
w.Have("ace.web", haveOf(t, nodeengineclient.PhaseFailed, nodeengineclient.HealthUnknown, "refused: the package manager (pacman) does not answer"), Put)
if len(o.findings) != 0 {
t.Fatalf("raised before its bound: %v", o.findings)
}
@@ -414,7 +419,7 @@ func TestTheWatchmansFindings(t *testing.T) {
if f := o.findings["machine.ace.apply-failing"]; !strings.Contains(f.Summary, "pacman does not answer") || f.Headline != "ace cannot install packages" {
t.Fatalf("findings = %v", o.findings)
}
w.Have("ace.web", haveOf(t, delivery.PhaseRunning, delivery.HealthOK, ""), Put)
w.Have("ace.web", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthOK, ""), Put)
}, map[string]string{}},
{"WM3 S21: a provisioner client's failed files are apply-failing past the bound; a silent machine's are not", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.Connect("node.ace")
@@ -426,23 +431,23 @@ func TestTheWatchmansFindings(t *testing.T) {
w.Provisioned(ProvReport{Node: "ace", Module: "web", Status: "applied", At: t0})
}, map[string]string{}},
{"WM3: unhealthy once a failed have is confirmed after a minute (written once and steady); recovered within it none; urgent after four hours", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.Have("ace.web", haveOf(t, delivery.PhaseRunning, delivery.HealthFailed, "the unit failed"), Put)
w.Have("ace.web", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthFailed, "the unit failed"), Put)
if len(o.findings) != 0 {
t.Fatalf("raised before its bound: %v", o.findings)
}
w.Have("ace.quick", haveOf(t, delivery.PhaseRunning, delivery.HealthFailed, "blip"), Put)
w.Have("ace.quick", haveOf(t, delivery.PhaseRunning, delivery.HealthOK, ""), Put) // recovered within the bound
w.Have("ace.quick", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthFailed, "blip"), Put)
w.Have("ace.quick", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthOK, ""), Put) // recovered within the bound
expire(w, c, signalKey("unhealthy-confirm", "quick.ace"), UnhealthyConfirmAfter)
expire(w, c, signalKey("unhealthy-confirm", "web.ace"), UnhealthyConfirmAfter) // the one write, never repeated
if o.findings["module.web.ace.unhealthy"].Severity != Warning || len(o.findings) != 1 {
t.Fatalf("findings = %v", o.findings)
}
expire(w, c, signalKey("unhealthy", "web.ace"), UnhealthyUrgentAfter)
w.Have("ace.mail", haveOf(t, delivery.PhaseRunning, delivery.HealthWaits, "a part waits for the secret token"), Put) // needs-operator is not observed here
w.Have("ace.mail", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthWaits, "a part waits for the secret token"), Put) // needs-operator is not observed here
}, map[string]string{"module.web.ace.unhealthy": Urgent}},
{"WM3: a warning unhealthy finding read back at the start is kept as it was, urgent when its four hours from Since are out", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
seedUnhealthy(t, w, o, Warning, t0.Add(-time.Hour))
w.Have("ace.web", haveOf(t, delivery.PhaseRunning, delivery.HealthFailed, "x"), Put)
w.Have("ace.web", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthFailed, "x"), Put)
w.Reconcile()
if o.findings["module.web.ace.unhealthy"].Severity != Warning {
t.Fatalf("cleared: findings %v", o.findings)
@@ -457,14 +462,14 @@ func TestTheWatchmansFindings(t *testing.T) {
}, map[string]string{"module.web.ace.unhealthy": Urgent}},
{"WM3: an urgent unhealthy finding read back at the start stays urgent, with no signal restarted", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
seedUnhealthy(t, w, o, Urgent, t0.Add(-5*time.Hour))
w.Have("ace.web", haveOf(t, delivery.PhaseRunning, delivery.HealthFailed, "x"), Put)
w.Have("ace.web", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthFailed, "x"), Put)
w.Reconcile()
if o.findings["module.web.ace.unhealthy"].Severity != Urgent || len(w.signals) != 0 {
t.Fatalf("findings %v signals %v", o.findings, w.signals)
}
}, map[string]string{"module.web.ace.unhealthy": Urgent}},
{"WM3: a restart replays a have already failed: it is confirmed after a minute like a new one", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.Have("novox.fail2ban", haveOf(t, delivery.PhaseRunning, delivery.HealthFailed, "down since 19:40"), Put)
w.Have("novox.fail2ban", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthFailed, "down since 19:40"), Put)
expire(w, c, signalKey("unhealthy-confirm", "fail2ban.novox"), UnhealthyConfirmAfter)
}, map[string]string{"module.fail2ban.novox.unhealthy": Warning}},
{"1954/D2: a provision in waits is its consumer's needs-operator; provided, it ends", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
@@ -486,22 +491,22 @@ func TestTheWatchmansFindings(t *testing.T) {
w.Provision("ace.web.oidc", nil, Delete)
}, map[string]string{}},
{"774: a node's own report: its network, the resolver file rewritten, failed units, a relogin, a directory used as found, a login fallen back", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
report := func(h delivery.NodeHealth) []byte {
b, err := delivery.EncodeNodeHealth(h)
report := func(h nodeengine.HealthByNodeValue) []byte {
b, err := nodeengineclient.EncodeHealth(h)
if err != nil {
t.Fatal(err)
}
return b
}
w.Have("novox.nats", haveOf(t, delivery.PhaseRunning, delivery.HealthOK, ""), Put) // the control node: the bus node
w.NodeHealth("novox", report(delivery.NodeHealth{Node: "novox", Network: &delivery.NetworkHealth{State: "unhealthy", Parts: []delivery.NetworkPart{
w.Have("novox.nats", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthOK, ""), Put) // the control node: the bus node
w.NodeHealth("novox", report(nodeengine.HealthByNodeValue{Node: "novox", Network: &nodeengine.NetworkHealth{State: "unhealthy", Parts: []nodeengine.NetworkPart{
{Part: "resolv-conf", State: "unhealthy", Writer: "NetworkManager", Owner: "systemd-resolved", Said: "rewritten"},
{Part: "names", State: "unhealthy", Reason: "names do not resolve.", Said: "x"},
{Part: "route", State: "unhealthy", Reason: "no default route", Said: "y"}}},
Units: &delivery.UnitsHealth{State: "degraded", Failed: []delivery.FailedUnit{{Unit: "cups.service", Scope: "system", Result: "exit-code"}}},
Relogin: []delivery.NodeAccount{{Account: "jochen", Module: "docker", Reason: "a group"}},
Found: []delivery.NodeDirectory{{Module: "photos", Resource: "data", Path: "/srv/photos", Reason: "owner differs"}},
Fallback: &delivery.LoginFallback{Node: "novox", From: "node.novox--b", Why: "refused twice", At: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)}}), Put)
Units: &nodeengine.UnitsHealth{State: "degraded", Failed: []nodeengine.FailedUnit{{Unit: "cups.service", Scope: "system", Result: "exit-code"}}},
Relogin: []nodeengine.NodeAccount{{Account: "jochen", Module: "docker", Reason: "a group"}},
Found: []nodeengine.NodeDirectory{{Module: "photos", Resource: "data", Path: "/srv/photos", Reason: "owner differs"}},
Fallback: &nodeengine.LoginFallback{Node: "novox", From: "node.novox--b", Why: "refused twice", At: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)}}), Put)
for key, sev := range map[string]string{"machine.novox.network": Urgent, "machine.novox.systemd-resolved.rewritten": Urgent,
"machine.novox.units": Warning, "module.docker.novox.relogin-needed": Warning, "module.photos.novox.directory-used-as-found": Warning,
"machine.novox.node-account-fallback": Warning} {
@@ -512,12 +517,12 @@ func TestTheWatchmansFindings(t *testing.T) {
if f := o.findings["machine.novox.network"]; strings.Contains(f.Summary, "names") {
t.Errorf("the names failing through the rewritten file are said twice: %s", f.Summary)
}
w.NodeHealth("novox", report(delivery.NodeHealth{Node: "novox"}), Put) // all well again
w.NodeHealth("novox", report(nodeengine.HealthByNodeValue{Node: "novox"}), Put) // all well again
}, map[string]string{}},
{"774: a failing part is held at the machines it points at, as the controller holds it", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
toward := func(node, part string, targets ...string) []byte {
b, _ := delivery.EncodeNodeHealth(delivery.NodeHealth{Node: node, Network: &delivery.NetworkHealth{State: "unhealthy",
Parts: []delivery.NetworkPart{{Part: part, State: "unhealthy", Reason: part + " fails", Said: "x", Targets: targets}}}})
b, _ := nodeengineclient.EncodeHealth(nodeengine.HealthByNodeValue{Node: node, Network: &nodeengine.NetworkHealth{State: "unhealthy",
Parts: []nodeengine.NetworkPart{{Part: part, State: "unhealthy", Reason: part + " fails", Said: "x", Targets: targets}}}})
return b
}
// own part: one machine alone failing toward a healthy one is its own.
@@ -555,11 +560,11 @@ func TestTheWatchmansFindings(t *testing.T) {
}, map[string]string{}},
{"774: a node two nodes' tunnels no longer see is unreachable, said once there; not while it is silent", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
for node, key := range map[string]string{"ace": "kA", "g14": "kG", "lap": "kL"} {
b, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node, TunnelKey: key}})
b, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: node, TunnelKey: key}})
w.Have(node+".node", b, Put)
}
peers := func(from string, seen bool) []byte {
b, _ := delivery.EncodeNodeHealth(delivery.NodeHealth{Node: from, Peers: []delivery.PeerSeen{{Key: "kL", Seen: seen}}})
b, _ := nodeengineclient.EncodeHealth(nodeengine.HealthByNodeValue{Node: from, Peers: []nodeengine.PeerSeen{{Key: "kL", Seen: seen}}})
return b
}
w.NodeHealth("ace", peers("ace", false), Put)
@@ -580,21 +585,21 @@ func TestTheWatchmansFindings(t *testing.T) {
w.NodeHealth("g14", peers("g14", true), Put)
}, map[string]string{}},
{"WM3: a have removed ends its findings", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.Have("ace.mail", haveOf(t, delivery.PhaseRunning, delivery.HealthFailed, "x"), Put)
w.Have("ace.mail", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthFailed, "x"), Put)
expire(w, c, signalKey("unhealthy-confirm", "mail.ace"), UnhealthyConfirmAfter)
w.Have("ace.mail", haveOf(t, delivery.PhaseRemoved, delivery.HealthUnknown, ""), Put)
w.Have("ace.mail", haveOf(t, nodeengineclient.PhaseRemoved, nodeengineclient.HealthUnknown, ""), Put)
}, map[string]string{}},
{"WM2 S1: a control node silent is urgent after half an hour", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
// 1126 (ADR 0590): the control node is the bus node, the one whose have holds the bus module; a have of the
// ended controller names none.
w.Have("g14.mesh-controller", haveOf(t, delivery.PhaseRunning, delivery.HealthOK, ""), Put)
w.Have("g14.mesh-controller", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthOK, ""), Put)
w.Connect("node.g14")
w.Disconnect("node.g14")
expire(w, c, pulseKeyOf(t, presenceNS, "g14"), 120*time.Second)
if _, ok := w.signals[signalKey("S1", "g14")]; ok {
t.Fatalf("a node running the controller's have is held a control node")
}
w.Have("novox.nats", haveOf(t, delivery.PhaseRunning, delivery.HealthOK, ""), Put)
w.Have("novox.nats", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthOK, ""), Put)
w.Connect("node.novox")
w.Disconnect("node.novox")
expire(w, c, pulseKeyOf(t, presenceNS, "novox"), 120*time.Second)
@@ -638,7 +643,7 @@ func TestTheWatchmansFindings(t *testing.T) {
}
}, map[string]string{}},
{"WM4: resync after a cleared bucket writes the same findings", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.Have("ace.mail", haveOf(t, delivery.PhaseRunning, delivery.HealthFailed, "x"), Put)
w.Have("ace.mail", haveOf(t, nodeengineclient.PhaseRunning, nodeengineclient.HealthFailed, "x"), Put)
expire(w, c, signalKey("unhealthy-confirm", "mail.ace"), UnhealthyConfirmAfter)
o.findings = map[string]Finding{}
if n := w.Rewrite(); n != 1 {
@@ -667,26 +672,26 @@ func TestTheWatchmansFindings(t *testing.T) {
}, map[string]string{"bus.SEAT_X.y_worker.max-deliveries": Warning, "bus.DEAD_LETTER_NOTICES.controller.notices-waiting": Warning}},
{"ADR 0603 §8: a want held by a rule, a provision asked and a build queued behind a package each raise a finding naming the wait past their bound, and clear when it ends", func(t *testing.T, w *Watchman, o *fakeOut, c *clock) {
w.SetStepBound(30 * time.Minute)
held, _ := delivery.EncodeWant(delivery.Want{FP: fpOld, Self: true, Newest: fpNew, Rule: delivery.RuleCanary, Words: "the canary ace does not run it yet"})
released, _ := delivery.EncodeWant(delivery.Want{FP: fpNew, Self: true})
held, _ := meshwantsclient.EncodeWant(meshwantsclient.Want{Fp: fpOld, Self: true, Newest: fpNew, Rule: meshwantsclient.RuleCanary, Words: "the canary ace does not run it yet"})
released, _ := meshwantsclient.EncodeWant(meshwantsclient.Want{Fp: fpNew, Self: true})
asked, _ := provisions.Encode(provisions.Provision{Provision: "oidc", Consumer: provisions.End{Module: "photos", Node: "ace"},
Provider: provisions.End{Module: "keycloak", Node: "novox"}, State: provisions.Asked, At: t0})
provided, _ := provisions.Encode(provisions.Provision{Provision: "oidc", Consumer: provisions.End{Module: "photos", Node: "ace"},
Provider: provisions.End{Module: "keycloak", Node: "novox"}, State: provisions.Provided, At: t0})
bkey, _ := delivery.BuilderBuildKey("web", fpNew)
queued, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildQueued, Waits: "mesh-sdk@v0.1.14"})
building, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildBuilding, By: "n1"})
bkey, _ := builds.BuilderBuildKey("web", fpNew)
queued, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildQueued, Waits: "mesh-sdk@v0.1.14"})
building, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildBuilding, By: "n1"})
w.Have("ace.web", haveValue(t, fpOld), Put) // the node runs what the held want names: the hold is still the wait
w.Want("want.ace.web", held, Put, t0)
w.Provision("ace.photos.oidc", asked, Put)
w.Build(bkey, queued, Put, t0)
waits := map[string]string{delivery.AssignerWantsBucket: "want.ace.web", provisions.ProvisionsBucket: "ace.photos.oidc", delivery.BuilderBuildsBucket: bkey}
waits := map[string]string{AssignerWantsBucket: "want.ace.web", provisions.ProvisionsBucket: "ace.photos.oidc", BuilderBuildsBucket: bkey}
if len(o.pulses) != 3 || len(o.findings) != 0 {
t.Fatalf("pulses %v findings %v", o.pulses, o.findings)
}
// The keys are written again as their parts move on, with new words and a new time: the wait still began at t0.
c.t = c.t.Add(20 * time.Minute)
held2, _ := delivery.EncodeWant(delivery.Want{FP: fpOld, Self: true, Newest: fpNew, Rule: delivery.RuleCanary, Words: "the canary ace runs it, not healthy yet"})
held2, _ := meshwantsclient.EncodeWant(meshwantsclient.Want{Fp: fpOld, Self: true, Newest: fpNew, Rule: meshwantsclient.RuleCanary, Words: "the canary ace runs it, not healthy yet"})
asked2, _ := provisions.Encode(provisions.Provision{Provision: "oidc", Consumer: provisions.End{Module: "photos", Node: "ace"},
Provider: provisions.End{Module: "keycloak", Node: "novox"}, State: provisions.Asked, Why: "the provider is applying", At: c.t})
w.Want("want.ace.web", held2, Put, c.t)
@@ -4,13 +4,19 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
// A build ask taken by a node is a move (novox/hq ADR 0525 §4, issues 1887 A8b, 1908, 1909): the node writes the ask's step
// key `<node>.<ask id>` running in the build module's steps (delivery.StepsBucket) when it takes the ask, and writes it passed,
// key `<node>.<ask id>` running in the build module's steps (StepsBucket) when it takes the ask, and writes it passed,
// failed or refused when the ask ends. The pulse of the move is the pulse of the bucket named as the seat's stream
// (delivery.AskStream), under the ask's id, and it begins when the node started the step.
// (buildstep.AskStream), under the ask's id, and it begins when the node started the step.
//
// **An ask still waiting in the stream is a backlog, not a stall** (issue 1908): the rebuild after the switch puts about 170
// asks on a few nodes' slots, and most wait for hours with every worker healthy. No pulse is made for a waiting ask, so none
@@ -22,12 +28,12 @@ import (
// stand, then each change). A running step makes the pulse of its ask, from the step's own start; a step that ended, or a
// key deleted, ends it. The node's `paused.<node>` key, a key that is no step key and a value that cannot be read are left.
func (w *Watchman) Step(language, key string, value []byte, op Op) {
bucket, err := delivery.AskStream(language)
bucket, err := buildstep.AskStream(language)
if err != nil {
w.say("no pulse for the step %s: %v", key, err)
return
}
node, id, err := delivery.ParseStepKey(key)
node, id, err := buildstep.ParseStepKey(key)
if err != nil {
return
}
@@ -41,12 +47,12 @@ func (w *Watchman) Step(language, key string, value []byte, op Op) {
return
}
w.stands(bucket, id)
step, err := delivery.DecodeStep(value)
step, err := buildstep.DecodeStep(value)
if err != nil {
w.say("the step %s cannot be read, and is left: %v", key, err)
return
}
if step.State != delivery.StepRunning {
if step.State != buildstep.StepRunning {
w.moved(bucket, id)
return
}
@@ -67,7 +73,7 @@ func (w *Watchman) Step(language, key string, value []byte, op Op) {
// Want is a change of a key of the assigner's wants bucket, read from the watch (at the start the keys that stand, then each
// change), at the time the entry was written.
func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) {
node, module, err := delivery.ParseWantKey(key)
node, module, err := meshwantsclient.ParseWantKey(key)
if err != nil {
return
}
@@ -79,11 +85,11 @@ func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) {
case Delete:
delete(w.wants, key)
delete(w.held, key)
w.moved(delivery.AssignerWantsBucket, key)
w.moved(AssignerWantsBucket, key)
return
}
w.stands(delivery.AssignerWantsBucket, key)
want, err := delivery.DecodeWant(value)
w.stands(AssignerWantsBucket, key)
want, err := meshwantsclient.DecodeWant(value)
if err != nil {
w.say("the want %s cannot be read, and is left: %v", key, err)
return
@@ -91,10 +97,10 @@ func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) {
if want.Removed {
delete(w.wants, key)
delete(w.held, key)
w.moved(delivery.AssignerWantsBucket, key)
w.moved(AssignerWantsBucket, key)
return
}
w.wants[key] = want.FP
w.wants[key] = want.Fp
if want.Newest != "" || want.Rule != 0 {
// A want held by a rule (ADR 0603 §8) waits on the rule, not on the node: its wait is the hold's, from the time the
// assigner wrote it, and the node running what the want names does not end it.
@@ -102,27 +108,27 @@ func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) {
at = w.now()
}
w.held[key] = true
w.mirrorKept(delivery.AssignerWantsBucket, key,
describe("the hold on %s on %s at %s (rule %d) to end, bound %s: %s", module, node, shortFP(want.FP), want.Rule, w.stepBound, want.Words),
"held|"+want.FP+"|"+want.Newest, at)
w.mirrorKept(AssignerWantsBucket, key,
describe("the hold on %s on %s at %s (rule %d) to end, bound %s: %s", module, node, shortFP(want.Fp), want.Rule, w.stepBound, want.Words),
"held|"+want.Fp+"|"+want.Newest, at)
return
}
if w.held[key] {
delete(w.held, key)
if pk, err := delivery.PulseKey(delivery.AssignerWantsBucket, key); err == nil {
if pk, err := meshwatchdogsclient.PulseKey(AssignerWantsBucket, key); err == nil {
delete(w.starts, pk)
}
w.stuckMoved(delivery.AssignerWantsBucket, key) // the hold ended: its finding is cleared
w.stuckMoved(AssignerWantsBucket, key) // the hold ended: its finding is cleared
}
if w.have(node, module) == want.FP {
w.moved(delivery.AssignerWantsBucket, key)
if w.have(node, module) == want.Fp {
w.moved(AssignerWantsBucket, key)
return
}
if at.IsZero() {
at = w.now()
}
w.mirrorWithin(w.stepBound, delivery.AssignerWantsBucket, key,
describe("the node agent of %s to run %s at %s", node, module, shortFP(want.FP)), want.FP, at, 0)
w.mirrorWithin(w.stepBound, AssignerWantsBucket, key,
describe("the node agent of %s to run %s at %s", node, module, shortFP(want.Fp)), want.Fp, at, 0)
}
// A build key queued behind a package is a wait of the builder's (ADR 0603 §8, ADR 0500 clause 6): its Waits names the package
@@ -133,7 +139,7 @@ func (w *Watchman) Want(key string, value []byte, op Op, at time.Time) {
// Build is a change of a key of the builder's builds bucket, read from the watch (at the start the keys that stand, then
// each change), at the time the entry was written.
func (w *Watchman) Build(key string, value []byte, op Op, at time.Time) {
module, fp, err := delivery.ParseBuilderBuildKey(key)
module, fp, err := builds.ParseBuilderBuildKey(key)
if err != nil {
return
}
@@ -143,30 +149,30 @@ func (w *Watchman) Build(key string, value []byte, op Op, at time.Time) {
return
}
if op == Delete {
w.moved(delivery.BuilderBuildsBucket, key)
w.moved(BuilderBuildsBucket, key)
return
}
w.stands(delivery.BuilderBuildsBucket, key)
b, err := delivery.DecodeBuilderBuild(value)
w.stands(BuilderBuildsBucket, key)
b, err := builds.DecodeBuilderBuild(value)
if err != nil {
w.say("the build %s cannot be read, and is left: %v", key, err)
return
}
if b.State != delivery.BuildQueued || b.Waits == "" {
w.moved(delivery.BuilderBuildsBucket, key)
if b.State != builds.BuildQueued || b.Waits == "" {
w.moved(BuilderBuildsBucket, key)
return
}
if at.IsZero() {
at = w.now()
}
w.mirrorKept(delivery.BuilderBuildsBucket, key,
w.mirrorKept(BuilderBuildsBucket, key,
describe("the package %s to be published before the build of %s at %s, bound %s", b.Waits, module, shortFP(fp), w.stepBound), b.Waits, at)
}
// Have is a change of a key of the node-engine's have bucket: the fingerprint a node runs of a module. One that names the
// fingerprint of the node's want for the module ends the want's pulse. A node's own key and a key deleted are left.
func (w *Watchman) Have(key string, value []byte, op Op) {
node, module, isNode, err := delivery.ParseHaveKey(key)
node, module, isNode, err := nodeengineclient.ParseHaveKey(key)
if err != nil {
return
}
@@ -174,9 +180,9 @@ func (w *Watchman) Have(key string, value []byte, op Op) {
defer w.mu.Unlock()
if isNode {
// The node's own record: its tunnel key, whose peers' sight of it is network-unreachable.
var record *delivery.NodeRecord
var record *nodeengine.NodeRecord
if op == Put {
if hn, err := delivery.DecodeHaveNode(value); err == nil {
if hn, err := nodeengineclient.DecodeHaveNode(value); err == nil {
record = hn.Record
}
}
@@ -191,24 +197,24 @@ func (w *Watchman) Have(key string, value []byte, op Op) {
w.haveFindings(key, node, module, nil)
return
}
have, err := delivery.DecodeHave(value)
have, err := nodeengineclient.DecodeHave(value)
if err != nil {
w.say("the have %s cannot be read, and is left: %v", key, err)
return
}
w.haves[key] = have.FP
w.haves[key] = have.Fp
w.haveFindings(key, node, module, &have)
wk, err := delivery.WantKey(node, module)
wk, err := meshwantsclient.WantKey(node, module)
if err != nil {
return
}
if fp, ok := w.wants[wk]; ok && fp == have.FP {
w.moved(delivery.AssignerWantsBucket, wk)
if fp, ok := w.wants[wk]; ok && fp == have.Fp {
w.moved(AssignerWantsBucket, wk)
}
}
func (w *Watchman) have(node, module string) string {
key, err := delivery.HaveKey(node, module)
key, err := nodeengineclient.HaveKey(node, module)
if err != nil {
return ""
}
@@ -216,7 +222,7 @@ func (w *Watchman) have(node, module string) string {
}
func shortFP(fp string) string {
hex := strings.TrimPrefix(fp, delivery.SourcePrefix2)
hex := strings.TrimPrefix(fp, meshcatalogclient.SourcePrefix2)
if len(hex) > 12 {
hex = hex[:12]
}
@@ -0,0 +1,40 @@
package watch
import (
meshwatchdogs "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
)
// The holders and buckets the watchman names, taken from its own manifest (module.json: its name, its `reads`, its
// states and the tools it invokes) and the seats' state names; a unit row checks them against it (TestManifestStateAndReads).
// No contract names a holder (novox/hq ADR 0625 §4, ADR 0634 §1).
const (
WatchmanModule = "mesh-delivery-watchman"
AssignerModule = "mesh-delivery-assigner"
builderModule = "mesh-delivery-builder"
// The watchman's own states of the seat mesh-watchdogs.
PresenceBucket = WatchmanModule + "_" + meshwatchdogs.StatePresence
PulseBucket = WatchmanModule + "_" + meshwatchdogs.StatePulse
StuckBucket = WatchmanModule + "_" + meshwatchdogs.StateStuck
// HaveBucket and NodeHealthBucket are the node-engine's states have and health (reads: node-engine.have, mesh-host.health).
HaveBucket = "node-engine_have"
NodeHealthBucket = "mesh-host_health"
// BuilderBuildsBucket is the builder's state builds (reads: mesh-delivery-builder.builds).
BuilderBuildsBucket = builderModule + "_builds"
// AssignerWantsBucket is the assigner's state wants, followed by its views family (consumes: mesh-delivery-assigner.views.wants).
AssignerWantsBucket = AssignerModule + "_wants"
// ModulesBucket is the indexer's state modules, followed by its views family (consumes: mesh-delivery-indexer.views.modules).
ModulesBucket = meshcatalogclient.Module + "_modules"
)
// The findings a value of another part is raised as, named by their words.
const (
FindingManifestOutgrown = "manifest-outgrown"
FindingManifestWaits = "manifest-waits"
FindingNodeAccountFallback = "node-account-fallback"
)
// StepsBucket is a build module's state steps (reads: mesh-build-go.steps, mesh-build-image.steps, mesh-build-node.steps).
func StepsBucket(module string) string { return module + "_steps" }
@@ -8,7 +8,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
)
// A node's own health report (novox/hq ADR 0241, issue 315, the operator's alert table of 2026-10-29): each node's engine
@@ -38,7 +39,7 @@ const (
KindMachineUnits = "machine-units"
KindReloginNeeded = "relogin-needed"
KindUsedAsFound = "directory-used-as-found"
KindAccountFallback = delivery.NodeAccountFallbackFinding
KindAccountFallback = FindingNodeAccountFallback
)
// The parts and states a report names (mesh-control internal/link).
@@ -57,7 +58,7 @@ func (w *Watchman) NodeHealth(node string, value []byte, op Op) {
w.mu.Lock()
defer w.mu.Unlock()
if op == Put {
h, err := delivery.DecodeNodeHealth(value)
h, err := nodeengineclient.DecodeHealth(value)
if err != nil {
w.say("the health report of %s cannot be read, and is left: %v", node, err)
return
@@ -71,7 +72,7 @@ func (w *Watchman) NodeHealth(node string, value []byte, op Op) {
}
// TunnelKey is a node's own tunnel key, from its record in its have: whose peers see it is read by it.
func (w *Watchman) tunnelKey(node string, record *delivery.NodeRecord) {
func (w *Watchman) tunnelKey(node string, record *nodeengine.NodeRecord) {
key := ""
if record != nil {
key = record.TunnelKey
@@ -125,7 +126,7 @@ func (w *Watchman) ownsHealthKey(key, node string) bool {
return f.Kind != KindNetworkUnreachable && f.Kind != KindMachineNetwork && f.Kind != KindNetworkRewritten
}
func (w *Watchman) rewrittenOf(m string, p delivery.NetworkPart, all []delivery.NetworkPart) Finding {
func (w *Watchman) rewrittenOf(m string, p nodeengine.NetworkPart, all []nodeengine.NetworkPart) Finding {
writer := ""
if p.Writer != "" {
writer = " (" + p.Writer + ")"
@@ -156,7 +157,7 @@ func (w *Watchman) rewrittenOf(m string, p delivery.NetworkPart, all []delivery.
}
// unitsOf is a machine's failed units no module places, one finding.
func unitsOf(node string, u *delivery.UnitsHealth) (Finding, bool) {
func unitsOf(node string, u *nodeengine.UnitsHealth) (Finding, bool) {
if u == nil || len(u.Failed) == 0 {
return Finding{}, false
}
@@ -202,8 +203,8 @@ func unitsOf(node string, u *delivery.UnitsHealth) (Finding, bool) {
}
// reloginOf is one finding per module whose accounts wait for a new login.
func reloginOf(node string, accounts []delivery.NodeAccount) []Finding {
by := map[string][]delivery.NodeAccount{}
func reloginOf(node string, accounts []nodeengine.NodeAccount) []Finding {
by := map[string][]nodeengine.NodeAccount{}
for _, a := range accounts {
by[a.Module] = append(by[a.Module], a)
}
@@ -227,8 +228,8 @@ func reloginOf(node string, accounts []delivery.NodeAccount) []Finding {
}
// foundOf is one finding per module with a directory used as found.
func foundOf(node string, dirs []delivery.NodeDirectory) []Finding {
by := map[string][]delivery.NodeDirectory{}
func foundOf(node string, dirs []nodeengine.NodeDirectory) []Finding {
by := map[string][]nodeengine.NodeDirectory{}
for _, d := range dirs {
by[d.Module] = append(by[d.Module], d)
}
@@ -257,7 +258,7 @@ func foundOf(node string, dirs []delivery.NodeDirectory) []Finding {
}
// fallbackOf is a node's fall back to its old login during a rotation of its account (ADR 0583 §6), one finding.
func fallbackOf(node string, f *delivery.LoginFallback) (Finding, bool) {
func fallbackOf(node string, f *nodeengine.LoginFallback) (Finding, bool) {
if f == nil || f.Node != node {
return Finding{}, false
}
@@ -279,19 +280,19 @@ func orModule(m string) string {
// pointed is one machine's failing part that points at another machine.
type pointed struct {
from string
part delivery.NetworkPart
part nodeengine.NetworkPart
}
// networks is every machine's network findings from every report together (the controller's networkObservations): the
// resolver file rewritten, each machine's own unhealthy parts, and the machine others point at, held there.
func (w *Watchman) networks() {
machines := slices.Sorted(maps.Keys(w.health))
unhealthy := func(m string) []delivery.NetworkPart {
unhealthy := func(m string) []nodeengine.NetworkPart {
h := w.health[m]
if h.Network == nil {
return nil
}
var out []delivery.NetworkPart
var out []nodeengine.NetworkPart
for _, p := range h.Network.Parts {
if p.State == stateUnhealthy {
out = append(out, p)
@@ -299,7 +300,7 @@ func (w *Watchman) networks() {
}
return out
}
targets := func(m string, p delivery.NetworkPart) ([]string, bool) {
targets := func(m string, p nodeengine.NetworkPart) ([]string, bool) {
var out []string
for _, x := range p.Targets {
if x == "" || x == m {
@@ -329,7 +330,7 @@ func (w *Watchman) networks() {
for _, m := range machines {
for _, p := range w.health[m].Peers {
if x, ok := byKey[p.Key]; ok && x != m && !p.Seen {
pointing[x] = append(pointing[x], pointed{from: m, part: delivery.NetworkPart{Part: partTunnel, State: stateUnhealthy,
pointing[x] = append(pointing[x], pointed{from: m, part: nodeengine.NetworkPart{Part: partTunnel, State: stateUnhealthy,
Said: "no handshake with " + x + " within five minutes", Targets: []string{x}}})
}
}
@@ -364,8 +365,8 @@ func (w *Watchman) networks() {
if len(parts) == 0 {
continue
}
var own []delivery.NetworkPart
var rewritten *delivery.NetworkPart
var own []nodeengine.NetworkPart
var rewritten *nodeengine.NetworkPart
for i, p := range parts {
if p.Part == partResolvConf {
rewritten = &parts[i]
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
)
// partPrefix is what the watchman keeps presence of among the module users: the parts of delivery.
@@ -21,7 +21,7 @@ func presenceOf(user string) (key, waited string, ok bool) {
return "", "", false
}
if first == "node" {
k, err := delivery.PresenceNodeKey(rest)
k, err := meshwatchdogsclient.PresenceNodeKey(rest)
if err != nil {
return "", "", false
}
@@ -30,7 +30,7 @@ func presenceOf(user string) (key, waited string, ok bool) {
if !strings.HasPrefix(rest, partPrefix) {
return "", "", false
}
k, err := delivery.PresencePartKey(rest, first)
k, err := meshwatchdogsclient.PresencePartKey(rest, first)
if err != nil {
return "", "", false
}
@@ -39,7 +39,7 @@ func presenceOf(user string) (key, waited string, ok bool) {
// waitedOf is the words a presence key's return is waited in.
func waitedOf(key string) string {
node, part, instance, err := delivery.ParsePresenceKey(key)
node, part, instance, err := meshwatchdogsclient.ParsePresenceKey(key)
switch {
case err != nil:
return key + " to come back online"
@@ -87,11 +87,11 @@ func (w *Watchman) settle(key string) {
return
}
w.presence[key] = presence{online: online, since: w.now()}
if node, part, _, err := delivery.ParsePresenceKey(key); err == nil && part == "" && node != "" {
if node, part, _, err := meshwatchdogsclient.ParsePresenceKey(key); err == nil && part == "" && node != "" {
defer w.applyCheck(node) // S21 is judged only while the machine is heard
}
if online {
w.moved(delivery.PresenceBucket, key)
w.moved(PresenceBucket, key)
return
}
w.ensureOffline(key)
@@ -100,7 +100,7 @@ func (w *Watchman) settle(key string) {
// ensureOffline mirrors the return of a node or part that is offline.
func (w *Watchman) ensureOffline(key string) {
p := w.presence[key]
w.mirror(delivery.PresenceBucket, key, waitedOf(key), "", p.since, 0)
w.mirror(PresenceBucket, key, waitedOf(key), "", p.since, 0)
}
// SeedPresence tells the watchman a presence key as it stands in its bucket at start, with the time it was written.
@@ -8,7 +8,9 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
@@ -99,7 +101,7 @@ func (w *Watchman) signal(row, id string, ttl time.Duration, due signalDue) {
w.say("no signal %s: %v", pk, err)
return
}
value, err := delivery.EncodePulse(delivery.Pulse{Bucket: signalBucket, Key: strings.TrimPrefix(pk, signalBucket+"."), Owes: string(owes)})
value, err := meshwatchdogsclient.EncodePulse(meshwatchdogsclient.Pulse{Bucket: signalBucket, Key: strings.TrimPrefix(pk, signalBucket+"."), Owes: string(owes)})
if err != nil {
w.say("no signal %s: %v", pk, err)
return
@@ -128,7 +130,7 @@ func (w *Watchman) unsignal(row, id string) {
func (w *Watchman) SeedSignal(key string, value []byte) {
w.mu.Lock()
defer w.mu.Unlock()
if p, err := delivery.DecodePulse(value); err == nil && p.Bucket == signalBucket {
if p, err := meshwatchdogsclient.DecodePulse(value); err == nil && p.Bucket == signalBucket {
var due signalDue
if json.Unmarshal([]byte(p.Owes), &due) == nil && due.Do == dueCreated {
w.busCreated[key] = true
@@ -141,7 +143,7 @@ func (w *Watchman) SeedSignal(key string, value []byte) {
}
func dueOf(value []byte) (signalDue, bool) {
p, err := delivery.DecodePulse(value)
p, err := meshwatchdogsclient.DecodePulse(value)
if err != nil || p.Bucket != signalBucket {
return signalDue{}, false
}
@@ -189,9 +191,9 @@ func (w *Watchman) stillWrong(haveKey, kind string) bool {
}
switch kind {
case KindApplyFailing:
return h.Phase == delivery.PhaseFailed
return h.Phase == nodeengineclient.PhaseFailed
case KindUnhealthy:
return h.Health == delivery.HealthFailed
return h.Health == nodeengineclient.HealthFailed
}
return false
}
@@ -228,7 +230,7 @@ func (w *Watchman) Power(node, state string) {
defer w.mu.Unlock()
away := state == "sleeping" || state == "shutting-down"
w.away[node] = away
key, err := delivery.PresenceNodeKey(node)
key, err := meshwatchdogsclient.PresenceNodeKey(node)
if err != nil {
return
}
@@ -248,9 +250,9 @@ func (w *Watchman) Power(node, state string) {
// stuckFinding is the finding of a stuck key as it stands (ADR 0433 §2, §3, now the watchman's own): a node's presence not
// moved is S1, the machine silent, unless it said it is away; any other key not moved is `delivery.<key>.stuck`; a key
// moved ends its finding.
func (w *Watchman) stuckFinding(sk string, s delivery.Stuck) {
if s.Bucket == delivery.PresenceBucket {
if node, part, _, err := delivery.ParsePresenceKey(s.Key); err == nil && part == "" && node != "" {
func (w *Watchman) stuckFinding(sk string, s meshwatchdogsclient.Stuck) {
if s.Bucket == PresenceBucket {
if node, part, _, err := meshwatchdogsclient.ParsePresenceKey(s.Key); err == nil && part == "" && node != "" {
key := FindingKey(ScopeMachine, node, KindSilent)
defer w.networks() // a silent machine is down on the record: what points at it is held there, said by its silence
if s.Moved || w.away[node] {
@@ -474,12 +476,12 @@ func ToolsNode(subject string) string {
//
// needs-operator is not observed from the haves: a module's part that waits for the operator is a provision in the state
// `waits` of the seat mesh-provisioner (Provision, ADR 0570 §7).
func (w *Watchman) haveFindings(key string, node, module string, h *delivery.Have) {
func (w *Watchman) haveFindings(key string, node, module string, h *nodeengine.HaveByNodeModuleValue) {
unhealthyKey := FindingKey(ScopeModule, module+"."+node, "unhealthy")
if module == ControlNodeModule {
w.control[node] = h != nil && h.Phase != delivery.PhaseRemoved
w.control[node] = h != nil && h.Phase != nodeengineclient.PhaseRemoved
}
if h == nil || h.Phase == delivery.PhaseRemoved {
if h == nil || h.Phase == nodeengineclient.PhaseRemoved {
delete(w.haveState, key)
delete(w.unhealthy, key)
delete(w.failedSince, key)
@@ -495,7 +497,7 @@ func (w *Watchman) haveFindings(key string, node, module string, h *delivery.Hav
w.keptFinding(key, node, module, h.Kept)
// S21: a have refused or applied in part says phase failed, with the node's words in its detail.
if h.Phase == delivery.PhaseFailed {
if h.Phase == nodeengineclient.PhaseFailed {
if _, ok := w.failedSince[key]; !ok {
w.failedSince[key] = now
}
@@ -507,7 +509,7 @@ func (w *Watchman) haveFindings(key string, node, module string, h *delivery.Hav
// unhealthy: a have that says failed and still says it after UnhealthyConfirmAfter (ADR 0564 §4's confirmation, by time:
// a have written once and then unchanged never gets a second write). The confirm signal's expiry writes the warning; a
// signal already standing (kept across a restart) is not renewed, so a steady have does not push its own expiry out.
if h.Health == delivery.HealthFailed {
if h.Health == nodeengineclient.HealthFailed {
if w.unhealthy[key] == 0 {
w.unhealthy[key] = 1
f := Finding{Scope: ScopeModule, ID: module + "." + node, Token: "unhealthy", Kind: KindUnhealthy, Machine: node,
@@ -686,7 +688,7 @@ func (w *Watchman) Provisioned(r ProvReport) {
// heard says the watchman hears the machine now: online, and not said away. S21 is judged only while it is (the
// controller's watchApplyFailing): S1 says silent, and asleep is not lost.
func (w *Watchman) heard(node string) bool {
key, err := delivery.PresenceNodeKey(node)
key, err := meshwatchdogsclient.PresenceNodeKey(node)
if err != nil || w.away[node] {
return false
}
@@ -704,7 +706,7 @@ func (w *Watchman) applyCheck(node string) {
var applied, placed []failure
var since time.Time
for hk, at := range w.failedSince {
n, module, _, err := delivery.ParseHaveKey(hk)
n, module, _, err := nodeengineclient.ParseHaveKey(hk)
if err != nil || n != node {
continue
}
@@ -3,7 +3,7 @@ package watch
import (
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
)
// KindManifestRefused is a module whose manifest the indexer's judge refused (novox/hq ADR 0578 §1): every reader holds
@@ -12,14 +12,14 @@ const KindManifestRefused = "manifest-refused"
// KindManifestOutgrown is a module whose manifest the indexer accepted before and a stricter judge finds problems in
// (novox/hq ADR 0579 §1): it stays accepted and runs; the warning stands until the manifest is mended.
const KindManifestOutgrown = delivery.ManifestOutgrown
const KindManifestOutgrown = FindingManifestOutgrown
// KindManifestWaits is a module whose manifest carries a field the indexer's judge does not know yet (novox/hq ADR 0578
// §1, amended): every reader holds it as a refused one and keeps what runs, and the warning stands until a judge knows the
// field or the manifest is mended.
const KindManifestWaits = delivery.ManifestWaits
const KindManifestWaits = FindingManifestWaits
// ModuleVerdict is a change of the indexer's value of a module (delivery.ModulesBucket): a value carrying a refusal is a
// ModuleVerdict is a change of the indexer's value of a module (ModulesBucket): a value carrying a refusal is a
// finding, one that does not (accepted, or not judged yet) or a deleted key ends it.
func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) {
w.mu.Lock()
@@ -33,17 +33,17 @@ func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) {
w.unfind(waits)
return
}
m, err := delivery.DecodeModule(value)
m, err := meshcatalogclient.DecodeModule(value)
if err != nil {
w.say("the indexer's value of %s cannot be read, so whether its manifest is refused is left as it was: %v", module, err)
return
}
v := m.VerdictOf(module)
if v != nil && v.Waits() {
v := meshcatalogclient.ModuleVerdict(module, m)
if v != nil && meshcatalogclient.VerdictWaits(*v) {
w.unfind(key)
w.unfind(outgrown)
w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestWaits, Severity: Warning, Source: delivery.ModulesBucket,
Summary: v.Why(),
w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestWaits, Severity: Warning, Source: ModulesBucket,
Summary: meshcatalogclient.VerdictWhy(*v),
Said: strings.Join(v.Unknown, "; "),
Headline: "The manifest of " + module + " waits for a judge that knows its fields",
Explanation: "The manifest carries " + strings.Join(v.Unknown, ", ") + ", which the indexer's judge does not know yet, so every " +
@@ -53,13 +53,13 @@ func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) {
return
}
w.unfind(waits)
if refused, _ := delivery.TakeManifestVerdict(v); !refused {
if refused, _ := meshcatalogclient.TakeVerdict(v); !refused {
w.unfind(key)
if v == nil || len(v.Warnings) == 0 {
w.unfind(outgrown)
return
}
w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestOutgrown, Severity: Warning, Source: delivery.ModulesBucket,
w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestOutgrown, Severity: Warning, Source: ModulesBucket,
Summary: "the manifest of " + module + " breaks rules the indexer's judge holds it to now: " + strings.Join(v.Warnings, "; "),
Said: strings.Join(v.Warnings, "; "),
Headline: "The manifest of " + module + " needs mending",
@@ -69,8 +69,8 @@ func (w *Watchman) ModuleVerdict(module string, value []byte, op Op) {
return
}
w.unfind(outgrown)
w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestRefused, Severity: Warning, Source: delivery.ModulesBucket,
Summary: v.Why(),
w.find(Finding{Scope: ScopeModule, ID: module, Kind: KindManifestRefused, Severity: Warning, Source: ModulesBucket,
Summary: meshcatalogclient.VerdictWhy(*v),
Said: strings.Join(v.Problems, "; "),
Headline: "The manifest of " + module + " is refused",
Explanation: "The indexer's judge refused it, so every node, the grants part and the assigner hold " + module + ": " +
@@ -8,7 +8,9 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// Op is what happened to a watched key.
@@ -59,7 +61,7 @@ type Watchman struct {
counts map[string]int // open connections per presence key
presence map[string]presence
pulses map[string]*pulse // live pulses, by pulse key
stuck map[string]delivery.Stuck
stuck map[string]meshwatchdogsclient.Stuck
// keptSince is, by have key, when its have first said what the node keeps of the module as it runs (keptFinding).
keptSince map[string]time.Time
wants map[string]string // the fingerprint of each want that stands, by want key
@@ -83,7 +85,7 @@ type Watchman struct {
away map[string]bool
// haveState is each module's have as last read, unhealthy its writes in a row that said it failed, advised each
// advisory's count while its finding stands and advisedSince since when.
haveState map[string]delivery.Have
haveState map[string]nodeengine.HaveByNodeModuleValue
unhealthy map[string]int
advised map[string]int
advisedSince map[string]time.Time
@@ -97,7 +99,7 @@ type Watchman struct {
// waiting is, by `<consumer>.<node>`, each of its provisions in waits, with its words.
waiting map[string]map[string]string
// health is each node's own health report, and tunnels each node's tunnel key from its record.
health map[string]delivery.NodeHealth
health map[string]nodeengine.HealthByNodeValue
tunnels map[string]string
// stepBound is how long a build step may run once a node took the ask; a test shortens it.
stepBound time.Duration
@@ -115,11 +117,11 @@ type Watchman struct {
// New is a watchman writing to out, reading the time from now.
func New(out Out, now func() time.Time) *Watchman {
return &Watchman{out: out, now: now, counts: map[string]int{}, presence: map[string]presence{},
pulses: map[string]*pulse{}, stuck: map[string]delivery.Stuck{}, wants: map[string]string{}, haves: map[string]string{}, held: map[string]bool{}, starts: map[string]startMark{}, bound: delivery.PulseMoveBound, stepBound: StepBound,
pulses: map[string]*pulse{}, stuck: map[string]meshwatchdogsclient.Stuck{}, wants: map[string]string{}, haves: map[string]string{}, held: map[string]bool{}, starts: map[string]startMark{}, bound: meshwatchdogsclient.PulseMoveBound, stepBound: StepBound,
findings: map[string][]byte{}, seededFindings: map[string]bool{}, signals: map[string]signalDue{}, busCreated: map[string]bool{}, takeInHeld: map[string]Finding{}, away: map[string]bool{},
haveState: map[string]delivery.Have{}, unhealthy: map[string]int{}, advised: map[string]int{}, advisedSince: map[string]time.Time{},
haveState: map[string]nodeengine.HaveByNodeModuleValue{}, unhealthy: map[string]int{}, advised: map[string]int{}, advisedSince: map[string]time.Time{},
control: map[string]bool{}, failedSince: map[string]time.Time{}, provFailed: map[string]map[string]ProvReport{}, closures: map[string]map[string]time.Time{}, waiting: map[string]map[string]string{},
health: map[string]delivery.NodeHealth{}, tunnels: map[string]string{},
health: map[string]nodeengine.HealthByNodeValue{}, tunnels: map[string]string{},
standing: map[string]map[string]bool{}, readWhole: map[string]bool{}, awaited: map[string]bool{}}
}
@@ -138,9 +140,9 @@ func (w *Watchman) mirror(bucket, key, waited, head string, since time.Time, rev
w.mirrorWithin(0, bucket, key, waited, head, since, revision)
}
// mirrorWithin is mirror for a move that may take bound (zero: the watchman's own bound, delivery.PulseMoveBound).
// mirrorWithin is mirror for a move that may take bound (zero: the watchman's own bound, meshwatchdogsclient.PulseMoveBound).
func (w *Watchman) mirrorWithin(bound time.Duration, bucket, key, waited, head string, since time.Time, revision uint64) {
pk, err := delivery.PulseKey(bucket, key)
pk, err := meshwatchdogsclient.PulseKey(bucket, key)
if err != nil {
w.say("no pulse for %s %s: %v", bucket, key, err)
return
@@ -161,7 +163,7 @@ func (w *Watchman) mirrorWithin(bound time.Duration, bucket, key, waited, head s
w.writeStuck(bucket, key, waited, since)
return
}
value, err := delivery.EncodePulse(delivery.Pulse{Bucket: bucket, Key: key, Revision: revision, Owes: waited})
value, err := meshwatchdogsclient.EncodePulse(meshwatchdogsclient.Pulse{Bucket: bucket, Key: key, Revision: int64(revision), Owes: waited})
if err != nil {
w.say("no pulse for %s %s: %v", bucket, key, err)
return
@@ -183,7 +185,7 @@ type startMark struct {
// (a provision asked, a want held at the same fingerprints, a build waiting for the same package), a rewrite of the key (new
// words, a new time) does not start the bound again; the earliest start is kept. A different mark is a new wait.
func (w *Watchman) mirrorKept(bucket, key, waited, mark string, since time.Time) {
if pk, err := delivery.PulseKey(bucket, key); err == nil {
if pk, err := meshwatchdogsclient.PulseKey(bucket, key); err == nil {
if cur, ok := w.starts[pk]; ok && cur.mark == mark && cur.since.Before(since) {
since = cur.since
}
@@ -202,7 +204,7 @@ func (w *Watchman) deletePulse(pk string) {
// moved is the mirrored key moving: its pulse is deleted, so it never expires, and a stuck key it left is written as
// moved (never deleted).
func (w *Watchman) moved(bucket, key string) {
pk, err := delivery.PulseKey(bucket, key)
pk, err := meshwatchdogsclient.PulseKey(bucket, key)
if err != nil {
return
}
@@ -215,7 +217,7 @@ func (w *Watchman) moved(bucket, key string) {
// stuckMoved writes a stuck key of the key as moved, if it stands and is not yet.
func (w *Watchman) stuckMoved(bucket, key string) {
sk, err := delivery.StuckKey(bucket, key)
sk, err := meshwatchdogsclient.StuckKey(bucket, key)
if err != nil {
return
}
@@ -228,7 +230,7 @@ func (w *Watchman) stuckMoved(bucket, key string) {
}
func (w *Watchman) writeStuck(bucket, key, waited string, since time.Time) {
sk, err := delivery.StuckKey(bucket, key)
sk, err := meshwatchdogsclient.StuckKey(bucket, key)
if err != nil {
w.say("no stuck key for %s %s: %v", bucket, key, err)
return
@@ -236,11 +238,11 @@ func (w *Watchman) writeStuck(bucket, key, waited string, since time.Time) {
if cur, ok := w.stuck[sk]; ok && cur.Since.Equal(since) {
return
}
w.putStuck(sk, delivery.Stuck{Bucket: bucket, Key: key, Waited: waited, Since: since})
w.putStuck(sk, meshwatchdogsclient.Stuck{Bucket: bucket, Key: key, Waited: waited, Since: since})
}
func (w *Watchman) putStuck(sk string, s delivery.Stuck) {
value, err := delivery.EncodeStuck(s)
func (w *Watchman) putStuck(sk string, s meshwatchdogsclient.Stuck) {
value, err := meshwatchdogsclient.EncodeStuck(s)
if err != nil {
w.say("cannot encode the stuck key %s: %v", sk, err)
return
@@ -249,7 +251,7 @@ func (w *Watchman) putStuck(sk string, s delivery.Stuck) {
w.say("cannot write the stuck key %s: %v", sk, err)
return
}
s.V = delivery.Version
s.V = keys.Version
w.stuck[sk] = s
w.stuckFinding(sk, s)
}
@@ -278,7 +280,7 @@ func (w *Watchman) Pulse(key string, op Op, at time.Time) {
func (w *Watchman) SeedStuck(key string, value []byte) {
w.mu.Lock()
defer w.mu.Unlock()
if s, err := delivery.DecodeStuck(value); err == nil {
if s, err := meshwatchdogsclient.DecodeStuck(value); err == nil {
w.stuck[key] = s
w.stuckFinding(key, s)
}
@@ -289,7 +291,7 @@ func (w *Watchman) SeedStuck(key string, value []byte) {
func (w *Watchman) SeedPulse(key string) {
w.mu.Lock()
defer w.mu.Unlock()
bucket, k, err := delivery.ParsePulseKey(key)
bucket, k, err := meshwatchdogsclient.ParsePulseKey(key)
if err != nil || bucket == signalBucket {
return
}
@@ -351,7 +353,7 @@ func (w *Watchman) closeGone() {
w.standing, w.readWhole, w.reconciled = standing, map[string]bool{}, true
}
func (w *Watchman) closeStuck(sk string, s delivery.Stuck) {
func (w *Watchman) closeStuck(sk string, s meshwatchdogsclient.Stuck) {
s.Moved = true
w.say("the stuck key %s is closed: its key %s is no longer in %s (it aged out of the bucket or was deleted while nobody heard), so nothing waits for it since %s",
sk, s.Key, s.Bucket, s.Since.UTC().Format(time.RFC3339))
@@ -444,7 +446,7 @@ func (w *Watchman) SetStepBound(d time.Duration) {
w.stepBound = d
}
// SetBound sets how long a move, and a running step, may take; it is delivery.PulseMoveBound unless a test shortens it.
// SetBound sets how long a move, and a running step, may take; it is meshwatchdogsclient.PulseMoveBound unless a test shortens it.
func (w *Watchman) SetBound(d time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
@@ -452,10 +454,10 @@ func (w *Watchman) SetBound(d time.Duration) {
}
// ttl is the time to live of a pulse for a move of the given age, and whether the move is stalled already
// (delivery.PulseTTL, with the bound this watchman keeps).
// (meshwatchdogsclient.PulseTTL, with the bound this watchman keeps).
func (w *Watchman) ttl(age time.Duration) (time.Duration, bool) {
if w.bound == delivery.PulseMoveBound {
return delivery.PulseTTL(age)
if w.bound == meshwatchdogsclient.PulseMoveBound {
return meshwatchdogsclient.PulseTTL(age)
}
if age >= w.bound {
return 0, true
@@ -8,23 +8,28 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
)
type fakeOut struct {
presence map[string]bool
presenceWrite int
pulses map[string]delivery.Pulse
pulses map[string]meshwatchdogsclient.Pulse
ttls map[string]time.Duration
pulseDeletes []string
stuck map[string]delivery.Stuck
stuck map[string]meshwatchdogsclient.Stuck
stuckWrites int
findings map[string]Finding
findingWrites int
}
func newOut() *fakeOut {
return &fakeOut{presence: map[string]bool{}, pulses: map[string]delivery.Pulse{}, ttls: map[string]time.Duration{}, stuck: map[string]delivery.Stuck{},
return &fakeOut{presence: map[string]bool{}, pulses: map[string]meshwatchdogsclient.Pulse{}, ttls: map[string]time.Duration{}, stuck: map[string]meshwatchdogsclient.Stuck{},
findings: map[string]Finding{}}
}
@@ -53,7 +58,7 @@ func (o *fakeOut) PutPresence(key string, online bool) error {
}
func (o *fakeOut) PutPulse(key string, value []byte, ttl time.Duration) error {
p, err := delivery.DecodePulse(value)
p, err := meshwatchdogsclient.DecodePulse(value)
if err != nil {
return err
}
@@ -68,7 +73,7 @@ func (o *fakeOut) DeletePulse(key string) error {
}
func (o *fakeOut) PutStuck(key string, value []byte) error {
s, err := delivery.DecodeStuck(value)
s, err := meshwatchdogsclient.DecodeStuck(value)
if err != nil {
return err
}
@@ -86,7 +91,7 @@ func (c *clock) now() time.Time { return c.t }
func setup() (*Watchman, *fakeOut, *clock) {
o, c := newOut(), &clock{t: t0}
w := New(o, c.now)
w.SetStepBound(delivery.PulseMoveBound) // the older tests move a step as they move any key: stuck at the pulse's bound
w.SetStepBound(meshwatchdogsclient.PulseMoveBound) // the older tests move a step as they move any key: stuck at the pulse's bound
return w, o, c
}
@@ -99,12 +104,12 @@ func checkValue(started time.Time, head string) []byte {
const (
checksKey = "SEAT_NODE_BUILD_GO"
presenceNS = delivery.PresenceBucket
presenceNS = PresenceBucket
)
// runningStep is a build module's step key as a node writes it when it takes an ask: the ask's id, running, since started.
func runningStep(id string, started time.Time) []byte {
b, err := delivery.EncodeStep(delivery.Step{ID: id, Tool: "build_go_test", Node: "n1", State: delivery.StepRunning, Started: started})
b, err := buildstep.EncodeStep(buildstep.Step{ID: id, Tool: "build_go_test", Node: "n1", State: buildstep.StepRunning, Started: started})
if err != nil {
panic(err)
}
@@ -112,7 +117,7 @@ func runningStep(id string, started time.Time) []byte {
}
func endedStep(id, state string, started time.Time) []byte {
b, err := delivery.EncodeStep(delivery.Step{ID: id, Tool: "build_go_test", Node: "n1", State: state, Started: started, Ended: started.Add(time.Second)})
b, err := buildstep.EncodeStep(buildstep.Step{ID: id, Tool: "build_go_test", Node: "n1", State: state, Started: started, Ended: started.Add(time.Second)})
if err != nil {
panic(err)
}
@@ -121,7 +126,7 @@ func endedStep(id, state string, started time.Time) []byte {
func pulseKeyOf(t *testing.T, bucket, key string) string {
t.Helper()
k, err := delivery.PulseKey(bucket, key)
k, err := meshwatchdogsclient.PulseKey(bucket, key)
if err != nil {
t.Fatal(err)
}
@@ -130,7 +135,7 @@ func pulseKeyOf(t *testing.T, bucket, key string) string {
func stuckKeyOf(t *testing.T, bucket, key string) string {
t.Helper()
k, err := delivery.StuckKey(bucket, key)
k, err := meshwatchdogsclient.StuckKey(bucket, key)
if err != nil {
t.Fatal(err)
}
@@ -501,7 +506,7 @@ func TestAnAskTakenByANodeHasAPulseFromWhenItStartedAndEndsWithTheStep(t *testin
if len(o.pulses) != 1 {
t.Fatalf("pulses = %v, want the taken ask's", o.pulses)
}
for _, state := range []string{delivery.StepPassed, delivery.StepFailed, delivery.StepRefused} {
for _, state := range []string{buildstep.StepPassed, buildstep.StepFailed, buildstep.StepRefused} {
w2, o2, c2 := setup()
c2.t = t0.Add(10 * time.Second)
w2.Step("go", "n1.askid1", runningStep("askid1", t0), Put)
@@ -573,11 +578,11 @@ func TestARunningStepPastItsBoundIsStuck(t *testing.T) { // 1908
// its fingerprint.
func wantValue(t *testing.T, fp string, removed bool) []byte {
t.Helper()
w := delivery.Want{FP: fp, Composed: "sha256:" + strings.Repeat("c", 64), Removed: removed}
w := meshwantsclient.Want{Fp: fp, Composed: "sha256:" + strings.Repeat("c", 64), Removed: removed}
if removed {
w.Composed = ""
}
b, err := delivery.EncodeWant(w)
b, err := meshwantsclient.EncodeWant(w)
if err != nil {
t.Fatal(err)
}
@@ -586,7 +591,7 @@ func wantValue(t *testing.T, fp string, removed bool) []byte {
func haveValue(t *testing.T, fp string) []byte {
t.Helper()
b, err := delivery.EncodeHave(delivery.Have{FP: fp, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
b, err := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
if err != nil {
t.Fatal(err)
}
@@ -594,15 +599,15 @@ func haveValue(t *testing.T, fp string) []byte {
}
var (
fpOld = delivery.SourcePrefix2 + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fpNew = delivery.SourcePrefix2 + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
fpOld = meshcatalogclient.SourcePrefix2 + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fpNew = meshcatalogclient.SourcePrefix2 + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
func TestAWantIsAPulseOwedByTheNodeAndTheHaveNamingItEndsIt(t *testing.T) {
w, o, c := setup()
c.t = t0.Add(10 * time.Second)
w.Want("want.ace.web", wantValue(t, fpNew, false), Put, t0)
pk := pulseKeyOf(t, delivery.AssignerWantsBucket, "want.ace.web")
pk := pulseKeyOf(t, AssignerWantsBucket, "want.ace.web")
p, ok := o.pulses[pk]
if !ok || p.Key != "want.ace.web" || !strings.Contains(p.Owes, "ace") || !strings.Contains(p.Owes, "web") {
t.Fatalf("pulses = %v", o.pulses)
@@ -642,10 +647,10 @@ func TestAWantNotAppliedByItsBoundIsStuckAndAKeyDeletedEndsIt(t *testing.T) {
o, c := newOut(), &clock{t: t0}
w := New(o, c.now)
w.Want("want.ace.web", wantValue(t, fpNew, false), Put, t0)
pk := pulseKeyOf(t, delivery.AssignerWantsBucket, "want.ace.web")
pk := pulseKeyOf(t, AssignerWantsBucket, "want.ace.web")
c.t = t0.Add(31 * time.Minute)
w.Pulse(pk, Expired, c.t)
if s, ok := o.stuck[stuckKeyOf(t, delivery.AssignerWantsBucket, "want.ace.web")]; !ok || s.Moved || !s.Since.Equal(t0) {
if s, ok := o.stuck[stuckKeyOf(t, AssignerWantsBucket, "want.ace.web")]; !ok || s.Moved || !s.Since.Equal(t0) {
t.Fatalf("stuck = %v", o.stuck)
}
w2, o2, c2 := setup()
@@ -673,18 +678,18 @@ func TestAStuckKeyWhoseKeyIsGoneIsClosedAtTheStart(t *testing.T) {
{"an ask's step aged out of the steps bucket", checksKey, "askold1", func(*Watchman) {}, true, true, false},
{"an ask's step still running", checksKey, "askold1", func(w *Watchman) { w.Step("go", "n1.askold1", runningStep("askold1", t0), Put) }, true, false, false},
{"the steps bucket could not be read", checksKey, "askold1", func(*Watchman) {}, false, false, false},
{"a want deleted while nobody heard", delivery.AssignerWantsBucket, wantKey, func(*Watchman) {}, true, true, false},
{"a want still standing", delivery.AssignerWantsBucket, wantKey, func(w *Watchman) { w.Want(wantKey, wantValue(t, fpNew, false), Put, t0) }, true, false, false},
{"a want deleted while nobody heard", AssignerWantsBucket, wantKey, func(*Watchman) {}, true, true, false},
{"a want still standing", AssignerWantsBucket, wantKey, func(w *Watchman) { w.Want(wantKey, wantValue(t, fpNew, false), Put, t0) }, true, false, false},
// DLV-M5 (ADR 0615): the wants are read from the assigner's first view, which may come after the start's end; what
// they hold waits for it, and is then reconciled as at the start.
{"a want deleted while nobody heard, read from a late first view", delivery.AssignerWantsBucket, wantKey, func(*Watchman) {}, true, true, true},
{"a want still standing, read from a late first view", delivery.AssignerWantsBucket, wantKey, func(w *Watchman) { w.Want(wantKey, wantValue(t, fpNew, false), Put, t0) }, true, false, true},
{"a want deleted while nobody heard, read from a late first view", AssignerWantsBucket, wantKey, func(*Watchman) {}, true, true, true},
{"a want still standing, read from a late first view", AssignerWantsBucket, wantKey, func(w *Watchman) { w.Want(wantKey, wantValue(t, fpNew, false), Put, t0) }, true, false, true},
} {
t.Run(row.name, func(t *testing.T) {
w, o, c := setup()
c.t = t0.Add(9 * 24 * time.Hour)
sk := stuckKeyOf(t, row.bucket, row.key)
v, _ := delivery.EncodeStuck(delivery.Stuck{Bucket: row.bucket, Key: row.key, Waited: "a move", Since: t0})
v, _ := meshwatchdogsclient.EncodeStuck(meshwatchdogsclient.Stuck{Bucket: row.bucket, Key: row.key, Waited: "a move", Since: t0})
w.SeedStuck(sk, v)
if _, ok := o.findings[FindingKey(ScopeDelivery, sk, KindStuck)]; !ok {
t.Fatal("the seeded stuck key raised no finding")
@@ -10,6 +10,10 @@ import (
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"mesh-delivery-watchman/internal/watch"
)
// The controller's manifest reader is mesh-controller internal/catalogue (ParseManifest), an internal package of another
@@ -130,6 +134,40 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6
if m.SecretsOwner != "mesh-delivery-watchman" {
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// D-11 (ADR 0634 §1): the holders and buckets the watchman names are its manifest's: a state of the seat it holds, a
// `reads` entry, a `consumes` views family or an `invokes` tool, each as "<module>_<state>".
// bucket is "<module>_<state>" of a manifest entry "<module>.<state>" or "<module>.views.<state>".
bucket := func(list []string, entry string) string {
if !slices.Contains(list, entry) {
return "(not in the manifest: " + entry + ")"
}
parts := strings.Split(entry, ".")
return parts[0] + "_" + parts[len(parts)-1]
}
entry := func(list []string, e string) string {
if !slices.Contains(list, e) {
return "(not in the manifest: " + e + ")"
}
return e
}
for name, row := range map[string]struct{ got, want string }{
"module": {watch.WatchmanModule, m.Module},
"presence": {watch.PresenceBucket, m.Module + "_" + m.State[0].Name},
"pulse": {watch.PulseBucket, m.Module + "_" + m.State[1].Name},
"stuck": {watch.StuckBucket, m.Module + "_" + m.State[2].Name},
"have": {watch.HaveBucket, bucket(m.Reads, "node-engine.have")},
"health": {watch.NodeHealthBucket, bucket(m.Reads, "mesh-host.health")},
"builds": {watch.BuilderBuildsBucket, bucket(m.Reads, "mesh-delivery-builder.builds")},
"steps": {watch.StepsBucket("mesh-build-go"), bucket(m.Reads, "mesh-build-go.steps")},
"wants": {watch.AssignerWantsBucket, bucket(m.Consumes, "mesh-delivery-assigner.views.wants")},
"modules": {watch.ModulesBucket, bucket(m.Consumes, "mesh-delivery-indexer.views.modules")},
"assigner": {watch.AssignerModule + ".assigner_read_wants", entry(m.Invokes, "mesh-delivery-assigner.assigner_read_wants")},
"indexer": {meshcatalogclient.Module + ".indexer_pending", entry(m.Invokes, "mesh-delivery-indexer.indexer_pending")},
} {
if row.got != row.want {
t.Errorf("the name for %s is %q, its manifest's is %q", name, row.got, row.want)
}
}
}
// The manifest is whole in its repository: the program, and the tools bundle the runtime loads (1952/WM1).
@@ -7,7 +7,6 @@ import (
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Revoke ends an enrolment at any stage (ADR 0587 §5), by a person's call: at asked it withdraws the ask; at redeeming it also
@@ -126,7 +125,7 @@ func (h *Holder) reconcileLocked() error {
if err != nil || !found {
continue
}
r, err := delivery.DecodeEnrolmentAddress(row.Value)
r, err := decodeReservation(row.Value)
// An address with no ask is reserved by an issue under way: its enrolment key is written next, then the ask.
if err != nil || r.Ask == "" || open[r.Node] {
continue
@@ -164,11 +163,7 @@ func (h *Holder) endExpired(e Enrolment) error {
if err := h.unplace(e, "expired"); err != nil {
return fmt.Errorf("%s, whose enrolment expired, is not taken out of placement (tried again on the next pass): %w", e.Node, err)
}
body, err := delivery.EncodeEnrolmentExpiry(delivery.EnrolmentExpiry{Node: e.Node, IssuedID: delivery.EnrolmentIssuedID(e.Node, e.Issued)})
if err != nil {
return err
}
if err := h.say(EventExpired, json.RawMessage(body), eventID(EventExpired, e)); err != nil {
if err := h.say(EventExpired, expiredEvent(e), eventID(EventExpired, e)); err != nil {
return fmt.Errorf("enrolment-expired of %s cannot be said (tried again on the next pass): %w", e.Node, err)
}
if err := h.Vault.Drop(e.Node); err != nil {
@@ -177,7 +172,7 @@ func (h *Holder) endExpired(e Enrolment) error {
if row, found, err := h.Store.Get(AddressKey(e.Address)); err != nil {
return fmt.Errorf("the address key %s cannot be read (tried again on the next pass): %w", e.Address, err)
} else if found {
if r, err := delivery.DecodeEnrolmentAddress(row.Value); err == nil && r.Node == e.Node && r.Ask == e.Ask {
if r, err := decodeReservation(row.Value); err == nil && r.Node == e.Node && r.Ask == e.Ask {
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
return fmt.Errorf("the address key %s is not deleted (tried again on the next pass): %w", e.Address, err)
}
@@ -3,8 +3,12 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
"reflect"
"slices"
"strings"
@@ -29,7 +33,7 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
}
// The node the yes placed, never confirmed, leaves placement on the enrolment's warrant before the end is said.
if _, placed := r.rd["mesh-delivery-assigner.placement"]["ace.node"]; placed || len(r.added) == 0 ||
!reflect.DeepEqual(r.added[len(r.added)-1].Change, delivery.PlacementProposalChange{Op: delivery.OpRemoveNode, Node: "ace"}) || r.added[len(r.added)-1].Warrant.Ask != "ask-ace" {
!reflect.DeepEqual(r.added[len(r.added)-1].Change, meshmoduleassigner.ProposalChange{Op: meshmoduleassignerclient.OpRemoveNode, Node: "ace"}) || r.added[len(r.added)-1].Warrant.Ask != "ask-ace" {
t.Errorf("revoked and still placed: %+v", r.added)
}
if len(r.keys()) != 0 || !slices.Equal(r.events(), []string{EventRevoked}) || len(r.acts) != 1 || !slices.Contains(r.vault.calls, "retire ace") {
@@ -55,7 +59,7 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
t.Error("expired and still placed")
}
last := r.said[len(r.said)-1]
x, err := delivery.DecodeEnrolmentExpiry(last.Body)
x, err := decodeExpired(last.Body)
if err != nil || last.Event != EventExpired || x.Node != "novox" || !strings.HasPrefix(x.IssuedID, "enrolment-issued.novox.") ||
!slices.Contains(r.vault.calls, "drop novox") || r.has(AddressKey("10.9.0.4")) {
t.Errorf("said %s %s: %+v, %v; vault %v, keys %v", last.Event, last.Body, x, err, r.vault.calls, r.keys())
@@ -87,10 +91,10 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
t.Fatalf("%s: %v", c.name, err)
}
last := r.said[len(r.said)-1]
x, err := delivery.DecodeEnrolmentExpiry(last.Body)
x, err := decodeExpired(last.Body)
_, placed := r.rd["mesh-delivery-assigner.placement"][c.node+".node"]
removed := r.added[len(r.added)-1]
if err != nil || x.IssuedID != delivery.EnrolmentIssuedID(c.node, issued) || placed || removed.Change.Op != delivery.OpRemoveNode ||
if err != nil || x.IssuedID != meshnodeenrollerclient.IssuedID(c.node, issued) || placed || removed.Change.Op != meshmoduleassignerclient.OpRemoveNode ||
removed.Change.Node != c.node || removed.Warrant.Ask != "ask-"+c.node || !slices.Contains(r.vault.calls, "drop "+c.node) || r.has(AddressKey("10.9.0.9")) {
t.Errorf("%s: said %s (%v), placed %v, last placement call %+v, vault %v, keys %v", c.name, last.Body, err, placed, removed, r.vault.calls, r.keys())
}
@@ -105,7 +109,7 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
}{
{"after a confirm stopped before the enrolment key went", func(r *rig, node string) {
e, _ := r.enrolment(node)
e.Stage, e.KeysDigest = StageRedeeming, delivery.KeysDigest("pk", "ov", "se", "sv")
e.Stage, e.KeysDigest = StageRedeeming, meshnodeenrollerclient.KeysDigest("pk", "ov", "se", "sv")
_, _ = r.st.Put(NodeKey(node), e, 0)
r.asker.answer("ask-"+node, "yes")
r.st.failDelete[NodeKey(node)] = true // the confirm stops after the address key, before the enrolment key
@@ -142,7 +146,7 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
c.end(r, "joiner")
removes := 0
for _, a := range r.added {
if a.Change.Op == delivery.OpRemoveNode {
if a.Change.Op == meshmoduleassignerclient.OpRemoveNode {
removes++
}
}
@@ -151,7 +155,7 @@ func revokeEndsAtAnyStageAndAnExpiryMarkerSaysExpired(t *testing.T) {
}
after := 0
for _, a := range r.added {
if a.Change.Op == delivery.OpRemoveNode {
if a.Change.Op == meshmoduleassignerclient.OpRemoveNode {
after++
}
}
@@ -207,3 +211,15 @@ func theDropFollowsOnlyAnEndAndNothingHoldsTheToken(t *testing.T) {
t.Errorf("history %s", raw)
}
}
// expired is what enrolment-expired says, read as the seat's contract has it: the enrolment, and the enrolment-issued event
// its issue time names.
type expired struct{ Node, IssuedID string }
func decodeExpired(raw []byte) (expired, error) {
c, err := meshnodeenroller.DecodeEnrolmentsByNodeNodeValue(raw)
if err != nil || c.Issued == nil {
return expired{}, fmt.Errorf("enrolment-expired is not the seat's enrolment with its issue time: %v", err)
}
return expired{Node: c.Node, IssuedID: meshnodeenrollerclient.IssuedID(c.Node, *c.Issued)}, nil
}
@@ -8,8 +8,8 @@ import (
"regexp"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// mesh-enrolment: the holder of the seat mesh-enrolment (novox/hq ADR 0587 §1), held once for the mesh. A Go bundle served
@@ -22,7 +22,7 @@ const (
Module = "mesh-enrolment"
Seat = meshnodeenroller.Seat
// State is the holder's bucket of open enrolments; its keys are node.<node> and address.<address> (ADR 0587 §2).
State = delivery.EnrolmentsState
State = enrolmentsState
)
// The seat's six verbs and five events (ADR 0587 §1).
@@ -41,20 +41,14 @@ const (
// to drop the token. A seat's event is heard as <seat>.<event>.
var EndEvents = []string{EventEnrolled, EventRefused, EventRevoked, EventExpired}
// The stages of an open enrolment (ADR 0587 §2), as the seat's contract names them (delivery.Enrolment).
// The stages of an open enrolment (ADR 0587 §2), as the seat's contract names them (Enrolment).
const (
// StageAsked: the operator's Approve is asked, no token exists.
StageAsked = delivery.EnrolmentAsked
StageAsked = stageAsked
// StageLive: the Approve is given, the token exists and expires on the bus with the key.
StageLive = delivery.EnrolmentLive
StageLive = stageLive
// StageRedeeming: the token is spent; the key has no expiry and ends only by confirm or revoke.
StageRedeeming = delivery.EnrolmentRedeeming
)
// Enrolment and Reservation are the seat's state contract (delivery.Enrolment, delivery.EnrolmentAddress).
type (
Enrolment = delivery.Enrolment
Reservation = delivery.EnrolmentAddress
StageRedeeming = stageRedeeming
)
const (
@@ -62,11 +56,11 @@ const (
)
// NodeKey and AddressKey are the two keys of an enrolment.
func NodeKey(node string) string { return delivery.EnrolmentNodeKey(node) }
func AddressKey(address string) string { return delivery.EnrolmentAddressKey(address) }
func NodeKey(node string) string { return nodeKey(node) }
func AddressKey(address string) string { return addressKey(address) }
// EnrolUser and NodeUser are the two bus users of a node: the one it joins with, and its own.
func EnrolUser(node string) string { return delivery.EnrolmentUser(node) }
func EnrolUser(node string) string { return meshnodeenrollerclient.User(node) }
func NodeUser(node string) string { return nodePrefix + node }
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
@@ -79,15 +73,13 @@ func CheckNode(node string) error {
return nil
}
func decodeEnrolment(raw []byte) (Enrolment, error) { return delivery.DecodeEnrolment(raw) }
// Encode is a value of the bucket as the seat's contract writes it: an Enrolment or a Reservation checked and versioned.
func Encode(v any) (json.RawMessage, error) {
switch x := v.(type) {
case Enrolment:
return delivery.EncodeEnrolment(x)
return encodeEnrolment(x)
case Reservation:
return delivery.EncodeEnrolmentAddress(x.Node, x.Ask, x.Issued)
return encodeReservation(x.Node, x.Ask, x.Issued)
}
return json.Marshal(v)
}
@@ -11,9 +11,12 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
mv "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
)
// Vault is the vault's side of enrolment (ADR 0587 §3, §5): the token and the node's account, and the signatures. The
@@ -22,7 +25,7 @@ type Vault interface {
// Token asks the vault to make the token and its hash for node, expiring at expires, and answers what the joining
// machine must trust (the bus's address and fingerprint, the vault's signing key). No token is in the answer.
// The token carries tunnel, the node's first tunnel built at issue (ADR 0587 §3, insight of 2026-11-02).
Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (mv.EnrolmentTokenAnswer, error)
Token(node string, expires time.Time, tunnel meshnodeenrollerclient.TokenTunnel) (mv.EnrolmentTokenAnswer, error)
// Sealed asks the vault for node's token sealed to the operator key, as the vault sealed it.
Sealed(node string) (string, error)
// Drop asks the vault to drop node's token and its hash; it answers only once both are gone.
@@ -33,7 +36,7 @@ type Vault interface {
Retire(node string) error
// SignCertificate and SignReply sign through the vault's `sign`; the certificate's call carries every public key the node
// presented, which the vault checks against the digest node-account was minted for.
SignCertificate(node string, keys delivery.JoiningKeys) (mv.SignAnswer, error)
SignCertificate(node string, keys meshmoduleassigner.ProposalChangeKeys) (mv.SignAnswer, error)
SignReply(node string, reply []byte) (mv.SignAnswer, error)
// Signer is the vault's public signing key in base64, which the redeem answer names for the node to check it against its token.
Signer() (string, error)
@@ -98,16 +101,16 @@ func (h *Holder) say(event string, body any, id string) error {
// AddNode is what the holder asks mesh-placement to add (ADR 0587 §7): the proposal's one change, a joining node with the
// address the operator approved and the public halves of its keys, and the issue's warrant, which names this node and this
// address (delivery.AddNodeWarrant).
// address (meshmoduleassigner.Warrant).
type AddNode struct {
Why string
Change delivery.PlacementProposalChange
Warrant delivery.AddNodeWarrant
Change meshmoduleassigner.ProposalChange
Warrant meshmoduleassigner.Warrant
}
// place adds the node into placement, with the address the operator approved and its tunnel key, on the issue's warrant
// (ADR 0587 §7; the operator's decision of 2026-11-02 01:05), unless placement holds it at that address already.
func (h *Holder) place(e Enrolment, placements map[string]delivery.Placement) error {
func (h *Holder) place(e Enrolment, placements map[string]meshmoduleassigner.Placement) error {
if p, ok := placements[e.Node]; ok && p.Decided.Address == e.Address {
return nil
}
@@ -116,8 +119,8 @@ func (h *Holder) place(e Enrolment, placements map[string]delivery.Placement) er
return err
}
return h.AddNode(AddNode{Why: "enrolment of " + e.Node + ": " + e.Why, Warrant: w,
Change: delivery.PlacementProposalChange{Op: delivery.OpAddNode, Node: e.Node, Decided: &delivery.NodeDecided{Address: e.Address},
Keys: &delivery.JoiningKeys{Overlay: e.OverlayKey}}})
Change: meshmoduleassigner.ProposalChange{Op: meshmoduleassignerclient.OpAddNode, Node: e.Node, Decided: &meshmoduleassigner.NodeDecided{Address: e.Address},
Keys: &meshmoduleassigner.ProposalChangeKeys{Overlay: e.OverlayKey}}})
}
// unplace takes a node that never confirmed off placement again, on the same warrant, when its enrolment ends revoked, refused
@@ -135,22 +138,22 @@ func (h *Holder) unplace(e Enrolment, why string) error {
return err
}
return h.AddNode(AddNode{Why: "enrolment of " + e.Node + " ended unconfirmed: " + why, Warrant: w,
Change: delivery.PlacementProposalChange{Op: delivery.OpRemoveNode, Node: e.Node}})
Change: meshmoduleassigner.ProposalChange{Op: meshmoduleassignerclient.OpRemoveNode, Node: e.Node}})
}
func (h *Holder) warrantOf(e Enrolment) (delivery.AddNodeWarrant, error) {
func (h *Holder) warrantOf(e Enrolment) (meshmoduleassigner.Warrant, error) {
if h.AddNode == nil || h.WarrantOf == nil {
return delivery.AddNodeWarrant{}, errors.New("no route to mesh-placement")
return meshmoduleassigner.Warrant{}, errors.New("no route to mesh-placement")
}
warrant, err := h.WarrantOf(e.Ask)
if err != nil {
return delivery.AddNodeWarrant{}, fmt.Errorf("the operator's warrant for %s cannot be read from the router: %w", e.Ask, err)
return meshmoduleassigner.Warrant{}, fmt.Errorf("the operator's warrant for %s cannot be read from the router: %w", e.Ask, err)
}
return delivery.AddNodeWarrant{Node: e.Node, Address: e.Address, Ask: e.Ask, Warrant: warrant}, nil
return meshmoduleassigner.Warrant{Node: e.Node, Address: e.Address, Ask: e.Ask, Warrant: warrant}, nil
}
// checkTunnelKey says a tunnel key is a WireGuard public key, as `nox-mesh-host key` prints it: 32 bytes in standard base64
// (as delivery.Enrolment requires it too); said here so issue names it among its findings.
// (as Enrolment requires it too); said here so issue names it among its findings.
func checkTunnelKey(k string) error {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(k))
if err != nil || len(raw) != 32 {
@@ -189,7 +192,7 @@ func (h *Holder) openEnrolments() (map[string]Row, map[string]Enrolment, error)
}
// placements reads every node's placement, assembled from its keys.
func (h *Holder) placements() (map[string]delivery.Placement, error) {
func (h *Holder) placements() (map[string]meshmoduleassigner.Placement, error) {
keys, err := h.Read.Keys(PlacementRead)
if err != nil {
return nil, fmt.Errorf("placement cannot be listed: %w", err)
@@ -202,7 +205,7 @@ func (h *Holder) placements() (map[string]delivery.Placement, error) {
held[k] = raw
}
}
p, err := delivery.PlacementsOf(held)
p, err := meshmoduleassignerclient.PlacementsOf(held)
if len(p) == 0 && err != nil {
return nil, err
}
@@ -215,8 +218,8 @@ func (h *Holder) placements() (map[string]delivery.Placement, error) {
// that has not written one, the found tunnel's key kept in its placement), the hub's endpoint from its placement, and the
// node's address on the private network. A mesh with no hub, or a hub with no key or no endpoint, has no tunnel to join
// through, and that is said.
func (h *Holder) firstTunnel(placements map[string]delivery.Placement, address, overlayKey string) (delivery.TokenTunnel, error) {
var hub *delivery.Placement
func (h *Holder) firstTunnel(placements map[string]meshmoduleassigner.Placement, address, overlayKey string) (meshnodeenrollerclient.TokenTunnel, error) {
var hub *meshmoduleassigner.Placement
for _, p := range placements {
if p.Decided.Hub {
p := p
@@ -224,12 +227,12 @@ func (h *Holder) firstTunnel(placements map[string]delivery.Placement, address,
}
}
if hub == nil {
return delivery.TokenTunnel{}, errors.New("placement names no hub, so there is no tunnel to join through")
return meshnodeenrollerclient.TokenTunnel{}, errors.New("placement names no hub, so there is no tunnel to join through")
}
key := ""
if k, err := delivery.HaveNodeKey(hub.Node); err == nil {
if k, err := nodeengineclient.HaveNodeKey(hub.Node); err == nil {
if raw, found, err := h.Read.Get("node-engine.have", k); err == nil && found {
if hn, err := delivery.DecodeHaveNode(raw); err == nil && hn.Record != nil {
if hn, err := nodeengineclient.DecodeHaveNode(raw); err == nil && hn.Record != nil {
key = hn.Record.TunnelKey
}
}
@@ -246,12 +249,12 @@ func (h *Holder) firstTunnel(placements map[string]delivery.Placement, address,
}
prefix, rerr := privateRange(placements)
if len(missing) > 0 {
return delivery.TokenTunnel{}, fmt.Errorf("the hub %s has %s, so there is no tunnel to join through", hub.Node, strings.Join(missing, " and "))
return meshnodeenrollerclient.TokenTunnel{}, fmt.Errorf("the hub %s has %s, so there is no tunnel to join through", hub.Node, strings.Join(missing, " and "))
}
if rerr != nil {
return delivery.TokenTunnel{}, rerr
return meshnodeenrollerclient.TokenTunnel{}, rerr
}
return delivery.TokenTunnel{Key: overlayKey, Address: address + "/32", Range: prefix.String(), HubKey: key, HubEndpoint: hub.Decided.Endpoint}, nil
return meshnodeenrollerclient.TokenTunnel{Key: overlayKey, Address: address + "/32", Range: prefix.String(), HubKey: key, HubEndpoint: hub.Decided.Endpoint}, nil
}
// hasRecord says the node already writes a record (a node of the mesh, ADR 0587 §4: "a node record ... holds it").
@@ -261,7 +264,7 @@ func (h *Holder) hasRecord(node string) bool {
return false
}
for _, k := range keys {
if n, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode && n == node {
if n, _, isNode, err := nodeengineclient.ParseHaveKey(k); err == nil && isNode && n == node {
return true
}
}
@@ -271,7 +274,7 @@ func (h *Holder) hasRecord(node string) bool {
// privateRange is the private network's range: the hub's tunnel range in its placement, as the controller's `token issue`
// writes it into the token today (the adopted tunnel's range). A mesh whose hub's placement names none has no range a token
// can carry, and that is said rather than guessed.
func privateRange(placements map[string]delivery.Placement) (netip.Prefix, error) {
func privateRange(placements map[string]meshmoduleassigner.Placement) (netip.Prefix, error) {
for _, p := range placements {
if t := p.Decided.Tunnel; p.Decided.Hub && t != nil && t.Range != "" {
r, err := netip.ParsePrefix(t.Range)
@@ -286,7 +289,7 @@ func privateRange(placements map[string]delivery.Placement) (netip.Prefix, error
// nextAddress is the lowest address of the private network that no placement key and no reservation holds (ADR 0587 §4),
// after skip. The network is the hub's range; the hub's own address and the tunnel's are never given.
func nextAddress(placements map[string]delivery.Placement, reserved map[string]bool, skip map[string]bool) (string, error) {
func nextAddress(placements map[string]meshmoduleassigner.Placement, reserved map[string]bool, skip map[string]bool) (string, error) {
taken := map[string]bool{}
for _, p := range placements {
if a := p.Decided.Address; a != "" {
@@ -23,10 +23,13 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
mv "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
@@ -145,7 +148,7 @@ type standInVault struct {
ctx context.Context
logins jetstream.KeyValue
mu sync.Mutex
tokens map[string]delivery.Token
tokens map[string]meshnodeenrollerclient.Token
calls []string
}
@@ -156,9 +159,9 @@ func (v *standInVault) note(c string) {
}
// Token makes the token as the vault does: the bus it states, its signing key, a secret, and the first tunnel it is handed.
func (v *standInVault) Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (mv.EnrolmentTokenAnswer, error) {
func (v *standInVault) Token(node string, expires time.Time, tunnel meshnodeenrollerclient.TokenTunnel) (mv.EnrolmentTokenAnswer, error) {
v.mu.Lock()
v.tokens[node] = delivery.Token{Version: 1, Node: node, Broker: "bus:4222", Fingerprint: "f", Signer: make([]byte, 32), Secret: "secret-" + node, Tunnel: &tunnel}
v.tokens[node] = meshnodeenrollerclient.Token{Version: 1, Node: node, Broker: "bus:4222", Fingerprint: "f", Signer: make([]byte, 32), Secret: "secret-" + node, Tunnel: &tunnel}
v.mu.Unlock()
v.note("token " + node)
return mv.EnrolmentTokenAnswer{Node: node, Broker: "bus:4222", Fingerprint: "f", Signer: "s"}, nil
@@ -172,21 +175,21 @@ func (v *standInVault) Drop(node string) error {
return nil
}
func (v *standInVault) Account(node, sealing, digest string) (mv.NodeAccountAnswer, error) {
raw, err := delivery.EncodeNodeLogin(delivery.NodeLogin{Node: node, Login: delivery.FirstLogin(node), Since: time.Now().UTC()})
raw, err := meshvaultclient.EncodeNodeLogin(mv.NodeLoginsByNodeValue{Node: node, Login: meshvaultclient.FirstLogin(node), Since: time.Now().UTC()})
if err != nil {
return mv.NodeAccountAnswer{}, err
}
if _, err := v.logins.Put(v.ctx, node, raw); err != nil {
return mv.NodeAccountAnswer{}, err
}
return mv.NodeAccountAnswer{Node: node, Login: delivery.FirstLogin(node), Account: "account-" + node}, nil
return mv.NodeAccountAnswer{Node: node, Login: meshvaultclient.FirstLogin(node), Account: "account-" + node}, nil
}
func (v *standInVault) Retire(node string) error { return nil }
func (v *standInVault) SignCertificate(node string, _ delivery.JoiningKeys) (mv.SignAnswer, error) {
return mv.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Certificate: "PEM"}, nil
func (v *standInVault) SignCertificate(node string, _ meshmoduleassigner.ProposalChangeKeys) (mv.SignAnswer, error) {
return mv.SignAnswer{Kind: meshvaultclient.KindNodeCertificate, Node: node, Certificate: "PEM"}, nil
}
func (v *standInVault) SignReply(node string, reply []byte) (mv.SignAnswer, error) {
return mv.SignAnswer{Kind: delivery.KindEnrolment, Node: node, Signature: "sig", Signer: "fp"}, nil
return mv.SignAnswer{Kind: meshvaultclient.KindEnrolment, Node: node, Signature: "sig", Signer: "fp"}, nil
}
func (v *standInVault) Signer() (string, error) { return "vault-key", nil }
@@ -220,9 +223,9 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
}
return kv
}
enrolments := mk(delivery.EnrolmentsBucket, true)
enrolments := mk(enrolmentsBucket, true)
// No controller lease bucket at all: the controller ended by its unassignment (issue 1126).
logins, placement := mk(delivery.NodeLoginsBucket, false), mk("mesh-delivery-assigner_placement", false)
logins, placement := mk(nodeLoginsBucket, false), mk("mesh-delivery-assigner_placement", false)
have := mk("node-engine_have", false)
if _, err := placement.Put(ctx, "hub.node", []byte(hubPlacement)); err != nil {
t.Fatal(err)
@@ -249,7 +252,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
events := func() []string { heardMu.Lock(); defer heardMu.Unlock(); return slices.Clone(heard) }
store := &busStore{ctx: ctx, js: js, kv: enrolments}
vault := &standInVault{ctx: ctx, logins: logins, tokens: map[string]delivery.Token{}}
vault := &standInVault{ctx: ctx, logins: logins, tokens: map[string]meshnodeenrollerclient.Token{}}
asker := &fakeAsker{}
h := &Holder{Store: store, Read: busReader{ctx, js}, Vault: vault, Asker: asker, AskLasts: time.Hour,
Say: func(event string, body any, id string) error {
@@ -259,7 +262,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
},
// mesh-placement, stood in: an add-node writes the node's key, a remove-node deletes it, each saying placement-changed.
AddNode: func(a AddNode) error {
if a.Change.Op == delivery.OpRemoveNode {
if a.Change.Op == meshmoduleassignerclient.OpRemoveNode {
if err := placement.Delete(ctx, a.Change.Node+".node"); err != nil {
return err
}
@@ -371,7 +374,7 @@ func TestMeshEnrolmentWholeOnARealBus(t *testing.T) {
if err != nil {
t.Fatal(err)
}
nl, err := delivery.DecodeNodeLogin(e.Value())
nl, err := meshvaultclient.DecodeNodeLogin(e.Value())
if err != nil || nl.Login != ans.Login {
t.Fatalf("node-logins says %+v, %v, and the answer %s", nl, err, ans.Login)
}
@@ -8,10 +8,10 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Event is what each of the seat's events carries (ADR 0587 §2): the key's whole content, which holds no secret and nothing
@@ -63,10 +63,10 @@ func (h *Holder) Issue(caller, node, why, overlayKey string, lasts time.Duration
findings = append(findings, err.Error())
}
if lasts == 0 {
lasts = delivery.DefaultTokenLife
lasts = meshnodeenrollerclient.DefaultTokenLife
}
if lasts < 0 || lasts > delivery.MostTokenLife {
findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, delivery.MostTokenLife))
if lasts < 0 || lasts > meshnodeenrollerclient.MostTokenLife {
findings = append(findings, fmt.Sprintf("the expiry %s is outside zero to %s", lasts, meshnodeenrollerclient.MostTokenLife))
}
placements, perr := h.placements()
if perr != nil {
@@ -289,7 +289,7 @@ func (h *Holder) dropAddress(e Enrolment) error {
if !found {
return nil
}
if r, err := delivery.DecodeEnrolmentAddress(row.Value); err == nil && r.Node != e.Node {
if r, err := decodeReservation(row.Value); err == nil && r.Node != e.Node {
return nil // another enrolment holds the address now
}
if err := h.Store.Delete(AddressKey(e.Address)); err != nil {
@@ -7,9 +7,10 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
)
var yes = oc.Option{ID: "yes"}
@@ -103,11 +104,11 @@ func twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
}
// The address key names the ask and the issue, with no expiry of its own, so it outlives the enrolment key (ADR 0589 §6).
if row, _, _ := r.st.Get(AddressKey(live.Address)); true {
if res, err := delivery.DecodeEnrolmentAddress(row.Value); err != nil || res.Ask != e.Ask || !res.Issued.Equal(live.Issued) {
if res, err := decodeReservation(row.Value); err != nil || res.Ask != e.Ask || !res.Issued.Equal(live.Issued) {
t.Errorf("the address key %s: %+v, %v", row.Value, res, err)
}
}
if got, want := r.vault.tunnels["ace"], (delivery.TokenTunnel{Key: tunnelKey, Address: live.Address + "/32", Range: "10.9.0.0/24",
if got, want := r.vault.tunnels["ace"], (meshnodeenrollerclient.TokenTunnel{Key: tunnelKey, Address: live.Address + "/32", Range: "10.9.0.0/24",
HubKey: "hub-tunnel-key", HubEndpoint: "192.0.2.1:51820"}); got != want {
t.Errorf("the token's tunnel %+v, want %+v", got, want)
}
@@ -116,7 +117,7 @@ func twoIssuesAtOnceHoldTwoAddressesAndAYesMakesTheToken(t *testing.T) {
t.Errorf("the token was made before placement held the node: %v", r.vault.calls)
}
for _, a := range r.added {
if a.Change.Node == "ace" && (*a.Change.Keys != delivery.JoiningKeys{Overlay: tunnelKey} || a.Warrant.Ask != e.Ask || a.Change.Decided.Address != live.Address) {
if a.Change.Node == "ace" && (*a.Change.Keys != meshmoduleassigner.ProposalChangeKeys{Overlay: tunnelKey} || a.Warrant.Ask != e.Ask || a.Change.Decided.Address != live.Address) {
t.Errorf("the add-node at the yes %+v", a)
}
}
@@ -11,9 +11,12 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
mv "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
)
// memStore is the holder's bucket in memory: revisions, and the expiry each key was written with.
@@ -117,8 +120,8 @@ func (r memRead) Get(state, key string) ([]byte, bool, error) {
type fakeVault struct {
calls []string
fail map[string]bool
tunnels map[string]delivery.TokenTunnel
cert delivery.JoiningKeys // the keys the certificate's call carried
tunnels map[string]meshnodeenrollerclient.TokenTunnel
cert meshmoduleassigner.ProposalChangeKeys // the keys the certificate's call carried
}
func (v *fakeVault) do(what string) error {
@@ -130,9 +133,9 @@ func (v *fakeVault) do(what string) error {
return nil
}
func (v *fakeVault) Token(node string, expires time.Time, tunnel delivery.TokenTunnel) (mv.EnrolmentTokenAnswer, error) {
func (v *fakeVault) Token(node string, expires time.Time, tunnel meshnodeenrollerclient.TokenTunnel) (mv.EnrolmentTokenAnswer, error) {
if v.tunnels == nil {
v.tunnels = map[string]delivery.TokenTunnel{}
v.tunnels = map[string]meshnodeenrollerclient.TokenTunnel{}
}
v.tunnels[node] = tunnel
return mv.EnrolmentTokenAnswer{Node: node, Broker: "b", Fingerprint: "f", Signer: "s"}, v.do("token " + node)
@@ -145,13 +148,13 @@ func (v *fakeVault) Account(node, sealing, digest string) (mv.NodeAccountAnswer,
return mv.NodeAccountAnswer{Node: node, Login: "node." + node, Account: "account-" + node}, v.do("account " + node)
}
func (v *fakeVault) Retire(node string) error { return v.do("retire " + node) }
func (v *fakeVault) SignCertificate(node string, k delivery.JoiningKeys) (mv.SignAnswer, error) {
func (v *fakeVault) SignCertificate(node string, k meshmoduleassigner.ProposalChangeKeys) (mv.SignAnswer, error) {
serving := k.Serving
v.cert = k
return mv.SignAnswer{Kind: delivery.KindNodeCertificate, Node: node, Certificate: "PEM-" + serving}, v.do("certificate " + node)
return mv.SignAnswer{Kind: meshvaultclient.KindNodeCertificate, Node: node, Certificate: "PEM-" + serving}, v.do("certificate " + node)
}
func (v *fakeVault) SignReply(node string, reply []byte) (mv.SignAnswer, error) {
return mv.SignAnswer{Kind: delivery.KindEnrolment, Node: node, Signature: "sig-" + delivery.DigestOf(reply)[7:15], Signer: "fp"}, v.do("sign " + node)
return mv.SignAnswer{Kind: meshvaultclient.KindEnrolment, Node: node, Signature: "sig-" + meshvaultclient.DigestOf(reply)[7:15], Signer: "fp"}, v.do("sign " + node)
}
func (v *fakeVault) Signer() (string, error) { return "vault-signer", nil }
@@ -247,7 +250,7 @@ func newRig(t *testing.T) *rig {
r.vault.calls = append(r.vault.calls, a.Change.Op+" "+a.Change.Node)
if r.placeErr == nil { // placement writes or deletes the node as the assigner does on the warrant
n := a.Change.Node
if a.Change.Op == delivery.OpRemoveNode {
if a.Change.Op == meshmoduleassignerclient.OpRemoveNode {
delete(r.rd["mesh-delivery-assigner.placement"], n+".node")
} else {
r.rd["mesh-delivery-assigner.placement"][n+".node"] = `{"v":1,"node":"` + n + `","decided":{"address":"` + a.Change.Decided.Address + `"},"modules":[]}`
@@ -15,7 +15,6 @@ import (
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -117,8 +116,8 @@ func (r followedReader) Get(state, key string) ([]byte, bool, error) {
// PlacementRead is the assigner's placement as the enrolment reads it, and PlacementTool the assigner's read tool the
// manifest invokes for its first view (ADR 0615, DLV-M6).
const (
PlacementRead = delivery.AssignerModule + "." + delivery.PlacementState
PlacementTool = delivery.AssignerModule + ".assigner_read_placement"
PlacementRead = assignerModule + "." + placementState
PlacementTool = assignerModule + ".assigner_read_placement"
)
// followed is the enrolment's pictures of the assigner's placement, started at once: its first view asked through
@@ -126,7 +125,7 @@ const (
func followed() *views.Reader {
v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { logf("["+Module+"] "+f, a...) }, Findings: views.OwnFindings{},
Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
v.Follow(delivery.AssignerModule, delivery.PlacementState, PlacementTool)
v.Follow(assignerModule, placementState, PlacementTool)
v.Start(context.Background())
return v
}
@@ -0,0 +1,22 @@
package main
import "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller"
// The holders, states and buckets the enrolment names beside its own, taken from its manifest (module.json: its module, its
// `reads`, its state, the seats it invokes); a unit row checks them (TestTheNamesAreTheManifests). No contract names a
// holder (novox/hq ADR 0625 §4, ADR 0634 §1).
const (
vaultModule = "mesh-vault"
assignerModule = "mesh-delivery-assigner"
// enrolmentsState is the holder's state of open enrolments, enrolmentsBucket its bucket.
enrolmentsState = meshnodeenroller.StateEnrolments
enrolmentsBucket = Module + "_" + enrolmentsState
// signersRead is the vault's state signers as the enrolment's manifest reads it, signersKey the key holding its signing key.
signersRead = vaultModule + ".signers"
signersKey = "key"
// nodeLoginsBucket is the vault's state node-logins (the holder reads it through the grants part, not directly).
nodeLoginsBucket = vaultModule + "_node-logins"
// placementState is the assigner's state placement, which the enrolment reads (reads: through its first view).
placementState = "placement"
)
@@ -7,7 +7,8 @@ import (
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
)
// Redeem is the joining node-engine's one call (ADR 0587 §5, §6). It answers only a caller the runtime proves to be
@@ -18,55 +19,55 @@ import (
// of the keys presented,
// waits for the bus node to load node.<node>, and answers the whole, signed by the vault. Called again with the same keys it
// finishes what is not done and answers the same.
func (h *Holder) Redeem(caller string, a delivery.Redeem) (delivery.RedeemAnswer, error) {
func (h *Holder) Redeem(caller string, a meshnodeenrollerclient.Redeem) (meshnodeenrollerclient.RedeemAnswer, error) {
if err := CheckNode(a.Node); err != nil {
return delivery.RedeemAnswer{}, err
return meshnodeenrollerclient.RedeemAnswer{}, err
}
if caller != EnrolUser(a.Node) {
return delivery.RedeemAnswer{}, fmt.Errorf("only %s may redeem the enrolment of %s, and the runtime proved the caller to be %q", EnrolUser(a.Node), a.Node, caller)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("only %s may redeem the enrolment of %s, and the runtime proved the caller to be %q", EnrolUser(a.Node), a.Node, caller)
}
pub, err := base64.StdEncoding.DecodeString(a.PublicKey)
proof, perr := a.ProofBytes()
if err != nil || perr != nil || len(pub) != ed25519.PublicKeySize ||
!ed25519.Verify(pub, delivery.RedeemProof(a.Node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey), proof) {
return delivery.RedeemAnswer{}, errors.New("the proof does not verify against the identity key presented: the enrolment is not spent")
!ed25519.Verify(pub, meshnodeenrollerclient.RedeemProof(a.Node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey), proof) {
return meshnodeenrollerclient.RedeemAnswer{}, errors.New("the proof does not verify against the identity key presented: the enrolment is not spent")
}
if a.SealingKey == "" {
return delivery.RedeemAnswer{}, errors.New("no sealing key is presented, so the account cannot be sealed to the node: the enrolment is not spent")
return meshnodeenrollerclient.RedeemAnswer{}, errors.New("no sealing key is presented, so the account cannot be sealed to the node: the enrolment is not spent")
}
digest := a.KeysDigest()
e, err := h.spend(a.Node, digest, a.OverlayKey)
if err != nil {
return delivery.RedeemAnswer{}, err
return meshnodeenrollerclient.RedeemAnswer{}, err
}
account, err := h.Vault.Account(e.Node, a.SealingKey, digest)
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not give the account of %s (call redeem again with the same keys): %w", e.Node, err)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the vault did not give the account of %s (call redeem again with the same keys): %w", e.Node, err)
}
cert, err := h.Vault.SignCertificate(e.Node, delivery.JoiningKeys{Identity: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey})
cert, err := h.Vault.SignCertificate(e.Node, meshmoduleassigner.ProposalChangeKeys{Identity: a.PublicKey, Overlay: a.OverlayKey, Sealing: a.SealingKey, Serving: a.ServingKey})
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not sign the certificate of %s (call redeem again with the same keys): %w", e.Node, err)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the vault did not sign the certificate of %s (call redeem again with the same keys): %w", e.Node, err)
}
if h.Loaded != nil {
loaded, err := h.Loaded(NodeUser(e.Node))
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("whether the bus node loaded %s cannot be read (call redeem again with the same keys): %w", NodeUser(e.Node), err)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("whether the bus node loaded %s cannot be read (call redeem again with the same keys): %w", NodeUser(e.Node), err)
}
if !loaded {
return delivery.RedeemAnswer{}, fmt.Errorf("the bus node has not loaded %s yet (call redeem again with the same keys)", NodeUser(e.Node))
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the bus node has not loaded %s yet (call redeem again with the same keys)", NodeUser(e.Node))
}
}
signer, err := h.Vault.Signer()
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the vault's signing key cannot be named (call redeem again with the same keys): %w", err)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the vault's signing key cannot be named (call redeem again with the same keys): %w", err)
}
out := delivery.RedeemAnswer{Node: e.Node, Login: account.Login, Account: account.Account, Certificate: cert.Certificate, Signer: signer}
out := meshnodeenrollerclient.RedeemAnswer{Node: e.Node, Login: account.Login, Account: account.Account, Certificate: cert.Certificate, Signer: signer}
if out.Login == "" {
out.Login = NodeUser(e.Node)
}
signed, err := h.Vault.SignReply(e.Node, out.SignedBytes())
if err != nil {
return delivery.RedeemAnswer{}, fmt.Errorf("the vault did not sign the answer (call redeem again with the same keys): %w", err)
return meshnodeenrollerclient.RedeemAnswer{}, fmt.Errorf("the vault did not sign the answer (call redeem again with the same keys): %w", err)
}
out.Signature = signed.Signature
return out, nil
@@ -7,20 +7,20 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
)
// redeemArgs are a node's redeem call with a proof signed by the key seeded, over the keys given.
func redeemArgs(node string, seed byte, serving string) delivery.Redeem {
func redeemArgs(node string, seed byte, serving string) meshnodeenrollerclient.Redeem {
return redeemArgsWith(node, seed, serving, tunnelKey)
}
// redeemArgsWith is redeemArgs presenting the tunnel key given.
func redeemArgsWith(node string, seed byte, serving, overlay string) delivery.Redeem {
func redeemArgsWith(node string, seed byte, serving, overlay string) meshnodeenrollerclient.Redeem {
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{seed}, ed25519.SeedSize))
a := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
a := meshnodeenrollerclient.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)),
OverlayKey: overlay, SealingKey: "sealing-" + node, ServingKey: serving}
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, delivery.RedeemProof(node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, meshnodeenrollerclient.RedeemProof(node, a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
return a
}
@@ -36,7 +36,7 @@ func redeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
cases := []struct {
name string
caller string
args delivery.Redeem
args meshnodeenrollerclient.Redeem
setup func(r *rig)
want string
}{
@@ -54,11 +54,11 @@ func redeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
_, _ = r.st.Put(NodeKey("ace"), e, 0)
}, "waits for the operator's yes"},
{"no enrolment", "enrol.novox", redeemArgs("novox", 3, "s"), nil, "no enrolment of novox is open"},
{"another tunnel key than issued", "enrol.ace", func() delivery.Redeem {
{"another tunnel key than issued", "enrol.ace", func() meshnodeenrollerclient.Redeem {
priv := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize))
a := good
a.OverlayKey = "another-tunnel-key"
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, delivery.RedeemProof("ace", a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
a.Proof = base64.StdEncoding.EncodeToString(ed25519.Sign(priv, meshnodeenrollerclient.RedeemProof("ace", a.PublicKey, a.OverlayKey, a.SealingKey, a.ServingKey)))
return a
}(), nil, "not the one ace's enrolment was issued for"},
{"not in placement yet", "enrol.ace", good, func(r *rig) { delete(r.rd["mesh-delivery-assigner.placement"], "ace.node") }, "placement does not hold ace"},
@@ -95,7 +95,7 @@ func redeemRefusesWhatItMustAndSpendsTheTokenOnce(t *testing.T) {
t.Errorf("placement %+v, answer %+v", r.added, ans)
}
// The certificate's call carries every key presented, which the vault checks against the account's digest.
if r.vault.cert.Digest() != good.KeysDigest() {
if meshnodeenrollerclient.KeysDigest(r.vault.cert.Identity, r.vault.cert.Overlay, r.vault.cert.Sealing, r.vault.cert.Serving) != good.KeysDigest() {
t.Errorf("the certificate was asked with %+v", r.vault.cert)
}
r.loaded = false
@@ -0,0 +1,166 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/netip"
"regexp"
"strings"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The holder's state: the two kinds of key of `enrolments`, whose forms are the seat's contract (meshnodeenroller:
// EnrolmentsByNodeNodeValue, EnrolmentsByAddressAddressValue). The contract's decoding is strict by schema only; the rules
// of a value are the holder's, who writes it, and are kept here once (novox/hq ADR 0587 §2, ADR 0634 §2).
// The stages of an open enrolment, the contract's `asked, live or redeeming`.
const (
stageAsked = "asked"
stageLive = "live"
stageRedeeming = "redeeming"
)
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
// from redeem the digest of the public keys presented. No secret and nothing derived from the token.
type Enrolment struct {
V int `json:"v"`
Node string `json:"node"`
Address string `json:"address"`
Why string `json:"why"`
Caller string `json:"caller"`
Ask string `json:"ask,omitempty"`
Stage string `json:"stage"`
Issued time.Time `json:"issued"`
Expires time.Time `json:"expires"`
KeysDigest string `json:"keys-digest,omitempty"`
// OverlayKey is the machine's tunnel public key, base64 of 32 bytes, given at issue.
OverlayKey string `json:"overlay-key"`
}
// Reservation is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask is
// raised, that ask's id and the issue's time. Ask and Issued are both empty before the ask and both set from it.
type Reservation struct {
V int `json:"v"`
Node string `json:"node"`
Ask string `json:"ask,omitempty"`
Issued time.Time `json:"issued,omitzero"`
}
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
func nodeKey(node string) string { return meshnodeenroller.EnrolmentsByNodeNodeKey(node) }
func addressKey(addr string) string { return meshnodeenroller.EnrolmentsByAddressAddressKey(addr) }
func timeOf(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
}
func (e Enrolment) check() error {
if err := CheckNode(e.Node); err != nil {
return err
}
if _, err := netip.ParseAddr(e.Address); err != nil {
return fmt.Errorf("the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
}
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
return fmt.Errorf("the enrolment of %s names no why or no caller", e.Node)
}
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
return fmt.Errorf("the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
}
switch e.Stage {
case stageAsked, stageLive:
if e.KeysDigest != "" {
return fmt.Errorf("the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
}
case stageRedeeming:
if !digestForm.MatchString(e.KeysDigest) {
return fmt.Errorf("the enrolment's keys digest %q is not sha256:<64 lowercase hex digits>", e.KeysDigest)
}
default:
return fmt.Errorf("the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
}
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
return fmt.Errorf("the enrolment of %s names no issue or an expiry not after it", e.Node)
}
return nil
}
// encodeEnrolment is e as JSON at the layout version; it refuses what decodeEnrolment refuses.
func encodeEnrolment(e Enrolment) ([]byte, error) {
if err := e.check(); err != nil {
return nil, err
}
e.V = keys.Version
return json.Marshal(e)
}
// decodeEnrolment reads a key `node.<node>` through the contract's decoding; it refuses an unknown version, a node that is
// not a name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry
// not after the issue.
func decodeEnrolment(raw []byte) (Enrolment, error) {
if err := keys.CheckVersion(raw); err != nil {
return Enrolment{}, err
}
c, err := meshnodeenroller.DecodeEnrolmentsByNodeNodeValue(raw)
if err != nil {
return Enrolment{}, fmt.Errorf("the enrolment does not fit its shape: %w", err)
}
e := Enrolment{V: int(c.V), Node: c.Node, Address: c.Address, Why: c.Why, Caller: c.Caller, Ask: c.Ask, Stage: c.Stage,
Issued: timeOf(c.Issued), Expires: timeOf(c.Expires), KeysDigest: c.KeysDigest, OverlayKey: c.OverlayKey}
if err := e.check(); err != nil {
return Enrolment{}, err
}
return e, nil
}
func (r Reservation) check() error {
if err := CheckNode(r.Node); err != nil {
return err
}
if (strings.TrimSpace(r.Ask) == "") != r.Issued.IsZero() {
return fmt.Errorf("the address reserved for %s names its ask without the issue's time, or the reverse", r.Node)
}
return nil
}
// encodeReservation is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is reserved,
// with the issue's ask and time once the ask is raised.
func encodeReservation(node, ask string, issued time.Time) ([]byte, error) {
r := Reservation{V: keys.Version, Node: node, Ask: ask, Issued: issued}
if err := r.check(); err != nil {
return nil, err
}
return json.Marshal(r)
}
// decodeReservation reads a key `address.<address>` through the contract's decoding; it refuses no node, and an ask without
// the issue's time or the reverse.
func decodeReservation(raw []byte) (Reservation, error) {
if err := keys.CheckVersion(raw); err != nil {
return Reservation{}, err
}
c, err := meshnodeenroller.DecodeEnrolmentsByAddressAddressValue(raw)
if err != nil {
return Reservation{}, fmt.Errorf("the reservation does not fit its shape: %w", err)
}
r := Reservation{V: int(c.V), Node: c.Node, Ask: c.Ask, Issued: timeOf(c.Issued)}
if err := r.check(); err != nil {
return Reservation{}, err
}
return r, nil
}
// expiredEvent is the body of enrolment-expired, the contract's: the enrolment as it stood. The enrolment-issued event it
// ends is named by the issue time (the client's IssuedID).
func expiredEvent(e Enrolment) meshnodeenroller.EnrolmentExpiredEvent {
issued := e.Issued
return meshnodeenroller.EnrolmentExpiredEvent{Node: e.Node, Address: e.Address, Ask: e.Ask, Stage: stageLive, Issued: &issued, V: keys.Version}
}
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
)
// seed writes an open enrolment of node at stage, with its address key, as the holder would have.
@@ -17,7 +17,7 @@ func (r *rig) seed(node, address string, stage string) {
e := Enrolment{Node: node, Address: address, Why: "a new machine", Caller: "person.operator", Ask: "ask-" + node,
Stage: stage, Issued: r.now.Add(-time.Minute), Expires: r.now.Add(time.Hour), OverlayKey: tunnelKey}
if stage == StageRedeeming {
e.KeysDigest = delivery.KeysDigest("pk", "ov", "se", "sv")
e.KeysDigest = meshnodeenrollerclient.KeysDigest("pk", "ov", "se", "sv")
}
_, _ = r.st.Put(NodeKey(node), e, 0)
_, _ = r.st.Put(AddressKey(address), Reservation{Node: node, Ask: e.Ask, Issued: e.Issued}, 0)
@@ -107,11 +107,19 @@ func theManifestSaysWhatTheCodeDoes(t *testing.T) {
if !slices.Contains(m.Invokes, PlacementTool) || !slices.Equal(m.Tools, []string{"enrolment_first_views", "enrolment_first_view_again"}) {
t.Errorf("invokes %v, tools %v: want %s and the first-view tools", m.Invokes, m.Tools, PlacementTool)
}
// The holders the holder names beside its own are the manifest's (D-11): the vault's signers it reads, the seats it invokes.
if !slices.Contains(m.Reads, signersRead) || !slices.Contains(m.Invokes, placementAdd) || !slices.Contains(m.Invokes, vaultSign) ||
!slices.Contains(m.Invokes, vaultToken) || !slices.Contains(m.Invokes, vaultAccount) {
t.Errorf("reads %v, invokes %v: want %s, %s, %s, %s and %s", m.Reads, m.Invokes, signersRead, placementAdd, vaultSign, vaultToken, vaultAccount)
}
if enrolmentsBucket != m.Module+"_"+State || !strings.HasPrefix(PlacementTool, assignerModule+".") {
t.Errorf("the names %s, %s are not the manifest's module %s and the assigner %s", enrolmentsBucket, PlacementTool, m.Module, assignerModule)
}
var heard []string
for _, e := range EndEvents {
heard = append(heard, Seat+"."+e)
}
heard = append(heard, "mesh-delivery-assigner.views.placement")
heard = append(heard, assignerModule+".views."+placementState)
if !slices.Equal(m.Consumes, heard) {
t.Errorf("consumes %v, the end events are %v", m.Consumes, heard)
}
@@ -6,8 +6,8 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Tool names: the seat's verbs, as the manifest claims them (ADR 0587 §1, §8).
@@ -39,8 +39,8 @@ func flag(a map[string]any, k string) bool {
return a[k] == true || strings.EqualFold(arg(a, k), "true")
}
// lastsOf reads an expiry as a duration (delivery.TokenLife); none is the default.
func lastsOf(s string) (time.Duration, error) { return delivery.TokenLife(s) }
// lastsOf reads an expiry as a duration (meshnodeenrollerclient.TokenLife); none is the default.
func lastsOf(s string) (time.Duration, error) { return meshnodeenrollerclient.TokenLife(s) }
// Tools are what the runtime serves.
func Tools(h *Holder) []stdio.Tool {
@@ -72,7 +72,7 @@ func Tools(h *Holder) []stdio.Tool {
"overlay_key": str("its private-network key"), "sealing_key": str("its sealing key, base64"),
"serving_key": str("its serving key"), "proof": str("the proof, base64")}, "node", "public_key", "proof"),
Run: func(a map[string]any) (any, error) {
var args delivery.Redeem
var args meshnodeenrollerclient.Redeem
raw, err := json.Marshal(a)
if err != nil {
return nil, err
@@ -7,8 +7,10 @@ import (
"fmt"
"time"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
mv "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner"
)
// Seats the holder calls (ADR 0587 §3, §5, §7). The vault's two verbs are the ones its manifest names this seat the only
@@ -17,7 +19,7 @@ const (
vaultToken = "seat:mesh-vault." + mv.VerbEnrolmentToken
vaultAccount = "seat:mesh-vault." + mv.VerbNodeAccount
vaultSign = "seat:mesh-vault." + mv.VerbSign
placementAdd = "seat:mesh-placement.propose"
placementAdd = "seat:" + meshmoduleassigner.Seat + "." + meshmoduleassigner.VerbPropose
)
// Call asks a seat verb by its address (stdio.Ask through the runtime, or a test's).
@@ -40,8 +42,8 @@ func answer[T any](raw json.RawMessage, err error) (T, error) {
return out, nil
}
func (v vaultOver) Token(node string, expires time.Time, t delivery.TokenTunnel) (mv.EnrolmentTokenAnswer, error) {
// The whole first tunnel the token carries (delivery.TokenTunnel, catalogue's enrolment-token).
func (v vaultOver) Token(node string, expires time.Time, t meshnodeenrollerclient.TokenTunnel) (mv.EnrolmentTokenAnswer, error) {
// The whole first tunnel the token carries (meshnodeenrollerclient.TokenTunnel, catalogue's enrolment-token).
tunnel := map[string]any{"hub_key": t.HubKey, "hub_endpoint": t.HubEndpoint, "address": t.Address, "key": t.Key, "range": t.Range}
return answer[mv.EnrolmentTokenAnswer](v.call(vaultToken, map[string]any{"node": node,
"expires": expires.UTC().Format(time.RFC3339), "tunnel": tunnel}))
@@ -75,22 +77,22 @@ func (v vaultOver) Retire(node string) error {
return err
}
func (v vaultOver) SignCertificate(node string, k delivery.JoiningKeys) (mv.SignAnswer, error) {
return answer[mv.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindNodeCertificate, "node": node,
func (v vaultOver) SignCertificate(node string, k meshmoduleassigner.ProposalChangeKeys) (mv.SignAnswer, error) {
return answer[mv.SignAnswer](v.call(vaultSign, map[string]any{"kind": meshvaultclient.KindNodeCertificate, "node": node,
"public_key": k.Identity, "overlay_key": k.Overlay, "sealing_key": k.Sealing, "serving_key": k.Serving}))
}
func (v vaultOver) SignReply(node string, reply []byte) (mv.SignAnswer, error) {
return answer[mv.SignAnswer](v.call(vaultSign, map[string]any{"kind": delivery.KindEnrolment, "node": node,
return answer[mv.SignAnswer](v.call(vaultSign, map[string]any{"kind": meshvaultclient.KindEnrolment, "node": node,
"reply": base64.StdEncoding.EncodeToString(reply)}))
}
func (v vaultOver) Signer() (string, error) {
raw, found, err := v.read.Get(delivery.SignersRead, delivery.SignersKeyKey)
raw, found, err := v.read.Get(signersRead, signersKey)
if err != nil || !found {
return "", fmt.Errorf("the vault's signing key is not readable in %s: %v", delivery.SignersRead, err)
return "", fmt.Errorf("the vault's signing key is not readable in %s: %v", signersRead, err)
}
k, err := delivery.DecodeSignerKey(raw)
k, err := meshvaultclient.DecodeSignerKey(raw)
if err != nil {
return "", err
}
@@ -102,7 +104,7 @@ func addNodeOver(call Call) func(AddNode) error {
return func(a AddNode) error {
_, err := call(placementAdd, map[string]any{"why": a.Why, "changes": []any{a.Change}, "warrant": a.Warrant})
if err != nil {
return fmt.Errorf("mesh-placement did not take the node %s: %w", a.Change.Node, err)
return fmt.Errorf("mesh-module-assigner did not take the node %s: %w", a.Change.Node, err)
}
return nil
}
+5
View File
@@ -3,6 +3,11 @@ module mesh-enrolment
go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/placement/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000
+1 -1
View File
@@ -29,7 +29,7 @@
"invokes": [
"seat:mesh-hand-acts.hand-act",
"seat:mesh-hand-acts.warranted",
"seat:mesh-placement.propose",
"seat:mesh-module-assigner.propose",
"seat:mesh-vault.enrolment-token",
"seat:mesh-vault.node-account",
"seat:mesh-vault.sign",