The parts serve the gate: the bridge performs owed acts and merges, the teller carries asks, the indexer reads source.main, the view shows asks (design 47 R3e, issues 1341-1345) #9
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
|
||||
// kvRevisions is the bucket performed as the performer's revisions.
|
||||
type kvRevisions struct{ kv jetstream.KeyValue }
|
||||
|
||||
func (s kvRevisions) Get(ctx context.Context, key string) (uint64, bool, error) {
|
||||
e, err := s.kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
rev, err := strconv.ParseUint(string(e.Value()), 10, 64)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("%s holds %q, which is no revision: %w", key, e.Value(), err)
|
||||
}
|
||||
return rev, true, nil
|
||||
}
|
||||
|
||||
func (s kvRevisions) Put(ctx context.Context, key string, rev uint64) error {
|
||||
_, err := s.kv.PutString(ctx, key, strconv.FormatUint(rev, 10))
|
||||
return err
|
||||
}
|
||||
|
||||
// revisions is what the performer keeps of the revisions it acted at.
|
||||
type revisions interface {
|
||||
Get(ctx context.Context, key string) (rev uint64, found bool, err error)
|
||||
Put(ctx context.Context, key string, rev uint64) error
|
||||
}
|
||||
|
||||
// performer does on the forge what the gate's keys ask (novox/hq ADR 0437 §8): one act for each revision of an owed
|
||||
// key, one merge for each revision of a merges key. A refusal is written on FORGE and the act is not tried again until
|
||||
// the key has a new revision; the performer holds no timer and serves no request.
|
||||
type performer struct {
|
||||
call caller
|
||||
pub publisher
|
||||
done revisions
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// seen says whether the revision of the key was acted on already. A revision that cannot be read is not seen, so the
|
||||
// act is done: a status set twice is the same status.
|
||||
func (p *performer) seen(ctx context.Context, doneKey string, rev uint64) bool {
|
||||
kept, found, err := p.done.Get(ctx, doneKey)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s: the revision acted at could not be read, so the act is done again: %v", doneKey, err)
|
||||
return false
|
||||
}
|
||||
return found && kept >= rev
|
||||
}
|
||||
|
||||
func (p *performer) keep(ctx context.Context, doneKey string, rev uint64) {
|
||||
if err := p.done.Put(ctx, doneKey, rev); err != nil {
|
||||
p.logger.Printf("%s: revision %d was acted on but could not be kept, so a restart acts on it again: %v", doneKey, rev, err)
|
||||
}
|
||||
}
|
||||
|
||||
// refuse writes the refused message of an act on FORGE; it reports whether it was written. The message id makes a
|
||||
// second write of one revision's refusal the same message to the stream.
|
||||
func (p *performer) refuse(ctx context.Context, doneKey string, rev uint64, a bridge.Act, words string) bool {
|
||||
msg, err := bridge.RefusedMessage(a, words)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s: the forge refused %s (%s) and the refusal cannot be said on FORGE: %v", doneKey, a.Name, words, err)
|
||||
return false
|
||||
}
|
||||
id := fmt.Sprintf("refused.%s.%d", doneKey, rev)
|
||||
if _, err := p.pub.Publish(ctx, forgeSubject(msg.Event), msg.Data, jetstream.WithMsgID(id)); err != nil {
|
||||
p.logger.Printf("%s: the forge refused %s (%s) and FORGE could not be written: %v", doneKey, a.Name, words, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// owed performs the act an owed key asks at revision rev, once.
|
||||
func (p *performer) owed(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
doneKey := "owed." + key
|
||||
if p.seen(ctx, doneKey, rev) {
|
||||
return
|
||||
}
|
||||
a, err := bridge.OwedAct(key, value)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s at revision %d is not an act the bridge can perform, and is left: %v", key, rev, err)
|
||||
p.keep(ctx, doneKey, rev)
|
||||
return
|
||||
}
|
||||
if _, err := p.call(ctx, a.Call.Tool, a.Call.Args); err != nil {
|
||||
p.logger.Printf("%s at revision %d: %s refused: %v", key, rev, a.Call.Tool, err)
|
||||
if !p.refuse(ctx, doneKey, rev, a, err.Error()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
p.keep(ctx, doneKey, rev)
|
||||
}
|
||||
|
||||
// merges performs the merges a merges key hands the bridge at revision rev, once: each pull request in the state
|
||||
// merging, in the order the value names them, stopping at the first refusal (novox/hq ADR 0441 §3).
|
||||
func (p *performer) merges(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
doneKey := "merges." + key
|
||||
if p.seen(ctx, doneKey, rev) {
|
||||
return
|
||||
}
|
||||
acts, err := bridge.MergeActs(value)
|
||||
if err != nil {
|
||||
p.logger.Printf("%s at revision %d cannot be merged, and is left: %v", key, rev, err)
|
||||
p.keep(ctx, doneKey, rev)
|
||||
return
|
||||
}
|
||||
for _, a := range acts {
|
||||
if _, err := p.call(ctx, a.Call.Tool, a.Call.Args); err != nil {
|
||||
p.logger.Printf("%s at revision %d: the merge of %s#%d refused: %v", key, rev, a.Repository, a.Number, err)
|
||||
if !p.refuse(ctx, doneKey, rev, a, err.Error()) {
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
p.keep(ctx, doneKey, rev)
|
||||
}
|
||||
|
||||
// watch hands each key of a bucket, with the revision it has now and then each new one, to react until ctx ends or the
|
||||
// watch fails. A deleted key is not an act.
|
||||
func watch(ctx context.Context, kv jetstream.KeyValue, react func(key string, rev uint64, value []byte)) error {
|
||||
w, err := kv.Watch(ctx, ">")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case e, ok := <-w.Updates():
|
||||
if !ok {
|
||||
return errors.New("the watch ended")
|
||||
}
|
||||
if e == nil || e.Operation() != jetstream.KeyValuePut {
|
||||
continue
|
||||
}
|
||||
react(e.Key(), e.Revision(), e.Value())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const (
|
||||
headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
type memRevisions map[string]uint64
|
||||
|
||||
func (m memRevisions) Get(_ context.Context, key string) (uint64, bool, error) {
|
||||
r, ok := m[key]
|
||||
return r, ok, nil
|
||||
}
|
||||
func (m memRevisions) Put(_ context.Context, key string, rev uint64) error { m[key] = rev; return nil }
|
||||
|
||||
type tcall struct {
|
||||
key string
|
||||
body string
|
||||
}
|
||||
|
||||
// forgeFake records the calls and answers each from refuse, a map from a tool to its refusal.
|
||||
type forgeFake struct {
|
||||
calls []tcall
|
||||
refuse map[string]string
|
||||
}
|
||||
|
||||
func (f *forgeFake) call(_ context.Context, key string, body any) (json.RawMessage, error) {
|
||||
b, _ := json.Marshal(body)
|
||||
f.calls = append(f.calls, tcall{key, string(b)})
|
||||
if w, ok := f.refuse[key]; ok {
|
||||
return nil, errors.New(w)
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
func newPerformer(f *forgeFake) (*performer, *fakePub, memRevisions, *bytes.Buffer) {
|
||||
pub, done, logs := &fakePub{}, memRevisions{}, &bytes.Buffer{}
|
||||
return &performer{call: f.call, pub: pub, done: done, logger: log.New(logs, "", 0)}, pub, done, logs
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.Helper()
|
||||
key, err := delivery.OwedKey(repository, number, headA, act)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := delivery.EncodeOwed(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, v
|
||||
}
|
||||
|
||||
func TestAnOwedStatusIsSetOnceForARevision(t *testing.T) { // 1341/AC1
|
||||
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"})
|
||||
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"}` {
|
||||
t.Fatalf("calls = %+v", f.calls)
|
||||
}
|
||||
p.owed(context.Background(), key, 7, v) // the same revision seen again (a restart's replay)
|
||||
if len(f.calls) != 1 {
|
||||
t.Errorf("the same revision called the tool again: %+v", f.calls)
|
||||
}
|
||||
p.owed(context.Background(), key, 8, v) // a new revision of the key is a new act
|
||||
if len(f.calls) != 2 {
|
||||
t.Errorf("a new revision was not performed: %+v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewNoteAndCommentEachCallTheirToolOnce(t *testing.T) { // 1341/AC2
|
||||
f := &forgeFake{}
|
||||
p, _, _, _ := newPerformer(f)
|
||||
for _, c := range []struct {
|
||||
act string
|
||||
o delivery.Owed
|
||||
}{
|
||||
{"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"}},
|
||||
} {
|
||||
key, v := owedKV(t, "novox/mesh-delivery", 12, c.act, c.o)
|
||||
p.owed(context.Background(), key, 1, v)
|
||||
p.owed(context.Background(), key, 1, v)
|
||||
}
|
||||
var tools []string
|
||||
for _, c := range f.calls {
|
||||
tools = append(tools, c.key)
|
||||
}
|
||||
if got := strings.Join(tools, " "); got != "gitea.gitea_delivery_view gitea.gitea_note_append gitea.gitea_add_comment" {
|
||||
t.Errorf("calls = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
p.owed(context.Background(), key, 3, v)
|
||||
p.owed(context.Background(), key, 3, v)
|
||||
if len(f.calls) != 1 {
|
||||
t.Errorf("the refused act was tried %d times at one revision", len(f.calls))
|
||||
}
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.refused" {
|
||||
t.Fatalf("published on %v", pub.subjects)
|
||||
}
|
||||
m, err := delivery.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != delivery.ForgeRefused || m.Act != "comment.1" || m.Number != 12 || m.Head != headA || m.Words != "the pull request is locked" {
|
||||
t.Errorf("message %+v err %v", m, err)
|
||||
}
|
||||
f.refuse = nil
|
||||
p.owed(context.Background(), key, 4, v) // only a new revision tries again
|
||||
if len(f.calls) != 2 {
|
||||
t.Errorf("a new revision was not tried: %d calls", len(f.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOwedValueThatCannotBeReadIsLoggedAndNotRetried(t *testing.T) {
|
||||
f := &forgeFake{}
|
||||
p, pub, done, logs := newPerformer(f)
|
||||
p.owed(context.Background(), "not-a-key", 2, []byte(`{}`))
|
||||
if len(f.calls) != 0 || len(pub.subjects) != 0 {
|
||||
t.Errorf("calls %v published %v", f.calls, pub.subjects)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "not-a-key") {
|
||||
t.Errorf("log %q does not name the key", logs.String())
|
||||
}
|
||||
if rev, ok := done["owed.not-a-key"]; !ok || rev != 2 {
|
||||
t.Errorf("revision kept = %v %v", rev, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeKV(t *testing.T, m delivery.Merge) []byte {
|
||||
t.Helper()
|
||||
v, err := delivery.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 TestAMergeRevisionMergesOnlyTheMemberHanded(t *testing.T) { // 1342/AC1
|
||||
f := &forgeFake{}
|
||||
p, _, _, _ := newPerformer(f)
|
||||
m := twoMembers(delivery.MergeMerging, delivery.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 {
|
||||
t.Fatalf("calls = %+v", f.calls)
|
||||
}
|
||||
p.merges(context.Background(), "g", 5, mergeKV(t, m))
|
||||
if len(f.calls) != 1 {
|
||||
t.Errorf("a revision merged twice: %+v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
p.merges(context.Background(), "g", 5, v)
|
||||
p.merges(context.Background(), "g", 5, v)
|
||||
if len(f.calls) != 1 || len(pub.data) != 1 {
|
||||
t.Fatalf("calls %d messages %d, want one each", len(f.calls), len(pub.data))
|
||||
}
|
||||
m, err := delivery.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != delivery.ForgeRefused || m.Repository != "novox/mesh-delivery" || m.Number != 12 || m.Head != headA ||
|
||||
m.Act != "merge" || m.Words != "novox/mesh-delivery#12 is not merged: its head moved" {
|
||||
t.Errorf("message %+v err %v", m, err)
|
||||
}
|
||||
f.refuse = nil
|
||||
p.merges(context.Background(), "g", 6, v)
|
||||
if len(f.calls) != 2 {
|
||||
t.Errorf("a new revision was not tried: %d calls", len(f.calls))
|
||||
}
|
||||
}
|
||||
|
||||
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)))
|
||||
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))
|
||||
}
|
||||
}
|
||||
+162
@@ -25,6 +25,9 @@ import (
|
||||
// the controller's user, and the bridge's own principal <node>.<module> with the permissions the controller composes for
|
||||
// it, and no others, so a permission the bridge lacks fails a test here and not on the mesh (issue 1279).
|
||||
//
|
||||
// The gate's buckets mesh-delivery-gate_merges and mesh-delivery-gate_owed are read and watched (the manifest's reads), the
|
||||
// bucket performed is the bridge's own (its state), and the five forge tools of the gate's acts are invoked (issue 1341, 1342).
|
||||
//
|
||||
// The lists are what mesh-controller's broker.PermissionsFor answers (internal/broker/nats.go:577) for a module principal
|
||||
// with the manifest of module.json: consumes the three gitea.pull events, invokes gitea.gitea_open_pulls and
|
||||
// gitea.gitea_commit_trees, state heads, and the stream forge; `**` is written `>` on the wire (modstreams.go
|
||||
@@ -58,6 +61,42 @@ accounts {
|
||||
"mesh.mod.gitea.tool.gitea_commit_trees.*"
|
||||
"mesh.mod.gitea.tool.gitea_open_pulls"
|
||||
"mesh.mod.gitea.tool.gitea_open_pulls.*"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_performed"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_performed.>"
|
||||
"$KV.mesh-delivery-forge-bridge_performed.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-gate_merges"
|
||||
"$JS.FC.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_owed.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_owed.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-gate_owed.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-gate_owed"
|
||||
"$JS.FC.KV_mesh-delivery-gate_owed.>"
|
||||
"mesh.mod.gitea.call.gitea_merge_pull_request.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_merge_pull_request.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_merge_pull_request"
|
||||
"mesh.mod.gitea.tool.gitea_merge_pull_request.*"
|
||||
"mesh.mod.gitea.call.gitea_commit_status.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_commit_status.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_commit_status"
|
||||
"mesh.mod.gitea.tool.gitea_commit_status.*"
|
||||
"mesh.mod.gitea.call.gitea_delivery_view.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_delivery_view.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_delivery_view"
|
||||
"mesh.mod.gitea.tool.gitea_delivery_view.*"
|
||||
"mesh.mod.gitea.call.gitea_note_append.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_note_append.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_note_append"
|
||||
"mesh.mod.gitea.tool.gitea_note_append.*"
|
||||
"mesh.mod.gitea.call.gitea_add_comment.*.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.call.gitea_add_comment.n1~mesh-delivery-forge-bridge"
|
||||
"mesh.mod.gitea.tool.gitea_add_comment"
|
||||
"mesh.mod.gitea.tool.gitea_add_comment.*"
|
||||
"mesh.stream.mesh-delivery-forge-bridge.event.forge.>"
|
||||
] }
|
||||
subscribe { allow: [
|
||||
@@ -149,6 +188,13 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: headsBucket, History: 1}); err != nil {
|
||||
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, delivery.MergesBucket: delivery.MergesHistory, delivery.OwedBucket: delivery.OwedHistory} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if prepare != nil {
|
||||
prepare(ctx, js)
|
||||
}
|
||||
@@ -475,3 +521,119 @@ func TestBridgeReadsTheForgeAtStartAndAfterAReconnectAndHearsNoOlderEvent(t *tes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBridgeMergesAGroupOneMemberAtATime: the gate (played by the test) writes merges.g with member A handed and the
|
||||
// Approve's heads of A and B; a stand-in forge merges A and announces it; the gate then hands B; both end merged on FORGE
|
||||
// (1342/AC3, ADR 0437 §8's check).
|
||||
func TestBridgeMergesAGroupOneMemberAtATime(t *testing.T) { // 1342/AC3
|
||||
b := startBed(t, `{"result":{"pulls":[]}}`, nil)
|
||||
const headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
const mergeA, mergeB = "1111111111111111111111111111111111111111", "2222222222222222222222222222222222222222"
|
||||
merges := make(chan string, 4)
|
||||
// The stand-in for gitea_merge_pull_request answers in the reply envelope of mesh-sdk go/process/call.go and publishes
|
||||
// gitea.pull.merged as mesh-catalog modules/gitea/index.ts:186-207 does, with the merge commit of the member merged.
|
||||
sub, err := b.ctl.Subscribe("mesh.mod.gitea.tool.gitea_merge_pull_request", func(m *nats.Msg) {
|
||||
var a struct {
|
||||
Repo string `json:"repo"`
|
||||
Head string `json:"head"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Data, &a)
|
||||
merges <- string(m.Data)
|
||||
commit, number := mergeA, 12
|
||||
if a.Repo == "mesh-sdk" {
|
||||
commit, number = mergeB, 4
|
||||
}
|
||||
ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": a.Repo, "number": number, "head_sha": a.Head, "head": "feat/x",
|
||||
"base": "main", "merge_commit_sha": commit, "module_dirs": []string{"modules/a"}, "module_dirs_said": true})
|
||||
_, _ = b.js.Publish(b.ctx, "mesh.mod.gitea.event.pull.merged", ev)
|
||||
_ = m.Respond([]byte(`{"result":{"merged":true},"node":"n0"}`))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
trees, err := b.ctl.Subscribe("mesh.mod.gitea.tool.gitea_commit_trees", func(m *nats.Msg) {
|
||||
_ = m.Respond([]byte(`{"result":{"root":"r0","trees":{"":"t0","modules/a":"t1"}},"node":"n0"}`))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = trees.Unsubscribe() })
|
||||
if err := b.ctl.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
kv, err := b.js.KeyValue(b.ctx, delivery.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})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(b.ctx, "g", v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
next := func(repo string) {
|
||||
select {
|
||||
case got := <-merges:
|
||||
if !strings.Contains(got, `"repo":"`+repo+`"`) {
|
||||
t.Errorf("the forge was asked %s, want %s", got, repo)
|
||||
}
|
||||
for _, want := range []string{"novox/mesh-delivery@" + head, "novox/mesh-sdk@" + headB} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the merge %s does not carry the approved head %s", got, want)
|
||||
}
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("%s was not merged within 10 s", repo)
|
||||
}
|
||||
}
|
||||
put(pullA)
|
||||
next("mesh-delivery")
|
||||
// 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
|
||||
put(pullA, pullB)
|
||||
next("mesh-sdk")
|
||||
var got []string
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if got = forgeMerged(t, b); len(got) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("FORGE holds merged messages for %v, want both members", got)
|
||||
}
|
||||
select {
|
||||
case extra := <-merges:
|
||||
t.Errorf("a third merge was asked: %s", extra)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// forgeMerged lists the repositories FORGE holds a merged message for, sorted.
|
||||
func forgeMerged(t *testing.T, b *bed) []string {
|
||||
t.Helper()
|
||||
forge, err := b.js.Stream(b.ctx, "FORGE")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, repo := range []string{"mesh-delivery", "mesh-sdk"} {
|
||||
raw, err := forge.GetLastMsgForSubject(b.ctx, "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox."+repo+".merged")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if f, err := delivery.DecodeForge(raw.Data); err == nil && f.Kind == delivery.ForgeMerged {
|
||||
out = append(out, f.Repository)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -196,6 +196,35 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// Everything written on FORGE, an event's and a sync's, keeps the head in the bucket.
|
||||
rec := &recorder{pub: js, heads: heads, logger: logger}
|
||||
said := func(ctx context.Context) error { return syncOpen(ctx, call, rec, heads, &turn, logger) }
|
||||
// What the gate asks of the forge (ADR 0437 §8): its owed acts and its merges, each performed once per revision.
|
||||
performedKV, err := js.KeyValue(ctx, performedBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", performedBucket, err)
|
||||
}
|
||||
perf := &performer{call: call, pub: rec, done: kvRevisions{performedKV}, logger: logger}
|
||||
watching := make(chan error, 2)
|
||||
for _, w := range []struct {
|
||||
bucket string
|
||||
react func(ctx context.Context, key string, rev uint64, value []byte)
|
||||
}{{delivery.OwedBucket, perf.owed}, {delivery.MergesBucket, perf.merges}} {
|
||||
kv, err := js.KeyValue(ctx, w.bucket)
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
// The gate's buckets come with the gate; the bridge does not wait for them (no timer), and is started again
|
||||
// by its delivery when the gate arrives.
|
||||
logger.Printf("the bucket %s does not exist yet: nothing of it is performed until the bridge starts again", w.bucket)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", w.bucket, err)
|
||||
}
|
||||
go func() {
|
||||
watching <- watch(ctx, kv, func(key string, rev uint64, value []byte) {
|
||||
hctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
defer cancel()
|
||||
w.react(hctx, key, rev, value)
|
||||
})
|
||||
}()
|
||||
}
|
||||
if err := said(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -217,6 +246,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return err
|
||||
}
|
||||
defer cc.Stop()
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-watching:
|
||||
return fmt.Errorf("watching the gate's keys: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9 h1:XL1dbtSleGAixg6FX4gEnSG/AJxRG/tWw6/7U1RuzHc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019195003-4580d80a32d9/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"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.
|
||||
type Call struct {
|
||||
Tool string
|
||||
Args map[string]any
|
||||
}
|
||||
|
||||
// Act is one thing the bridge does on the forge for a key of the gate: the act's name (an owed key's act, `status=<context>`,
|
||||
// `view`, `note`, `comment.<seq>`, or `merge`), the pull request and head it is on (Number 0 for a commit no pull
|
||||
// request names) and the forge call that performs it.
|
||||
type Act struct {
|
||||
Name string
|
||||
Repository string
|
||||
Number int
|
||||
Head string
|
||||
Call Call
|
||||
}
|
||||
|
||||
// The forge's tools, by manifest key; their arguments are the ones of mesh-catalog modules/gitea: delivery.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).
|
||||
const (
|
||||
commitStatusTool = "gitea.gitea_commit_status"
|
||||
deliveryViewTool = "gitea.gitea_delivery_view"
|
||||
noteAppendTool = "gitea.gitea_note_append"
|
||||
addCommentTool = "gitea.gitea_add_comment"
|
||||
mergePullTool = "gitea.gitea_merge_pull_request"
|
||||
)
|
||||
|
||||
// MergeAct is the name of the act of merging a pull request.
|
||||
const MergeAct = "merge"
|
||||
|
||||
func ownerRepo(repository string) (owner, repo string) {
|
||||
owner, repo, _ = strings.Cut(repository, "/")
|
||||
return owner, repo
|
||||
}
|
||||
|
||||
// OwedAct turns an owed key and its value (mesh-sdk go/delivery OwedKey, EncodeOwed) into the forge call that performs
|
||||
// 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)
|
||||
if err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
o, err := delivery.DecodeOwed(value)
|
||||
if err != nil {
|
||||
return Act{}, fmt.Errorf("the owed act %s: %w", key, err)
|
||||
}
|
||||
kind, context, err := delivery.ParseOwedAct(name)
|
||||
if err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
if o.Act != kind {
|
||||
return Act{}, fmt.Errorf("the owed act %s says %q, not the %q its key names", key, o.Act, kind)
|
||||
}
|
||||
owner, repo := ownerRepo(repository)
|
||||
a := Act{Name: name, Repository: repository, Number: number, Head: head}
|
||||
needsPull := func() error {
|
||||
if number <= 0 {
|
||||
return fmt.Errorf("the owed %s %s is on a commit no pull request names, and it needs the pull request", kind, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case delivery.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)
|
||||
}
|
||||
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:
|
||||
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:
|
||||
a.Call = Call{Tool: noteAppendTool, Args: map[string]any{"owner": owner, "repo": repo, "sha": head, "ref": o.Ref, "line": o.Text}}
|
||||
case delivery.OwedComment:
|
||||
if err := needsPull(); err != nil {
|
||||
return Act{}, err
|
||||
}
|
||||
a.Call = Call{Tool: addCommentTool, Args: map[string]any{"owner": owner, "repo": repo, "number": number, "body": o.Text}}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// MergeActs turns the value of a merges key (mesh-sdk go/delivery 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var handed []delivery.MergePull
|
||||
for _, p := range m.Pulls {
|
||||
if p.State == delivery.MergeMerging {
|
||||
handed = append(handed, p)
|
||||
}
|
||||
}
|
||||
if len(handed) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if len(m.Approved) == 0 {
|
||||
return nil, fmt.Errorf("the merge of the group %s names no approved heads, so the forge's merge tool has nothing to merge at", m.Group)
|
||||
}
|
||||
approved := make([]string, 0, len(m.Approved))
|
||||
for key, sha := range m.Approved {
|
||||
repository, _, ok := strings.Cut(key, "@")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the approved head %q of the group %s is not <repository>@<number>", key, m.Group)
|
||||
}
|
||||
approved = append(approved, repository+"@"+sha)
|
||||
}
|
||||
slices.Sort(approved)
|
||||
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,
|
||||
"approved_heads": approved}}})
|
||||
}
|
||||
return acts, nil
|
||||
}
|
||||
|
||||
// RefusedMessage is the refused message the bridge writes on FORGE when the forge refuses an act: the act, the pull
|
||||
// request and head it was on, and the forge's own words.
|
||||
func RefusedMessage(a Act, words string) (Message, error) {
|
||||
event, err := delivery.ForgeEvent(a.Repository, delivery.ForgeRefused)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
body, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeRefused, Repository: a.Repository, Number: a.Number,
|
||||
Head: a.Head, Act: a.Name, Words: words})
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return Message{Event: event, Data: body}, nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const (
|
||||
headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
t.Helper()
|
||||
key, err := delivery.OwedKey(repository, number, headA, act)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := delivery.EncodeOwed(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, b
|
||||
}
|
||||
|
||||
func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1
|
||||
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"})
|
||||
a, err := OwedAct(key, v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Call{Tool: "gitea.gitea_commit_status", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "sha": headA,
|
||||
"context": "mesh/merge-gate", "state": "pending", "description": "waiting", "target_url": "https://forge.example/novox/mesh-delivery/pulls/12"}}
|
||||
if !reflect.DeepEqual(a.Call, want) {
|
||||
t.Errorf("call = %+v, want %+v", a.Call, want)
|
||||
}
|
||||
if a.Repository != "novox/mesh-delivery" || a.Number != 12 || a.Head != headA || a.Name != "status=mesh/merge-gate" {
|
||||
t.Errorf("act = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedViewNoteAndCommentCalls(t *testing.T) { // 1341/AC2
|
||||
cases := []struct {
|
||||
act string
|
||||
o delivery.Owed
|
||||
want Call
|
||||
}{
|
||||
{"view", delivery.Owed{Act: delivery.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"},
|
||||
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"},
|
||||
Call{Tool: "gitea.gitea_add_comment", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "body": "hello"}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
key, v := owedValue(t, "novox/mesh-delivery", 12, c.act, c.o)
|
||||
a, err := OwedAct(key, v)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.act, err)
|
||||
}
|
||||
if !reflect.DeepEqual(a.Call, c.want) {
|
||||
t.Errorf("%s: call = %+v, want %+v", c.act, a.Call, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedActWithoutAPullRequestNeedsOneOnlyWhereTheToolDoes(t *testing.T) {
|
||||
key, v := owedValue(t, "novox/mesh-delivery", 0, "note", delivery.Owed{Act: delivery.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"})
|
||||
if _, err := OwedAct(key, v); err == nil {
|
||||
t.Error("a view of no pull request was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedActOfADottedRepositoryIsUnescaped(t *testing.T) {
|
||||
key, v := owedValue(t, "novox/my.repo", 3, "comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "x"})
|
||||
a, err := OwedAct(key, v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Call.Args["repo"] != "my.repo" || a.Repository != "novox/my.repo" {
|
||||
t.Errorf("act = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedActThatDisagreesWithItsKeyIsRefused(t *testing.T) {
|
||||
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"})
|
||||
if _, err := OwedAct(key, v); err == nil {
|
||||
t.Error("a comment under a view's key was taken")
|
||||
}
|
||||
if _, err := OwedAct("nonsense", v); err == nil {
|
||||
t.Error("a key that is no owed key was taken")
|
||||
}
|
||||
if _, err := OwedAct(key, []byte(`{"v":1`)); err == nil {
|
||||
t.Error("a value that is not json was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func mergeValue(t *testing.T, m delivery.Merge) []byte {
|
||||
t.Helper()
|
||||
b, err := delivery.EncodeMerge(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
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},
|
||||
}})
|
||||
acts, err := MergeActs(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(acts) != 1 {
|
||||
t.Fatalf("%d acts, want 1 (the merged member is not merged again)", len(acts))
|
||||
}
|
||||
want := Call{Tool: "gitea.gitea_merge_pull_request", Args: map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "head": headA,
|
||||
"approved_heads": []string{"novox/mesh-delivery@" + headA, "novox/mesh-sdk@" + headB}}}
|
||||
if !reflect.DeepEqual(acts[0].Call, want) {
|
||||
t.Errorf("call = %+v, want %+v", acts[0].Call, want)
|
||||
}
|
||||
if acts[0].Name != "merge" || acts[0].Number != 12 || acts[0].Head != headA {
|
||||
t.Errorf("act = %+v", acts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeActsOfAMergeWithoutApprovedHeadsAreRefused(t *testing.T) {
|
||||
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}}})
|
||||
if _, err := MergeActs(v); err == nil {
|
||||
t.Error("a merge with no approved heads was taken: the forge's tool would refuse it with nothing to say which")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3, 1342/AC2
|
||||
a := Act{Name: "merge", Repository: "novox/mesh-delivery", Number: 12, Head: headA}
|
||||
m, err := RefusedMessage(a, "novox/mesh-delivery#12 is not merged: its head moved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Event != "forge.novox.mesh-delivery.refused" {
|
||||
t.Errorf("event = %q", m.Event)
|
||||
}
|
||||
f, err := delivery.DecodeForge(m.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA,
|
||||
Act: "merge", Words: "novox/mesh-delivery#12 is not merged: its head moved"}
|
||||
if !delivery.SameForge(f, want) {
|
||||
t.Errorf("message = %+v", f)
|
||||
}
|
||||
}
|
||||
@@ -102,9 +102,11 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
|
||||
if m.SecretsOwner != "mesh-delivery-forge-bridge" {
|
||||
t.Errorf("secrets-owner = %q", m.SecretsOwner)
|
||||
}
|
||||
// One bucket: the head last written on FORGE for each pull request, so a sync writes only what differs (ADR 0443 §5).
|
||||
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}}) { // 1279/AC6
|
||||
t.Errorf("state = %+v, want the one bucket heads", m.State)
|
||||
// Two buckets: the head last written on FORGE for each pull request, so a sync writes only what differs (ADR 0443
|
||||
// §5), and the revision of each key of the gate's buckets the bridge performed, so a key is performed once per
|
||||
// revision (ADR 0437 §8; issue 1341).
|
||||
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "performed", History: 1}}) { // 1279/AC6, 1341
|
||||
t.Errorf("state = %+v, want the buckets heads and performed", m.State)
|
||||
}
|
||||
// No other state, no bus, no seat, no tool (ADR 0437 §4).
|
||||
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "tools", "seat", "seats"} {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
{ "name": "forge", "events": ["forge.**"] }
|
||||
],
|
||||
"state": [
|
||||
{ "name": "heads", "history": 1 }
|
||||
{ "name": "heads", "history": 1 },
|
||||
{ "name": "performed", "history": 1 }
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-gate.merges",
|
||||
|
||||
@@ -255,9 +255,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The gate's source bucket (mesh-delivery-gate_source) is read from release 3d on; it does not exist before, and the
|
||||
// indexer does not wait for it.
|
||||
|
||||
known := index.NewBuilds()
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
@@ -274,6 +271,27 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
go func() { errs <- follow(ctx, w, each) }()
|
||||
|
||||
ix := &index.Indexer{Store: kvStore{candidates}, Modules: kvStore{modules}, Builds: known, Logf: logger.Printf}
|
||||
// The gate's source bucket (mesh-delivery-gate_source) comes with the gate (release 3e, novox/hq ADR 0450 §1): its key
|
||||
// main is the mesh's source revision, which stamps the modules (issue 1344). Where it does not exist yet the indexer does
|
||||
// not wait for it (that needs a timer, ADR 0437 §8): it says so, and is started again with the gate.
|
||||
sourceErrs := make(chan error, 1)
|
||||
switch source, err := js.KeyValue(ctx, delivery.SourceBucket); {
|
||||
case errors.Is(err, jetstream.ErrBucketNotFound):
|
||||
logger.Printf("the bucket %s does not exist yet: no source revision is read until the indexer starts again", delivery.SourceBucket)
|
||||
case err != nil:
|
||||
return fmt.Errorf("the bucket %s: %w", delivery.SourceBucket, err)
|
||||
default:
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if opOf(e) {
|
||||
applySource(ctx, ix, e.Key(), e.Revision(), e.Value(), logger)
|
||||
}
|
||||
}
|
||||
sw, err := settle(ctx, source, each)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { sourceErrs <- follow(ctx, sw, each) }()
|
||||
}
|
||||
cons, err := js.Consumer(ctx, forgeStream, cred.Node+"_"+cred.Module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the consumer %s_%s on %s: %w", cred.Node, cred.Module, forgeStream, err)
|
||||
@@ -289,5 +307,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return nil
|
||||
case err := <-errs:
|
||||
return err
|
||||
case err := <-sourceErrs:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,3 +67,24 @@ func TestAMessageThatIsNoForgeMessageIsTerminatedAndLogged(t *testing.T) { // 12
|
||||
t.Errorf("terminated %d acked %d keys %d log %q", m.termd, m.acked, len(st.vals), logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSourceMainKeyIsTakenAtItsRevision(t *testing.T) { // 1344/AC1 (the watch's side: key, revision, value)
|
||||
var logs bytes.Buffer
|
||||
logger := log.New(&logs, "", 0)
|
||||
mods := &memStore{vals: map[string][]byte{}}
|
||||
ix := &index.Indexer{Store: &memStore{vals: map[string][]byte{}}, Modules: mods, Builds: index.NewBuilds()}
|
||||
value, err := delivery.EncodeSource(delivery.Source{Group: "g", Heads: map[string]string{"novox/mesh-delivery": head}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applySource(context.Background(), ix, "other", 3, value, logger) // not the key of the mesh's source
|
||||
applySource(context.Background(), ix, delivery.SourceMainKey, 4, []byte(`{"v":1`), logger)
|
||||
if !strings.Contains(logs.String(), "source") {
|
||||
t.Errorf("a source that cannot be read was not said: %q", logs.String())
|
||||
}
|
||||
logs.Reset()
|
||||
applySource(context.Background(), ix, delivery.SourceMainKey, 5, value, logger)
|
||||
if logs.Len() != 0 {
|
||||
t.Errorf("a good source said %q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-indexer/internal/index"
|
||||
)
|
||||
|
||||
// applySource takes one put of the gate's bucket source into the indexer: the key main, at its revision, which is the mesh's
|
||||
// source revision (mesh-sdk go/delivery SourceMainKey, Source). Any other key is not the mesh's source. A value that is no
|
||||
// source is said; so is a write that failed, and it is tried again only at the next revision (novox/hq ADR 0437 §8).
|
||||
func applySource(ctx context.Context, ix *index.Indexer, key string, rev uint64, value []byte, logger *log.Logger) {
|
||||
if key != delivery.SourceMainKey {
|
||||
return
|
||||
}
|
||||
s, err := delivery.DecodeSource(value)
|
||||
if err != nil {
|
||||
logger.Printf("source.%s at revision %d is not a source, and is left: %v", key, rev, err)
|
||||
return
|
||||
}
|
||||
if err := ix.ApplySource(ctx, rev, s); err != nil {
|
||||
logger.Printf("source.%s at revision %d could not be taken into the modules: %v", key, rev, err)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-indexer
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337 h1:cZyFnvDi
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g/Q86N8CCHq3OatJIJ0JSy6Kh980ekGxNLmE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -44,6 +45,9 @@ type Indexer struct {
|
||||
Modules Store
|
||||
Builds *Builds
|
||||
Logf func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex // guards source, which the source watch and the forge messages both reach
|
||||
source sourceNow
|
||||
}
|
||||
|
||||
func (ix *Indexer) logf(format string, args ...any) {
|
||||
|
||||
@@ -45,7 +45,9 @@ func fingerprintOf(k Known, trees map[string]string) (fp, none string) {
|
||||
}
|
||||
|
||||
// applyMerged writes, for each module built from the merged repository, its fingerprint at the merge commit (or none and
|
||||
// why). A module of another repository is not written. A value the module already holds is not written again.
|
||||
// why), with the source revision that names the merge commit as the repository's head when the indexer has seen it
|
||||
// (ApplySource), and Src 0 when it has not. A module of another repository is not written. A value the module already
|
||||
// holds is not written again.
|
||||
func (ix *Indexer) applyMerged(ctx context.Context, f delivery.Forge) error {
|
||||
for _, k := range ix.Builds.Modules(f.Repository) {
|
||||
key, err := delivery.ModulesKey(k.Module)
|
||||
@@ -54,7 +56,7 @@ func (ix *Indexer) applyMerged(ctx context.Context, f delivery.Forge) error {
|
||||
continue
|
||||
}
|
||||
fp, none := fingerprintOf(k, f.Trees)
|
||||
next := delivery.Module{Repository: f.Repository, Commit: f.Merge, FP: fp, None: none}
|
||||
next := delivery.Module{Repository: f.Repository, Commit: f.Merge, FP: fp, None: none, Src: ix.srcOf(f.Repository, f.Merge)}
|
||||
err = write(ctx, ix.Modules, key, func(value []byte, found bool) ([]byte, bool, error) {
|
||||
if found {
|
||||
if cur, err := delivery.DecodeModule(value); err == nil && delivery.SameModule(cur, withV(next)) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The gate's source revision (novox/hq ADR 0437 §5, ADR 0450 §1): source.main names every repository's merged head, and its
|
||||
// revision in the bucket is the mesh's source revision. The indexer keeps the latest it has seen. A module is stamped
|
||||
// with it (Module.Src) when the head the source names for its repository is the merge commit the module's fingerprint
|
||||
// was computed at: from the modules bucket when the merge was heard first, and when the merge is heard after the source.
|
||||
|
||||
// sourceNow is the latest source revision seen.
|
||||
type sourceNow struct {
|
||||
rev uint64
|
||||
heads map[string]string
|
||||
}
|
||||
|
||||
// srcOf is the source revision that names this merge commit as the repository's head, or 0 when the latest source seen
|
||||
// names another head or none has been seen.
|
||||
func (ix *Indexer) srcOf(repository, merge string) uint64 {
|
||||
ix.mu.Lock()
|
||||
defer ix.mu.Unlock()
|
||||
if ix.source.rev != 0 && merge != "" && ix.source.heads[repository] == merge {
|
||||
return ix.source.rev
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ApplySource takes a revision of the gate's source.main in. An older revision than the latest seen is left. For each
|
||||
// repository the source names, each module built from it whose fingerprint was computed at the head the source names is
|
||||
// written again with Src the revision; a module computed at another commit is left (a merge heard later is stamped
|
||||
// when it is written, applyMerged), and a repository none of the indexer's modules is built from writes nothing.
|
||||
func (ix *Indexer) ApplySource(ctx context.Context, rev uint64, s delivery.Source) error {
|
||||
ix.mu.Lock()
|
||||
if rev < ix.source.rev {
|
||||
ix.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
ix.source = sourceNow{rev: rev, heads: maps.Clone(s.Heads)}
|
||||
ix.mu.Unlock()
|
||||
if ix.Modules == nil {
|
||||
return nil
|
||||
}
|
||||
repositories := slices.Sorted(maps.Keys(s.Heads))
|
||||
for _, repository := range repositories {
|
||||
head := s.Heads[repository]
|
||||
for _, k := range ix.Builds.Modules(repository) {
|
||||
key, err := delivery.ModulesKey(k.Module)
|
||||
if err != nil {
|
||||
ix.logf("%s: the module %q has no usable key, skipped: %v", repository, k.Module, err)
|
||||
continue
|
||||
}
|
||||
err = write(ctx, ix.Modules, key, func(value []byte, found bool) ([]byte, bool, error) {
|
||||
if !found {
|
||||
return nil, false, nil
|
||||
}
|
||||
cur, err := delivery.DecodeModule(value)
|
||||
if err != nil {
|
||||
ix.logf("modules.%s holds what is not a module, left: %v", key, err)
|
||||
return nil, false, nil
|
||||
}
|
||||
if cur.Repository != repository || cur.Commit != head || cur.Src == rev {
|
||||
return nil, false, nil
|
||||
}
|
||||
cur.Src = rev
|
||||
data, err := delivery.EncodeModule(cur)
|
||||
return data, false, err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("modules.%s: %w", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package index
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const otherMerge = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
func sourceOf(repo, head string) delivery.Source {
|
||||
return delivery.Source{Group: "g", Heads: map[string]string{repo: head}}
|
||||
}
|
||||
|
||||
func TestASourceRevisionStampsTheModulesOfTheRepositoriesHeadItNames(t *testing.T) { // 1344/AC1
|
||||
ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs),
|
||||
rec(t, "build-2", "c", "novox/mesh-catalog", "modules/c", "main", "", "2026-10-19T09:00:00Z", aInputs))
|
||||
ctx := context.Background()
|
||||
if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := moduleOf(t, mods, "a")
|
||||
if before.Src != 0 {
|
||||
t.Fatalf("module before the source = %+v, want Src 0", before)
|
||||
}
|
||||
// The source names the head merged for the delivery repository, and another repository's head no module of ours is built from.
|
||||
s := delivery.Source{Group: "g", Heads: map[string]string{"novox/mesh-delivery": merge, "novox/mesh-catalog": otherMerge}}
|
||||
if err := ix.ApplySource(ctx, 7, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := moduleOf(t, mods, "a")
|
||||
want := before
|
||||
want.Src = 7
|
||||
if !ok || m != want {
|
||||
t.Errorf("module a = %+v (%v), want %+v: the fingerprint and the commit as the merge wrote them, with Src 7", m, ok, want)
|
||||
}
|
||||
if c, ok := moduleOf(t, mods, "c"); ok {
|
||||
t.Errorf("module c = %+v: written for a head no merge message of the indexer carried", c)
|
||||
}
|
||||
// The same revision again changes nothing; a later revision moves Src.
|
||||
if err := ix.ApplySource(ctx, 7, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ix.ApplySource(ctx, 9, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, _ := moduleOf(t, mods, "a"); m.Src != 9 {
|
||||
t.Errorf("module a Src = %d, want 9", m.Src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMergeHeardAfterTheSourceNamesItsHeadIsStampedWhenWritten(t *testing.T) { // 1344/AC1 (the source first)
|
||||
ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs))
|
||||
ctx := context.Background()
|
||||
if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := moduleOf(t, mods, "a"); ok {
|
||||
t.Fatal("a module was written from a source revision alone: its tree is the merge message's")
|
||||
}
|
||||
if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, ok := moduleOf(t, mods, "a"); !ok || m.Src != 7 || m.Commit != merge || m.FP == "" {
|
||||
t.Errorf("module a = %+v (%v), want the merge's fingerprint with Src 7", m, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMergeThatNoSourceRevisionNamesIsWrittenAsToday(t *testing.T) { // 1344/AC2
|
||||
ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs))
|
||||
ctx := context.Background()
|
||||
// No source revision yet.
|
||||
if err := ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, ok := moduleOf(t, mods, "a"); !ok || m.Src != 0 || m.FP == "" {
|
||||
t.Fatalf("module a = %+v (%v), want a fingerprint with Src 0", m, ok)
|
||||
}
|
||||
// A source revision that names another head of the repository: a merge of the head it does not name keeps Src 0.
|
||||
if err := ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", otherMerge)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, _ := moduleOf(t, mods, "a"); m.Src != 0 {
|
||||
t.Errorf("module a Src = %d after a source naming another head, want 0", m.Src)
|
||||
}
|
||||
merged := mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newertree"})
|
||||
merged.Merge = "cccccccccccccccccccccccccccccccccccccccc"
|
||||
if err := ix.Apply(ctx, merged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, _ := moduleOf(t, mods, "a"); m.Src != 0 || m.Commit != merged.Merge {
|
||||
t.Errorf("module a = %+v, want the new merge with Src 0", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASourceRevisionOlderThanTheOneHeldIsLeft(t *testing.T) {
|
||||
ix, mods := modulesIndexer(t, rec(t, "build-1", "a", "novox/mesh-delivery", "modules/a", "main", "", "2026-10-19T09:00:00Z", aInputs))
|
||||
ctx := context.Background()
|
||||
_ = ix.Apply(ctx, mergedMsg("novox/mesh-delivery", map[string]string{"modules/a": "newtree"}))
|
||||
_ = ix.ApplySource(ctx, 9, sourceOf("novox/mesh-delivery", merge))
|
||||
_ = ix.ApplySource(ctx, 7, sourceOf("novox/mesh-delivery", merge))
|
||||
if m, _ := moduleOf(t, mods, "a"); m.Src != 9 {
|
||||
t.Errorf("module a Src = %d, want 9", m.Src)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The teller's three bus adapters for mesh-sdk go/asker, which in a bundle reaches the bus through the runtime: here the
|
||||
// teller is a process on its own credential. What each may do is the controller's composed grant for a module that uses
|
||||
// the seat operator-channel (mesh-control internal/broker seattraffic.go SeatTrafficOf): publish its own ask, cancel and
|
||||
// condition, hear its own decided event, and read the router's record of its own asks.
|
||||
|
||||
// callTimeout bounds a read of the router's record.
|
||||
const callTimeout = 30 * time.Second
|
||||
|
||||
// publishVia publishes an accept of the seat, de-duplicated by id: the same id twice is one message.
|
||||
func publishVia(ctx context.Context, js jetstream.JetStream) func(subject string, body any, id string) error {
|
||||
return func(subject string, body any, id string) error {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var opts []jetstream.PublishOpt
|
||||
if id != "" {
|
||||
opts = append(opts, jetstream.WithMsgID(id))
|
||||
}
|
||||
_, err = js.Publish(ctx, subject, data, opts...)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// recordVia reads the router's record of one of this module's asks: the key `<module>.<id>` of the router's bucket asks,
|
||||
// by a direct get on the subject the controller grants for it, `$JS.API.DIRECT.GET.KV_asks.$KV.asks.<module>.>`. The
|
||||
// record is {state, warrant}. An ask the router holds no record of is nil.
|
||||
func recordVia(ctx context.Context, conn *nats.Conn, module string) func(id string) (*stdio.StateEntry, error) {
|
||||
return func(id string) (*stdio.StateEntry, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, callTimeout)
|
||||
defer cancel()
|
||||
msg, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_asks.$KV.asks."+module+"."+id, nil)
|
||||
if errors.Is(err, nats.ErrNoResponders) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status := msg.Header.Get("Status"); status == "404" || len(msg.Data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &stdio.StateEntry{Key: id, Value: msg.Data}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// decidedVia hears the warrants the router says to this module alone: its own `decided` event, a subject only the router
|
||||
// publishes (the grant to subscribe it names the module). The handler is the asker's: pattern is the key it expects.
|
||||
func decidedVia(conn *nats.Conn, module string, logf func(string, ...any)) func(pattern string, handle func(stdio.Envelope) error) error {
|
||||
return func(pattern string, handle func(stdio.Envelope) error) error {
|
||||
_, err := conn.Subscribe(asks.DecidedSubject(module), func(m *nats.Msg) {
|
||||
if err := handle(stdio.Envelope{Key: pattern, Body: m.Data}); err != nil {
|
||||
logf("a warrant could not be handled and is read again from the router's record at the next start: %v", err)
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// sayVia publishes a condition on the seat's one subject for conditions, under the module's own name.
|
||||
func sayVia(ctx context.Context, js jetstream.JetStream, module string) func(c asks.Condition) error {
|
||||
return func(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject(module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := asks.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = js.Publish(ctx, subject, data)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// watchAsks hands each key of the gate's bucket asks, with its revision, to put, and each key taken away to gone, until
|
||||
// ctx ends or the watch fails.
|
||||
func watchAsks(ctx context.Context, kv jetstream.KeyValue, put func(key string, rev uint64, value []byte), gone func(key string)) error {
|
||||
w, err := kv.Watch(ctx, ">")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case e, ok := <-w.Updates():
|
||||
if !ok {
|
||||
return errors.New("the watch ended")
|
||||
}
|
||||
switch {
|
||||
case e == nil:
|
||||
case e.Operation() == jetstream.KeyValuePut:
|
||||
put(e.Key(), e.Revision(), e.Value())
|
||||
default:
|
||||
gone(e.Key())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
|
||||
@@ -121,3 +124,275 @@ func TestTellerWaitsForTheGate(t *testing.T) { // 1240/AC2
|
||||
t.Errorf("the teller stopped with %v, want a clean stop", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The principals of the composed accounts file for the teller's tests, with the permissions mesh-controller composes for
|
||||
// them (internal/broker): a module's own state is the read grants of its bucket and `$KV.<bucket>.>` to write it
|
||||
// (state.go stateGrants, readGrants), a read of another module's bucket is the read grants alone, and a module that uses
|
||||
// the seat operator-channel submits its own `accept.ask.<module>`, `accept.cancel.<module>` and `accept.condition.<module>`,
|
||||
// hears its own `event.decided.<module>` and reads `$KV.asks.<module>.>` of the router's record (seattraffic.go
|
||||
// SeatTrafficOf, with the messenger's seat declaring accepts ask, cancel, condition, emits decided, by-caller and records
|
||||
// asks). The gate is a bundle on the tool runner's account (ADR 0437 §3), so the gate's writes are the account node-tools'.
|
||||
// The permissions are these lists written out, not the controller's function: the broker tests of issue 1243 prove that.
|
||||
func readOf(bucket string) []string {
|
||||
stream := "KV_" + bucket
|
||||
return []string{"$JS.API.STREAM.INFO." + stream, "$JS.API.DIRECT.GET." + stream + ".>", "$JS.API.CONSUMER.CREATE." + stream + ".>",
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".>", "$JS.FC." + stream + ".>"}
|
||||
}
|
||||
|
||||
func keepsOf(bucket string) []string { return append(readOf(bucket), "$KV."+bucket+".>") }
|
||||
|
||||
func user(name string, pub, sub []string) string {
|
||||
q := func(l []string) string {
|
||||
var b strings.Builder
|
||||
for _, s := range l {
|
||||
b.WriteString(fmt.Sprintf(" %q\n", s))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
return fmt.Sprintf(` { user: %q, password: "pw"
|
||||
permissions {
|
||||
publish { allow: [
|
||||
%s ] }
|
||||
subscribe { allow: [
|
||||
%s ] }
|
||||
allow_responses: true
|
||||
}
|
||||
}
|
||||
`, name, q(pub), q(append([]string{"_INBOX." + name + ".>"}, sub...)))
|
||||
}
|
||||
|
||||
const (
|
||||
tellerAnswers = "mesh-delivery-teller_answers"
|
||||
tellerAsked = "mesh-delivery-teller_asked"
|
||||
gateAsksKV = "mesh-delivery-gate_asks"
|
||||
seatAsk = "mesh.seat.operator-channel.accept.ask.mesh-delivery-teller"
|
||||
seatCancel = "mesh.seat.operator-channel.accept.cancel.mesh-delivery-teller"
|
||||
seatCondition = "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller"
|
||||
seatDecided = "mesh.seat.operator-channel.event.decided.mesh-delivery-teller"
|
||||
)
|
||||
|
||||
func fullConf(dir string) string {
|
||||
teller := append(append(append(keepsOf(tellerAnswers), keepsOf(tellerAsked)...), readOf(gateAsksKV)...),
|
||||
seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_asks.$KV.asks.mesh-delivery-teller.>")
|
||||
view := append(readOf(tellerAnswers), readOf(gateAsksKV)...)
|
||||
tools := append(keepsOf(gateAsksKV), readOf(tellerAnswers)...)
|
||||
return fmt.Sprintf(`
|
||||
listen: 127.0.0.1:-1
|
||||
jetstream { store_dir: %q }
|
||||
accounts {
|
||||
MESH {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "ctl", password: "pw" }
|
||||
%s%s%s ]
|
||||
}
|
||||
}
|
||||
`, filepath.Join(dir, "js"),
|
||||
user("n1.mesh-delivery-teller", teller, []string{seatDecided}),
|
||||
user("n1.mesh-delivery-view", view, nil),
|
||||
user("n1.node-tools", tools, nil))
|
||||
}
|
||||
|
||||
type fullBed struct {
|
||||
srv *server.Server
|
||||
ctl *nats.Conn
|
||||
js jetstream.JetStream
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// startFull starts the bus with the principals above and what the controller makes before any module starts: the buckets
|
||||
// of the gate's asks, the teller's answers and asked, the router's work queue SEAT_OPERATOR_CHANNEL.
|
||||
func startFull(t *testing.T) *fullBed {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
conf := filepath.Join(dir, "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte(fullConf(dir)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
srv, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Start()
|
||||
if !srv.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(srv.Shutdown)
|
||||
ctl, err := nats.Connect(srv.ClientURL(), nats.UserInfo("ctl", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(ctl.Close)
|
||||
js, err := jetstream.New(ctl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
for name, history := range map[string]uint8{gateAsksKV: delivery.AsksHistory, tellerAnswers: delivery.AnswersHistory, tellerAsked: 1} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEAT_OPERATOR_CHANNEL", Subjects: []string{"mesh.seat.operator-channel.accept.>"},
|
||||
Retention: jetstream.WorkQueuePolicy}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fullBed{srv: srv, ctl: ctl, js: js, ctx: ctx}
|
||||
}
|
||||
|
||||
func (b *fullBed) as(t *testing.T, name string) jetstream.JetStream {
|
||||
t.Helper()
|
||||
c, err := nats.Connect(b.srv.ClientURL(), nats.UserInfo(name, "pw"), nats.CustomInboxPrefix("_INBOX."+name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(c.Close)
|
||||
js, err := jetstream.New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return js
|
||||
}
|
||||
|
||||
// put writes a key as a principal, within a bounded time: a write the permissions refuse reaches the writer as a timeout.
|
||||
func (b *fullBed) put(t *testing.T, js jetstream.JetStream, bucket, key string) error {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(b.ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
kv, err := js.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
t.Fatalf("%s cannot be bound: %v", bucket, err)
|
||||
}
|
||||
_, err = kv.Put(ctx, key, []byte(`{}`))
|
||||
return err
|
||||
}
|
||||
|
||||
// TestCredentialsWriteOnlyTheirOwnBuckets: the teller's credential is refused writing the gate's asks, and the gate's
|
||||
// (the tool runner's) and the view's are refused writing the teller's answers; each writes where it may, so a refusal is the
|
||||
// permissions' and not a bus that does not take writes (1343/AC4, ADR 0437 §6's check).
|
||||
func TestCredentialsWriteOnlyTheirOwnBuckets(t *testing.T) { // 1343/AC4
|
||||
b := startFull(t)
|
||||
teller, view, tools := b.as(t, "n1.mesh-delivery-teller"), b.as(t, "n1.mesh-delivery-view"), b.as(t, "n1.node-tools")
|
||||
if err := b.put(t, teller, tellerAnswers, "merge.g.0123456789ab"); err != nil {
|
||||
t.Errorf("the teller cannot write its own answers: %v", err)
|
||||
}
|
||||
if err := b.put(t, tools, gateAsksKV, "merge.g.0123456789ab"); err != nil {
|
||||
t.Errorf("the gate's account cannot write the gate's asks: %v", err)
|
||||
}
|
||||
if err := b.put(t, teller, gateAsksKV, "merge.g.ffffffffffff"); err == nil {
|
||||
t.Error("the teller's credential wrote the gate's asks")
|
||||
}
|
||||
if err := b.put(t, tools, tellerAnswers, "merge.g.ffffffffffff"); err == nil {
|
||||
t.Error("the gate's account wrote the teller's answers")
|
||||
}
|
||||
if err := b.put(t, view, tellerAnswers, "merge.g.ffffffffffff"); err == nil {
|
||||
t.Error("the view's credential wrote the teller's answers")
|
||||
}
|
||||
// What was refused is not there.
|
||||
for _, c := range []struct{ bucket, key string }{{gateAsksKV, "merge.g.ffffffffffff"}, {tellerAnswers, "merge.g.ffffffffffff"}} {
|
||||
kv, _ := b.js.KeyValue(b.ctx, c.bucket)
|
||||
if _, err := kv.Get(b.ctx, c.key); err == nil {
|
||||
t.Errorf("%s holds %s though its write was refused", c.bucket, c.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTellerCarriesAnAskAndWritesTheAnswer starts the program on the composed principal against the bus: the gate's account
|
||||
// writes an ask, the teller publishes it to the seat's work queue under its own name, the router (the test) says the
|
||||
// warrant on the teller's decided subject, and the answer is under the ask's key in the teller's bucket; withdrawing a
|
||||
// second ask cancels it (1343/AC1, AC2).
|
||||
func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
|
||||
now = time.Now().UTC()
|
||||
b := startFull(t)
|
||||
tools := b.as(t, "n1.node-tools")
|
||||
broker := filepath.Join(t.TempDir(), "broker")
|
||||
cred, _ := json.Marshal(credential{URL: b.srv.ClientURL(), Node: "n1", Module: "mesh-delivery-teller", User: "n1.mesh-delivery-teller", Password: "pw"})
|
||||
if err := os.WriteFile(broker, cred, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{"MESH_BROKER_FILE": broker}
|
||||
logs := &syncBuffer{}
|
||||
rn := startRunner(b.ctx, func(c context.Context) error { return run(c, func(k string) string { return env[k] }, logs) })
|
||||
t.Cleanup(func() { rn.finish(t, 10*time.Second) })
|
||||
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
kv, err := tools.KeyValue(b.ctx, gateAsksKV)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The program hears warrants before it reads the asks; give it its subscription before the ask is written.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if _, err := kv.Put(b.ctx, key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seat, err := b.js.Stream(b.ctx, "SEAT_OPERATOR_CHANNEL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw *jetstream.RawStreamMsg
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if raw, err = seat.GetLastMsgForSubject(b.ctx, seatAsk); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
t.Fatalf("no ask on the seat's queue: %v\n%s", err, logs.String())
|
||||
}
|
||||
var asked asks.Ask
|
||||
if err := json.Unmarshal(raw.Data, &asked); err != nil || asked.Digest() != ga.Ask.Digest() {
|
||||
t.Fatalf("the ask on the queue is %+v (%v), want the gate's", asked, err)
|
||||
}
|
||||
// The router says the warrant.
|
||||
w, _ := json.Marshal(warrantFor(ga.Ask, "approve"))
|
||||
if err := b.ctl.Publish(seatDecided, w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answers, err := b.js.KeyValue(b.ctx, tellerAnswers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var e jetstream.KeyValueEntry
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if e, err = answers.Get(b.ctx, delivery.AnswerKey(key)); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if e == nil {
|
||||
t.Fatalf("no answer under %s: %v\n%s", key, err, logs.String())
|
||||
}
|
||||
if a, err := delivery.DecodeAnswer(e.Value()); err != nil || a.Option != "approve" {
|
||||
t.Errorf("answer %+v err %v", a, err)
|
||||
}
|
||||
// A second ask, withdrawn by the gate, is cancelled at the router.
|
||||
other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}}
|
||||
key2, value2, ga2 := gateAsk(t, other, other)
|
||||
if _, err := kv.Put(b.ctx, key2, value2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if _, err := seat.GetLastMsgForSubject(b.ctx, seatAsk); err == nil && strings.Contains(logs.String(), ga2.Ask.ID) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := kv.Delete(b.ctx, key2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if raw, err = seat.GetLastMsgForSubject(b.ctx, seatCancel); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil || string(raw.Data) != `{"id":"`+ga2.Ask.ID+`"}` {
|
||||
t.Errorf("the withdrawn ask was not cancelled: %v %v\n%s", raw, err, logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// kvStore is a state bucket of the teller as the asker's store (mesh-sdk go/asker Store): a compare-and-set that lost is
|
||||
// stdio.ErrChanged, as the runtime's state reports it.
|
||||
type kvStore struct {
|
||||
ctx context.Context
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (s kvStore) Get(key string) (*stdio.StateEntry, error) {
|
||||
e, err := s.kv.Get(s.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stdio.StateEntry{Key: key, Value: e.Value(), Revision: e.Revision()}, nil
|
||||
}
|
||||
|
||||
func (s kvStore) Create(key string, value any) (uint64, error) {
|
||||
b, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := s.kv.Create(s.ctx, key, b)
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
|
||||
func (s kvStore) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
b, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rev, err := s.kv.Update(s.ctx, key, b, revision)
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return 0, fmt.Errorf("%s: %w", key, stdio.ErrChanged)
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
|
||||
func (s kvStore) Delete(key string) error {
|
||||
err := s.kv.Delete(s.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// kvAnswers is the bucket answers: the teller's, written by compare-and-set: created when the key has no answer, updated at the revision read when it holds an earlier asking's.
|
||||
type kvAnswers struct {
|
||||
ctx context.Context
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (a kvAnswers) Get(key string) ([]byte, uint64, bool, error) {
|
||||
e, err := a.kv.Get(a.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value(), e.Revision(), true, nil
|
||||
}
|
||||
|
||||
func (a kvAnswers) Write(key string, value []byte, rev uint64, found bool) (bool, error) {
|
||||
var err error
|
||||
if found {
|
||||
_, err = a.kv.Update(a.ctx, key, value, rev)
|
||||
} else {
|
||||
_, err = a.kv.Create(a.ctx, key, value)
|
||||
}
|
||||
if errors.Is(err, jetstream.ErrKeyExists) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// kvGate reads the gate's bucket asks, which the teller may watch and read and never write.
|
||||
type kvGate struct {
|
||||
ctx context.Context
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (g kvGate) Get(key string) ([]byte, bool, error) {
|
||||
e, err := g.kv.Get(g.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value(), true, nil
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// Command mesh-delivery-teller is delivery's teller (novox/hq ADR 0437 §4, issue 1240). It will carry the gate's asks to
|
||||
// the operator and write the answers back (release 3d). In release 3c nothing writes asks, so it only runs: it connects
|
||||
// as itself, says once that it waits for the gate's asks, and writes and publishes nothing. It does not open the asks
|
||||
// bucket, for waiting for an absent bucket needs a timer (ADR 0437 §8 lets the parts hold none); the watch comes with the
|
||||
// gate. It serves no request subject.
|
||||
// Command mesh-delivery-teller is delivery's teller (novox/hq ADR 0437 §4, §6, §9; issues 1240, 1343). It carries the
|
||||
// gate's merge asks to the operator and writes the answers back: it watches the gate's asks, asks each of the router as
|
||||
// itself, and on the router's warrant, once it holds, writes the answer under the ask's key. Where the gate's asks
|
||||
// bucket does not exist yet it says once that it waits for the gate's asks and does nothing: waiting for an absent bucket
|
||||
// needs a timer (ADR 0437 §8 lets the parts hold none), so it is started again with the gate. It serves no request
|
||||
// subject.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -22,6 +23,10 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -103,10 +108,43 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return fmt.Errorf("connecting to the bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
logger.Print(waiting)
|
||||
<-ctx.Done()
|
||||
js, err := jetstream.New(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gate, err := js.KeyValue(ctx, delivery.AsksBucket)
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
logger.Print(waiting)
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", delivery.AsksBucket, err)
|
||||
}
|
||||
var kvs [2]jetstream.KeyValue
|
||||
for i, name := range []string{delivery.AnswersBucket, askedBucket} {
|
||||
if kvs[i], err = js.KeyValue(ctx, name); err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
state := kvStore{ctx: ctx, kv: kvs[1]}
|
||||
client := &asker.Client{Module: delivery.TellerModule, State: state, Publish: publishVia(ctx, js),
|
||||
Record: recordVia(ctx, conn, delivery.TellerModule), Subscribe: decidedVia(conn, delivery.TellerModule, logger.Printf)}
|
||||
t := &Teller{Asker: client, Store: state, Answers: kvAnswers{ctx: ctx, kv: kvs[0]}, Gate: kvGate{ctx: ctx, kv: gate},
|
||||
Say: sayVia(ctx, js, delivery.TellerModule), Logf: logger.Printf}
|
||||
// Hear the warrants first, so none that comes while the asks are read is missed.
|
||||
if err := client.Listen(t.Answer, t.Said); err != nil {
|
||||
return fmt.Errorf("hearing the router's warrants: %w", err)
|
||||
}
|
||||
if err := watchAsks(ctx, gate, t.OnAsk, t.OnAskGone); err != nil {
|
||||
return fmt.Errorf("watching the gate's asks: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// askedBucket is the teller's state bucket asked, `<module>_<name>`: what it asked of the router and which condition it
|
||||
// raised (manifest state entry asked, novox/hq ADR 0437 §4).
|
||||
const askedBucket = delivery.TellerModule + "_asked"
|
||||
|
||||
// waiting is said once at start.
|
||||
const waiting = "waiting for the gate's asks, which come with the gate in release 3d; nothing is read, written or published until then"
|
||||
const waiting = "waiting for the gate's asks, whose bucket does not exist yet; the teller is started again with the gate, and nothing is read, written or published until then"
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The teller carries the gate's merge asks to the operator and writes the operator's answer back (novox/hq ADR 0437
|
||||
// §6, §9). It reads the gate's asks, publishes each to the router unchanged as the asker mesh-delivery-teller
|
||||
// (mesh-sdk go/asker), withdraws it when the gate takes its key away, and on the warrant the router says to it
|
||||
// holds the warrant to the ask (ADR 0259 §6) and writes the answer under the ask's own key by compare-and-set. A
|
||||
// warrant that does not hold writes no answer and is said as a condition. It holds no timer and serves no request:
|
||||
// every reaction is a revision of the gate's key or a warrant.
|
||||
|
||||
// store is where the teller keeps what it asked: the asker's own records (`ask.<id>`) and, beside them, which gate key
|
||||
// each ask was made for and at which revision of it (`key.<id>`), and which conditions it has raised (`cond.<id>`).
|
||||
type store interface {
|
||||
asker.Store
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// routerAsks is the asker.Client as the teller uses it.
|
||||
type routerAsks interface {
|
||||
Ask(a asks.Ask) error
|
||||
Cancel(id string) error
|
||||
Where(id string) (state string, w *asks.Warrant, err error)
|
||||
Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (asker.Handled, error)
|
||||
}
|
||||
|
||||
// answers is the teller's bucket answers.
|
||||
type answers interface {
|
||||
// Get is the answer under the ask's key and its revision; found is false when there is none.
|
||||
Get(key string) (value []byte, rev uint64, found bool, err error)
|
||||
// Write creates the answer when found is false (the key had none when it was read) and otherwise updates it at the
|
||||
// revision read; written is false when another writer was first.
|
||||
Write(key string, value []byte, rev uint64, found bool) (written bool, err error)
|
||||
}
|
||||
|
||||
// gateAsks reads the gate's bucket asks.
|
||||
type gateAsks interface {
|
||||
Get(key string) (value []byte, found bool, err error)
|
||||
}
|
||||
|
||||
// Teller is the teller's logic; the program's wiring is main.go and decided.go.
|
||||
type Teller struct {
|
||||
Asker routerAsks
|
||||
Store store
|
||||
Answers answers
|
||||
Gate gateAsks
|
||||
// Say publishes a condition to the operator (mesh.seat.operator-channel.accept.condition.<module>).
|
||||
Say func(c asks.Condition) error
|
||||
Logf func(format string, args ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (t *Teller) logf(format string, args ...any) {
|
||||
if t.Logf != nil {
|
||||
t.Logf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// seen is what the teller keeps beside an ask it made. Under `key.<router id>` it names the gate key the asking was made for;
|
||||
// under `gate.<gate key>` it names the router id of the latest asking of that key and the revision of the key it was made
|
||||
// at, so a replay of the revision asks nothing again and an overwrite with a new id is known for a new asking.
|
||||
type seen struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Rev uint64 `json:"rev"`
|
||||
}
|
||||
|
||||
func gateRecordKey(key string) string { return "gate." + key }
|
||||
func recordKey(id string) string { return "key." + id }
|
||||
func keptKey(id string) string { return "ask." + id }
|
||||
func condKey(id string) string { return "cond." + id }
|
||||
|
||||
// condID is what a condition of an ask is kept and named by: the gate key it was asked for, so the asking that follows a bad
|
||||
// answer clears the condition of the one before it. An id the teller holds no key for is its own.
|
||||
func (t *Teller) condID(id string) string {
|
||||
if s, e, _ := t.seenOf(id); e != nil && s.Key != "" {
|
||||
return s.Key
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (t *Teller) seenAt(rk string) (seen, *stdio.StateEntry, error) {
|
||||
e, err := t.Store.Get(rk)
|
||||
if err != nil || e == nil {
|
||||
return seen{}, nil, err
|
||||
}
|
||||
var s seen
|
||||
if err := json.Unmarshal(e.Value, &s); err != nil {
|
||||
return seen{}, nil, fmt.Errorf("the record %s cannot be read: %w", rk, err)
|
||||
}
|
||||
return s, e, nil
|
||||
}
|
||||
|
||||
// seenOf is the record of a router id: the gate key it was asked for.
|
||||
func (t *Teller) seenOf(id string) (seen, *stdio.StateEntry, error) { return t.seenAt(recordKey(id)) }
|
||||
|
||||
func (t *Teller) keptOf(id string) (*asker.Kept, uint64, error) {
|
||||
e, err := t.Store.Get(keptKey(id))
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var k asker.Kept
|
||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
||||
return nil, 0, fmt.Errorf("the record %s cannot be read: %w", keptKey(id), err)
|
||||
}
|
||||
return &k, e.Revision, nil
|
||||
}
|
||||
|
||||
// open says whether an ask is made and neither answered nor ended.
|
||||
func open(k *asker.Kept) bool { return k != nil && !k.Acted && k.Ended == "" }
|
||||
|
||||
// note keeps that the asking with this router id is the latest of the gate key, made at revision rev of it.
|
||||
func (t *Teller) note(id, key string, rev uint64, prior *stdio.StateEntry) error {
|
||||
if _, err := t.Store.Create(recordKey(id), seen{Key: key, Rev: rev}); err != nil && !errors.Is(err, stdio.ErrChanged) {
|
||||
return err
|
||||
}
|
||||
g := seen{ID: id, Rev: rev}
|
||||
if prior == nil {
|
||||
_, err := t.Store.Create(gateRecordKey(key), g)
|
||||
return err
|
||||
}
|
||||
_, err := t.Store.Update(gateRecordKey(key), g, prior.Revision)
|
||||
return err
|
||||
}
|
||||
|
||||
// OnAsk handles the gate's asks key put at revision rev. Each asking has its own router id, which is the value's ask.id and
|
||||
// never one recomputed from the key (mesh-sdk go/delivery Ask.Asking, AskingID): the router keeps an ended ask and drops a
|
||||
// second ask under its id. So the ask is published once per id; a key overwritten at a higher revision under a new id is a
|
||||
// new asking, and the earlier asking's router ask is cancelled first. A key whose value is not the ask its name says, one
|
||||
// already answered, and a revision the teller has handled are left. An ask still open and unchanged is only noted (and its
|
||||
// warrant read from the router, in case it came while the teller was away). A value that changed its words under the id
|
||||
// it was asked under is cancelled and not asked again: the router would drop it, so the gate must give it a new asking.
|
||||
func (t *Teller) OnAsk(key string, rev uint64, value []byte) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
ga, err := delivery.DecodeAsk(value)
|
||||
if err != nil {
|
||||
t.logf("the gate's key %s at revision %d is not an ask, and is left: %v", key, rev, err)
|
||||
return
|
||||
}
|
||||
if want, err := delivery.MergeAskKey(ga.Group, ga.Heads); err != nil || want != key {
|
||||
t.logf("the gate's key %s at revision %d holds the ask of %q, not its own, and is left (%v)", key, rev, want, err)
|
||||
return
|
||||
}
|
||||
id := ga.Ask.ID
|
||||
if raw, _, has, err := t.Answers.Get(key); err != nil {
|
||||
t.logf("%s: the answers could not be read, so the ask is left: %v", key, err)
|
||||
return
|
||||
} else if has && answeredAsking(raw) == id {
|
||||
return // this asking is answered; an earlier asking's answer does not stop a later asking
|
||||
}
|
||||
prior, grec, err := t.seenAt(gateRecordKey(key))
|
||||
if err != nil {
|
||||
t.logf("%s: %v", key, err)
|
||||
return
|
||||
}
|
||||
if grec != nil && rev <= prior.Rev {
|
||||
if k, _, err := t.keptOf(prior.ID); err == nil && open(k) {
|
||||
t.catchUp(prior.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
if grec != nil && prior.ID != id {
|
||||
// A new asking of the group at these heads: the earlier one is taken back.
|
||||
t.cancelOpen(key, prior.ID)
|
||||
}
|
||||
k, _, err := t.keptOf(id)
|
||||
if err != nil {
|
||||
t.logf("%s: %v", key, err)
|
||||
return
|
||||
}
|
||||
if k != nil {
|
||||
switch {
|
||||
case open(k) && k.Ask.Digest() == ga.Ask.Digest():
|
||||
_ = t.note(id, key, rev, grec)
|
||||
t.catchUp(id)
|
||||
case open(k):
|
||||
t.cancelOpen(key, id)
|
||||
_ = t.note(id, key, rev, grec)
|
||||
t.logf("%s: the ask %s changed its words at revision %d under the same id: cancelled, and not asked again, for the router drops a second ask under an id (the gate gives a new asking a new id)", key, id, rev)
|
||||
default:
|
||||
_ = t.note(id, key, rev, grec)
|
||||
t.logf("%s: the ask %s ended, and the gate wrote it again under the same id at revision %d: not asked again, the router would drop it", key, id, rev)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := t.note(id, key, rev, grec); err != nil {
|
||||
t.logf("%s: the record of the ask %s could not be kept, so it is left: %v", key, id, err)
|
||||
return
|
||||
}
|
||||
if err := t.Asker.Ask(ga.Ask); err != nil {
|
||||
t.logf("%s at revision %d: the ask %s was not asked: %v", key, rev, id, err)
|
||||
if kept, _, _ := t.keptOf(id); open(kept) {
|
||||
// Kept but not published: forgotten, so the next revision of the key asks it.
|
||||
_ = t.Store.Delete(keptKey(id))
|
||||
}
|
||||
return
|
||||
}
|
||||
t.logf("%s at revision %d: the ask %s is asked of the operator (asking %d)", key, rev, id, ga.Asking)
|
||||
}
|
||||
|
||||
// cancelOpen takes an open ask back at the router and marks it ended here, since the router's word that it ended may come
|
||||
// later. An ask that is not open is left.
|
||||
func (t *Teller) cancelOpen(key, id string) {
|
||||
k, krev, err := t.keptOf(id)
|
||||
if err != nil {
|
||||
t.logf("%s: %v", key, err)
|
||||
return
|
||||
}
|
||||
if !open(k) {
|
||||
return
|
||||
}
|
||||
if err := t.Asker.Cancel(id); err != nil {
|
||||
t.logf("%s: the ask %s could not be cancelled: %v", key, id, err)
|
||||
return
|
||||
}
|
||||
k.Ended = asks.OutcomeCancelled
|
||||
if _, err := t.Store.Update(keptKey(id), k, krev); err != nil && !errors.Is(err, stdio.ErrChanged) {
|
||||
t.logf("%s: the cancelled ask %s could not be marked: %v", key, id, err)
|
||||
}
|
||||
t.logf("%s: the ask %s is cancelled", key, id)
|
||||
}
|
||||
|
||||
// OnAskGone handles the gate's asks key taken away (a withdrawn ask): the latest asking's router ask is cancelled, and an
|
||||
// answer to it merges nothing.
|
||||
func (t *Teller) OnAskGone(key string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if _, _, _, err := delivery.ParseAskKey(key); err != nil {
|
||||
t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err)
|
||||
return
|
||||
}
|
||||
prior, grec, err := t.seenAt(gateRecordKey(key))
|
||||
if err != nil || grec == nil {
|
||||
if err != nil {
|
||||
t.logf("%s: %v", key, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.cancelOpen(key, prior.ID)
|
||||
}
|
||||
|
||||
// catchUp reads the router's record of an ask the teller made and hands its warrant on, as the router's event would:
|
||||
// a warrant that came while the teller was away.
|
||||
func (t *Teller) catchUp(id string) {
|
||||
state, w, err := t.Asker.Where(id)
|
||||
if err != nil {
|
||||
t.logf("the router's record of the ask %s could not be read: %v", id, err)
|
||||
return
|
||||
}
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
t.logf("the ask %s is %s at the router: its warrant is handled now", id, state)
|
||||
h, err := t.Asker.Take(*w, t.Answer)
|
||||
t.Said(h, *w, err)
|
||||
}
|
||||
|
||||
// mergeAct is what the approve option of the gate's ask binds: this ask, the answer, and the heads it covers. It is
|
||||
// spelled as the gate composes it (mesh-catalog modules/mesh-delivery-gate approval.go mergeAct, from
|
||||
// modules/mesh-delivery approval.go:63-65) and as mesh-controller verifies it (cmd/mesh-controller/moved.go:98-106).
|
||||
func mergeAct(ask, option string, heads map[string]string) asks.Act {
|
||||
var out []string
|
||||
for id, sha := range heads {
|
||||
out = append(out, id+"="+sha)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return asks.Act{"verb": "mesh-delivery.merge", "ask": ask, "option": option, "heads": strings.Join(out, ",")}
|
||||
}
|
||||
|
||||
// headsOf is the heads an ask covers as an act names them: a delivery's id, `owner/repository@<twelve characters of its
|
||||
// commit>` with the repository in lower case, as the gate's IDOf spells it (mesh-catalog modules/mesh-delivery-gate model.go),
|
||||
// and its whole commit.
|
||||
func headsOf(heads []delivery.Head) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, h := range heads {
|
||||
short := h.Head
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
out[strings.ToLower(h.Repository)+"@"+short] = h.Head
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// answeredAsking is the router id of the asking an answer is for: its warrant's id before the "@". An answer that cannot be
|
||||
// read answers no asking.
|
||||
func answeredAsking(raw []byte) string {
|
||||
a, err := delivery.DecodeAnswer(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
id, _, _ := strings.Cut(a.Warrant, "@")
|
||||
return id
|
||||
}
|
||||
|
||||
// warrantID names a warrant, which carries no id of its own: the ask and the time the router gave it.
|
||||
func warrantID(w asks.Warrant) string {
|
||||
return w.Ask + "@" + w.At.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// Answer is the act of a warrant that holds the ask (asker.Client.Take has checked its asker, the ask's digest, the
|
||||
// option and its level, and the time): the option's bound act must be the act of this ask and the heads it covers,
|
||||
// and then the answer is written under the ask's key, once. An error is a warrant that does not hold here.
|
||||
func (t *Teller) Answer(o asks.Option, w asks.Warrant) error {
|
||||
s, srec, err := t.seenOf(w.Ask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if srec == nil {
|
||||
return fmt.Errorf("the teller holds no gate key for the ask %s", w.Ask)
|
||||
}
|
||||
raw, found, err := t.Gate.Get(s.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the gate's ask %s could not be read: %w", s.Key, err)
|
||||
}
|
||||
if !found {
|
||||
t.logf("%s: the ask %s was answered and the gate has taken it away: an answer to a withdrawn ask merges nothing", s.Key, w.Ask)
|
||||
return nil
|
||||
}
|
||||
ga, err := delivery.DecodeAsk(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the gate's ask %s is not an ask: %w", s.Key, err)
|
||||
}
|
||||
if ga.Ask.ID != w.Ask {
|
||||
t.logf("%s: the ask %s was answered, and the gate has asked again under %s: an answer to an earlier asking merges nothing", s.Key, w.Ask, ga.Ask.ID)
|
||||
return nil
|
||||
}
|
||||
if ga.Ask.Digest() != w.AskDigest {
|
||||
return fmt.Errorf("the gate's ask %s is no longer the ask the operator answered (its digest is %s, the warrant's %s)",
|
||||
s.Key, ga.Ask.Digest(), w.AskDigest)
|
||||
}
|
||||
if err := o.Performs(mergeAct(w.Ask, o.ID, headsOf(ga.Heads))); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := delivery.EncodeAnswer(delivery.Answer{Option: o.ID, Warrant: warrantID(w), Words: w.Says()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Compare-and-set: created when the key has no answer; updated at the revision read when it holds an earlier asking's
|
||||
// answer (the gate ignores that one); left when it holds this asking's.
|
||||
akey := delivery.AnswerKey(s.Key)
|
||||
cur, crev, found, err := t.Answers.Get(akey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the answer under %s could not be read: %w", s.Key, err)
|
||||
}
|
||||
if found && answeredAsking(cur) == w.Ask {
|
||||
t.logf("%s: an answer is there already, and is not written again", s.Key)
|
||||
return nil
|
||||
}
|
||||
written, err := t.Answers.Write(akey, body, crev, found)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the answer under %s could not be written: %w", s.Key, err)
|
||||
}
|
||||
if !written {
|
||||
t.logf("%s: another writer answered first, and the answer is not written again", s.Key)
|
||||
return nil
|
||||
}
|
||||
t.logf("%s: %s", s.Key, w.Says())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Said is what the teller does with what became of a warrant: a good answer clears the condition of its ask; a warrant
|
||||
// that does not hold, or that the router refused, is said as a condition (ADR 0437 §9). Nothing is written for either.
|
||||
func (t *Teller) Said(h asker.Handled, w asks.Warrant, err error) {
|
||||
switch {
|
||||
case h == asker.Acted && err == nil:
|
||||
t.logf("the warrant of %s was %s", w.Ask, h)
|
||||
t.clear(w.Ask)
|
||||
case h == asker.Refused || (h == asker.Acted && err != nil):
|
||||
t.logf("the warrant of %s does not hold, and no answer is written: %v", w.Ask, err)
|
||||
t.raise(w.Ask, "A merge Approve did not hold", "its answer did not hold here: "+oneLine(err))
|
||||
case h == asker.Recorded && w.Outcome == asks.OutcomeRefused:
|
||||
t.logf("the router refused the ask %s: %s", w.Ask, w.Words)
|
||||
t.raise(w.Ask, "A merge ask was refused", "the router refused it: "+w.Words)
|
||||
case err != nil:
|
||||
t.logf("the warrant of %s: %v", w.Ask, err)
|
||||
}
|
||||
}
|
||||
|
||||
func oneLine(err error) string {
|
||||
if err == nil {
|
||||
return "no reason was given"
|
||||
}
|
||||
s := strings.Join(strings.Fields(err.Error()), " ")
|
||||
if r := []rune(s); len(r) > 200 {
|
||||
return string(r[:199]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// conditionKey is the key of the condition about an ask.
|
||||
func conditionKey(id string) string { return delivery.TellerModule + ".merge-ask." + id }
|
||||
|
||||
func (t *Teller) raise(askID, headline, why string) {
|
||||
if t.Say == nil {
|
||||
return
|
||||
}
|
||||
id := t.condID(askID)
|
||||
if _, err := t.Store.Create(condKey(id), seen{}); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return // raised already
|
||||
}
|
||||
t.logf("the condition of %s could not be kept: %v", id, err)
|
||||
}
|
||||
needs := "to ask for the Approve again"
|
||||
c := asks.Condition{Key: conditionKey(id), Severity: asks.SeverityWarning, Subject: asks.Scope{Scope: "merge", ID: id}, State: asks.StateRaised,
|
||||
Summary: "The merge ask " + id + ": " + why, Headline: headline, Needs: needs,
|
||||
Explanation: asks.NeedsYou + needs + ". The merge ask " + id + ", " + why + ". Nothing was merged.",
|
||||
Resolved: "A good answer to the merge ask was written."}
|
||||
if err := t.Say(c); err != nil {
|
||||
t.logf("the condition of %s could not be said: %v", id, err)
|
||||
_ = t.Store.Delete(condKey(id))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Teller) clear(askID string) {
|
||||
if t.Say == nil {
|
||||
return
|
||||
}
|
||||
id := t.condID(askID)
|
||||
e, err := t.Store.Get(condKey(id))
|
||||
if err != nil || e == nil {
|
||||
return
|
||||
}
|
||||
c := asks.Condition{Key: conditionKey(id), Subject: asks.Scope{Scope: "merge", ID: id}, State: asks.StateCleared}
|
||||
if err := t.Say(c); err != nil {
|
||||
t.logf("the condition of %s could not be cleared: %v", id, err)
|
||||
return
|
||||
}
|
||||
_ = t.Store.Delete(condKey(id))
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const (
|
||||
headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
var now = time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
// memStore is a state bucket in memory with the revisions and the compare-and-set of the runtime's state.
|
||||
type memStore struct {
|
||||
entries map[string]*stdio.StateEntry
|
||||
seq uint64
|
||||
}
|
||||
|
||||
func (m *memStore) Get(key string) (*stdio.StateEntry, error) {
|
||||
if e, ok := m.entries[key]; ok {
|
||||
c := *e
|
||||
return &c, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) put(key string, value any) (uint64, error) {
|
||||
b, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
m.seq++
|
||||
m.entries[key] = &stdio.StateEntry{Key: key, Value: b, Revision: m.seq}
|
||||
return m.seq, nil
|
||||
}
|
||||
|
||||
func (m *memStore) Create(key string, value any) (uint64, error) {
|
||||
if _, ok := m.entries[key]; ok {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return m.put(key, value)
|
||||
}
|
||||
|
||||
func (m *memStore) Update(key string, value any, revision uint64) (uint64, error) {
|
||||
if e, ok := m.entries[key]; !ok || e.Revision != revision {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return m.put(key, value)
|
||||
}
|
||||
|
||||
func (m *memStore) Delete(key string) error { delete(m.entries, key); return nil }
|
||||
|
||||
type memAnswers struct {
|
||||
written map[string][]byte
|
||||
revs map[string]uint64
|
||||
seq uint64
|
||||
writes int
|
||||
}
|
||||
|
||||
func (a *memAnswers) Get(key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := a.written[key]
|
||||
return v, a.revs[key], ok, nil
|
||||
}
|
||||
|
||||
// Write is the bucket's compare-and-set: a create of a key that has an answer, or an update at another revision, loses.
|
||||
func (a *memAnswers) Write(key string, value []byte, rev uint64, found bool) (bool, error) {
|
||||
_, has := a.written[key]
|
||||
if found != has || (found && rev != a.revs[key]) {
|
||||
return false, nil
|
||||
}
|
||||
a.seq++
|
||||
a.writes++
|
||||
if a.revs == nil {
|
||||
a.revs = map[string]uint64{}
|
||||
}
|
||||
a.written[key], a.revs[key] = value, a.seq
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type memGate map[string][]byte
|
||||
|
||||
func (g memGate) Get(key string) ([]byte, bool, error) { v, ok := g[key]; return v, ok, nil }
|
||||
|
||||
type published struct {
|
||||
subject string
|
||||
body json.RawMessage
|
||||
id string
|
||||
}
|
||||
|
||||
// bed is a teller with the asker.Client of the SDK on in-memory ends: what is published, the router's records, the
|
||||
// warrants it would hear, and the conditions it says.
|
||||
type bed struct {
|
||||
t *Teller
|
||||
client *asker.Client
|
||||
store *memStore
|
||||
answers *memAnswers
|
||||
gate memGate
|
||||
pubs []published
|
||||
conds []asks.Condition
|
||||
records map[string]string
|
||||
// seenIDs are the ids the fake router has taken an ask under, and dropped the asks it took a second time under: the
|
||||
// messenger keeps an ask after it ends and returns for an ask whose id it holds (mesh-catalog modules/messenger
|
||||
// cmd/messenger/asks.go:487-489, keepAsk), so a reused id reaches no phone.
|
||||
seenIDs map[string]bool
|
||||
dropped []string
|
||||
hear func(stdio.Envelope) error
|
||||
logs strings.Builder
|
||||
}
|
||||
|
||||
func newBed(t *testing.T) *bed {
|
||||
t.Helper()
|
||||
b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}, revs: map[string]uint64{}},
|
||||
gate: memGate{}, records: map[string]string{}, seenIDs: map[string]bool{}}
|
||||
b.client = &asker.Client{Module: delivery.TellerModule, State: b.store, Now: func() time.Time { return now },
|
||||
Publish: func(subject string, body any, id string) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if subject == asks.AskSubject(delivery.TellerModule) {
|
||||
if b.seenIDs[id] {
|
||||
b.dropped = append(b.dropped, id)
|
||||
}
|
||||
b.seenIDs[id] = true
|
||||
}
|
||||
b.pubs = append(b.pubs, published{subject, raw, id})
|
||||
return err
|
||||
},
|
||||
Record: func(id string) (*stdio.StateEntry, error) {
|
||||
if v, ok := b.records[id]; ok {
|
||||
return &stdio.StateEntry{Key: id, Value: json.RawMessage(v)}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
Subscribe: func(_ string, handle func(stdio.Envelope) error) error { b.hear = handle; return nil },
|
||||
}
|
||||
b.t = &Teller{Asker: b.client, Store: b.store, Answers: b.answers, Gate: b.gate,
|
||||
Say: func(c asks.Condition) error {
|
||||
if _, err := asks.EncodeCondition(c); err != nil {
|
||||
t.Errorf("a condition the router would refuse: %v", err)
|
||||
}
|
||||
b.conds = append(b.conds, c)
|
||||
return nil
|
||||
},
|
||||
Logf: func(f string, a ...any) { fmt.Fprintf(&b.logs, f+"\n", a...) }}
|
||||
if err := b.client.Listen(b.t.Answer, b.t.Said); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// gateAsk is an ask as the gate composes it: the router's ask with the approve and decline options binding the act
|
||||
// `mesh-delivery.merge` for this ask and the heads (mesh-catalog modules/mesh-delivery approval.go:123-191 composeMergeAsk
|
||||
// and :63-65 mergeAct), under the id its key gives (mesh-sdk go/delivery AskID). boundTo are the heads the options bind,
|
||||
// which are the ask's own unless a test says another.
|
||||
func gateAsk(t *testing.T, heads, boundTo []delivery.Head) (key string, value []byte, ask delivery.Ask) {
|
||||
t.Helper()
|
||||
return gateAskN(t, heads, boundTo, 1)
|
||||
}
|
||||
|
||||
// gateAskN is the asking-th writing of the group's ask at these heads: its router id is AskingID(key, asking) (mesh-sdk
|
||||
// go/delivery Ask.Asking), the first asking's the key's own.
|
||||
func gateAskN(t *testing.T, heads, boundTo []delivery.Head, asking int) (key string, value []byte, ask delivery.Ask) {
|
||||
t.Helper()
|
||||
key, err := delivery.MergeAskKey("g", heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := delivery.AskingID(key, asking)
|
||||
a := asks.Ask{ID: id, Headline: "Merge into main: novox/mesh-delivery#12", Explanation: "Merges two pull requests.\nReview: approved.",
|
||||
Who: asks.Operator, Expires: now.Add(asks.ApproveLasts - 10*time.Minute), OnExpiry: "the merge stays held", About: "delivery.mesh-delivery.12"}
|
||||
for _, o := range []struct{ id, label, does string }{{"approve", "Approve", "merges"}, {"decline", "Decline", "merges nothing"}} {
|
||||
binds, err := asks.ActDigest(mergeAct(id, o.id, headsOf(boundTo)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
ask = delivery.Ask{Group: "g", Heads: heads, Asking: asking, Ask: a}
|
||||
value, err = delivery.EncodeAsk(ask)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key, value, ask
|
||||
}
|
||||
|
||||
func twoHeads() []delivery.Head {
|
||||
return []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}}
|
||||
}
|
||||
|
||||
// warrantFor is the router's warrant for a chosen option of an ask, as the router verified the operator.
|
||||
func warrantFor(a asks.Ask, option string) asks.Warrant {
|
||||
return asks.Warrant{Ask: a.ID, Asker: delivery.TellerModule, Outcome: asks.OutcomeChosen, Option: option, Label: "Approve", Level: asks.Approve,
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id"}, Channel: "telegram",
|
||||
At: now.Add(time.Hour), AskDigest: a.Digest()}
|
||||
}
|
||||
|
||||
func (b *bed) deliver(t *testing.T, w asks.Warrant) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(w)
|
||||
if err := b.hear(stdio.Envelope{Key: asks.Seat + ".decided." + delivery.TellerModule, Body: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bed) askedOf(subject string) []published {
|
||||
var out []published
|
||||
for _, p := range b.pubs {
|
||||
if p.subject == subject {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1343/AC1
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 3, value)
|
||||
asked := b.askedOf(asks.AskSubject(delivery.TellerModule))
|
||||
if len(asked) != 1 || asked[0].id != ga.Ask.ID {
|
||||
t.Fatalf("published %+v, want one ask under %s with id %s", b.pubs, delivery.TellerModule, ga.Ask.ID)
|
||||
}
|
||||
var got asks.Ask
|
||||
if err := json.Unmarshal(asked[0].body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Digest() != ga.Ask.Digest() || got.Headline != ga.Ask.Headline || got.Explanation != ga.Ask.Explanation || got.ID != delivery.AskID(key) {
|
||||
t.Errorf("the router's ask differs from the gate's: %+v", got)
|
||||
}
|
||||
b.t.OnAsk(key, 3, value) // the revision seen again, as at a restart
|
||||
b.t.OnAsk(key, 4, value) // the key written again with the same ask
|
||||
if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 1 {
|
||||
t.Errorf("the ask was published %d times", n)
|
||||
}
|
||||
delete(b.gate, key)
|
||||
b.t.OnAskGone(key)
|
||||
cancels := b.askedOf(asks.CancelSubject(delivery.TellerModule))
|
||||
if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+ga.Ask.ID+`"}` {
|
||||
t.Fatalf("cancels = %+v", cancels)
|
||||
}
|
||||
b.t.OnAskGone(key)
|
||||
if n := len(b.askedOf(asks.CancelSubject(delivery.TellerModule))); n != 1 {
|
||||
t.Errorf("the ask was cancelled %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { // 1343/AC2
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 1, value)
|
||||
w := warrantFor(ga.Ask, "approve")
|
||||
b.deliver(t, w)
|
||||
raw, ok := b.answers.written[delivery.AnswerKey(key)]
|
||||
if !ok || len(b.answers.written) != 1 {
|
||||
t.Fatalf("answers = %v", b.answers.written)
|
||||
}
|
||||
a, err := delivery.DecodeAnswer(raw)
|
||||
if err != nil || a.Option != "approve" || a.Warrant != w.Ask+"@"+w.At.UTC().Format(time.RFC3339Nano) || !strings.Contains(a.Words, "chose Approve") {
|
||||
t.Errorf("answer %+v err %v", a, err)
|
||||
}
|
||||
b.deliver(t, w) // the same warrant again
|
||||
w2 := w
|
||||
w2.At = w.At.Add(time.Minute) // a second warrant for the same ask
|
||||
b.deliver(t, w2)
|
||||
if b.answers.writes != 1 {
|
||||
t.Errorf("the answer was written %d times, want once", b.answers.writes)
|
||||
}
|
||||
if len(b.conds) != 0 {
|
||||
t.Errorf("conditions said: %+v", b.conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T) { // 1343/AC3
|
||||
other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}}
|
||||
cases := map[string]func(t *testing.T) (*bed, string, asks.Warrant){
|
||||
"heads that differ from the ones the option bound": func(t *testing.T) (*bed, string, asks.Warrant) {
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), other) // the options bind other heads than the ask covers
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 1, value)
|
||||
return b, key, warrantFor(ga.Ask, "approve")
|
||||
},
|
||||
"an asker that differs": func(t *testing.T) (*bed, string, asks.Warrant) {
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 1, value)
|
||||
w := warrantFor(ga.Ask, "approve")
|
||||
w.Asker = "mesh-delivery"
|
||||
return b, key, w
|
||||
},
|
||||
"a digest that differs": func(t *testing.T) (*bed, string, asks.Warrant) {
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 1, value)
|
||||
w := warrantFor(ga.Ask, "approve")
|
||||
w.AskDigest = "sha256:0000"
|
||||
return b, key, w
|
||||
},
|
||||
}
|
||||
for name, mk := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
b, key, w := mk(t)
|
||||
b.deliver(t, w)
|
||||
if len(b.answers.written) != 0 {
|
||||
t.Errorf("an answer was written: %v", b.answers.written)
|
||||
}
|
||||
if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || b.conds[0].Subject.ID != key ||
|
||||
!strings.HasPrefix(b.conds[0].Explanation, asks.NeedsYou) {
|
||||
t.Fatalf("conditions = %+v", b.conds)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/AC3 (cleared when a good answer is written)
|
||||
b := newBed(t)
|
||||
other := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}}
|
||||
key, bad, gaBad := gateAsk(t, twoHeads(), other)
|
||||
b.gate[key] = bad
|
||||
b.t.OnAsk(key, 1, bad)
|
||||
b.deliver(t, warrantFor(gaBad.Ask, "approve"))
|
||||
if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised {
|
||||
t.Fatalf("conditions = %+v", b.conds)
|
||||
}
|
||||
// The gate writes the ask again, right this time, at a new revision of its key: the second asking, under a new id.
|
||||
_, good, gaGood := gateAskN(t, twoHeads(), twoHeads(), 2)
|
||||
b.gate[key] = good
|
||||
b.t.OnAsk(key, 2, good)
|
||||
if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 2 {
|
||||
t.Fatalf("the ask was published %d times, want again at the new revision", n)
|
||||
}
|
||||
b.deliver(t, warrantFor(gaGood.Ask, "approve"))
|
||||
if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok {
|
||||
t.Fatalf("no answer: %v\n%s", b.answers.written, b.logs.String())
|
||||
}
|
||||
if len(b.conds) != 2 || b.conds[1].State != asks.StateCleared || b.conds[1].Key != b.conds[0].Key {
|
||||
t.Errorf("conditions = %+v", b.conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWarrantComingWhileTheTellerWasAwayIsReadFromTheRouter(t *testing.T) {
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 1, value)
|
||||
w, _ := json.Marshal(warrantFor(ga.Ask, "approve"))
|
||||
b.records[ga.Ask.ID] = `{"state":"chosen","warrant":` + string(w) + `}`
|
||||
b.t.OnAsk(key, 1, value) // the teller starts again and reads the key at the same revision
|
||||
if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok {
|
||||
t.Errorf("no answer: %s", b.logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestARewriteWithAskingTwoCancelsAskingOneAndAsksUnderTheNewID(t *testing.T) { // G2 of review round 1
|
||||
b := newBed(t)
|
||||
key, one, g1 := gateAskN(t, twoHeads(), twoHeads(), 1)
|
||||
b.gate[key] = one
|
||||
b.t.OnAsk(key, 1, one)
|
||||
_, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2)
|
||||
if g1.Ask.ID == g2.Ask.ID {
|
||||
t.Fatal("the two askings share an id")
|
||||
}
|
||||
b.gate[key] = two
|
||||
b.t.OnAsk(key, 2, two)
|
||||
cancels := b.askedOf(asks.CancelSubject(delivery.TellerModule))
|
||||
if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+g1.Ask.ID+`"}` {
|
||||
t.Fatalf("cancels = %+v, want asking 1's router ask", cancels)
|
||||
}
|
||||
asked := b.askedOf(asks.AskSubject(delivery.TellerModule))
|
||||
if len(asked) != 2 || asked[1].id != g2.Ask.ID {
|
||||
t.Fatalf("asks = %+v, want the second under %s", asked, g2.Ask.ID)
|
||||
}
|
||||
if len(b.dropped) != 0 {
|
||||
t.Errorf("the router dropped asks under a reused id: %v", b.dropped)
|
||||
}
|
||||
// The same revision again, and a warrant for the first asking (the gate has moved on), change nothing.
|
||||
b.t.OnAsk(key, 2, two)
|
||||
b.deliver(t, warrantFor(g1.Ask, "approve"))
|
||||
if len(b.answers.written) != 0 || len(b.askedOf(asks.AskSubject(delivery.TellerModule))) != 2 {
|
||||
t.Errorf("answers %v asks %d", b.answers.written, len(b.askedOf(asks.AskSubject(delivery.TellerModule))))
|
||||
}
|
||||
// The warrant of the second names it: its answer's warrant is the router id and the time.
|
||||
w := warrantFor(g2.Ask, "approve")
|
||||
b.deliver(t, w)
|
||||
raw, ok := b.answers.written[delivery.AnswerKey(key)]
|
||||
a, err := delivery.DecodeAnswer(raw)
|
||||
if !ok || err != nil || a.Warrant != g2.Ask.ID+"@"+w.At.UTC().Format(time.RFC3339Nano) {
|
||||
t.Errorf("answer %+v (%v) err %v", a, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAskWhoseWordsChangedUnderTheSameIDIsCancelledAndNotAskedAgain(t *testing.T) {
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.t.OnAsk(key, 1, value)
|
||||
changed := ga
|
||||
changed.Ask.Explanation = "Merges two pull requests, now with a third point.\nReview: approved."
|
||||
v2, err := delivery.EncodeAsk(changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.t.OnAsk(key, 2, v2)
|
||||
if n := len(b.askedOf(asks.CancelSubject(delivery.TellerModule))); n != 1 {
|
||||
t.Errorf("cancels = %d", n)
|
||||
}
|
||||
if n := len(b.askedOf(asks.AskSubject(delivery.TellerModule))); n != 1 || len(b.dropped) != 0 {
|
||||
t.Errorf("asks = %d dropped %v: the router would drop a second ask under the id", n, b.dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACapitalisedRepositoryIsSpelledInLowerCaseInTheAct(t *testing.T) { // T1 of review round 1
|
||||
b := newBed(t)
|
||||
heads := []delivery.Head{{Repository: "novox/Mesh-Delivery", Number: 12, Head: headA}}
|
||||
key, err := delivery.MergeAskKey("g", heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := delivery.AskID(key)
|
||||
// The act as the gate binds it: its IDOf lowercases the repository (mesh-catalog modules/mesh-delivery-gate model.go).
|
||||
binds, err := asks.ActDigest(asks.Act{"verb": "mesh-delivery.merge", "ask": id, "option": "approve", "heads": "novox/mesh-delivery@" + headA[:12] + "=" + headA})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := asks.Ask{ID: id, Headline: "Merge into main: Mesh-Delivery#12", Explanation: "Merges.", Who: asks.Operator, Expires: now.Add(asks.ApproveLasts - 10*time.Minute),
|
||||
OnExpiry: "the merge stays held", Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: binds}}}
|
||||
value, err := delivery.EncodeAsk(delivery.Ask{Group: "g", Heads: heads, Ask: a})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.gate[key] = value
|
||||
b.t.OnAsk(key, 1, value)
|
||||
b.deliver(t, warrantFor(a, "approve"))
|
||||
if _, ok := b.answers.written[delivery.AnswerKey(key)]; !ok {
|
||||
t.Errorf("no answer for the gate's spelling: %s", b.logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyWhoseValueIsNotItsOwnAskIsLeft(t *testing.T) {
|
||||
b := newBed(t)
|
||||
_, value, _ := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.t.OnAsk("merge.g.000000000000", 1, value)
|
||||
b.t.OnAsk("merge.g.000000000000", 2, []byte(`{"v":1`))
|
||||
if len(b.pubs) != 0 {
|
||||
t.Errorf("published %+v", b.pubs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusedAskIsSaidAsACondition(t *testing.T) { // ADR 0437 §9
|
||||
b := newBed(t)
|
||||
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
|
||||
b.t.OnAsk(key, 1, value)
|
||||
w := asks.Warrant{Ask: ga.Ask.ID, Asker: delivery.TellerModule, Outcome: asks.OutcomeRefused, Words: "no channel can send", At: now}
|
||||
b.deliver(t, w)
|
||||
if len(b.conds) != 1 || b.conds[0].State != asks.StateRaised || !strings.Contains(b.conds[0].Summary, "no channel can send") {
|
||||
t.Errorf("conditions = %+v", b.conds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R2-1 of review round 2
|
||||
b := newBed(t)
|
||||
key, one, g1 := gateAskN(t, twoHeads(), twoHeads(), 1)
|
||||
b.gate[key] = one
|
||||
b.t.OnAsk(key, 1, one)
|
||||
b.deliver(t, warrantFor(g1.Ask, "approve"))
|
||||
first, ok := b.answers.written[delivery.AnswerKey(key)]
|
||||
if a, err := delivery.DecodeAnswer(first); !ok || err != nil || !strings.HasPrefix(a.Warrant, g1.Ask.ID+"@") {
|
||||
t.Fatalf("asking 1's answer: %+v %v %v", a, ok, err)
|
||||
}
|
||||
// The gate rewrites the key at asking 2 (a review record changed). Asking 1's answer stays in the bucket.
|
||||
_, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2)
|
||||
b.gate[key] = two
|
||||
b.t.OnAsk(key, 2, two)
|
||||
asked := b.askedOf(asks.AskSubject(delivery.TellerModule))
|
||||
if len(asked) != 2 || asked[1].id != g2.Ask.ID {
|
||||
t.Fatalf("asks = %+v, want asking 2 asked under %s", asked, g2.Ask.ID)
|
||||
}
|
||||
w := warrantFor(g2.Ask, "approve")
|
||||
b.deliver(t, w)
|
||||
raw := b.answers.written[delivery.AnswerKey(key)]
|
||||
a, err := delivery.DecodeAnswer(raw)
|
||||
if err != nil || a.Warrant != g2.Ask.ID+"@"+w.At.UTC().Format(time.RFC3339Nano) {
|
||||
t.Fatalf("the key holds %+v (%v), want asking 2's answer", a, err)
|
||||
}
|
||||
// A second warrant for the same asking writes nothing, and a replay of asking 2 asks nothing.
|
||||
w2 := w
|
||||
w2.At = w.At.Add(time.Minute)
|
||||
b.deliver(t, w2)
|
||||
b.t.OnAsk(key, 2, two)
|
||||
b.t.OnAsk(key, 3, two)
|
||||
if b.answers.writes != 2 || len(b.askedOf(asks.AskSubject(delivery.TellerModule))) != 2 || len(b.dropped) != 0 {
|
||||
t.Errorf("writes %d asks %d dropped %v", b.answers.writes, len(b.askedOf(asks.AskSubject(delivery.TellerModule))), b.dropped)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ module mesh-delivery-teller
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -76,7 +76,9 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
|
||||
if m.Module != "mesh-delivery-teller" {
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
if want := []state{{Name: "answers", History: 5}}; !reflect.DeepEqual(m.State, want) {
|
||||
// answers is the teller's answer to each ask; asked is what it asked of the router, which condition it raised and which
|
||||
// gate key and revision each ask was made at (issue 1343).
|
||||
if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}}; !reflect.DeepEqual(m.State, want) {
|
||||
t.Errorf("state = %+v, want %+v", m.State, want)
|
||||
}
|
||||
if want := []string{"mesh-delivery-gate.asks"}; !reflect.DeepEqual(sorted(m.Reads), want) {
|
||||
@@ -134,7 +136,10 @@ func TestManifestProcess(t *testing.T) { // 1240/AC1
|
||||
}
|
||||
|
||||
func TestServesNoRequestSubjectAndHoldsNoTicker(t *testing.T) { // 1240/AC3 (ADR 0437 §8)
|
||||
forbidden := []string{"Subscribe(", "QueueSubscribe", "ChanSubscribe", "time.NewTicker", "time.Tick", "time.NewTimer", "time.AfterFunc", "time.After(", "time.Sleep"}
|
||||
forbidden := []string{"Subscribe(", "QueueSubscribe", "ChanSubscribe", "Respond(", "RespondMsg(", "time.NewTicker", "time.Tick", "time.NewTimer", "time.AfterFunc", "time.After(", "time.Sleep"}
|
||||
// The one subscription the teller makes is to its own `decided` event, the router's word on its asks (ADR 0259, ADR 0437
|
||||
// §6): an event it hears, not a request it answers. It is allowed in decided.go and there only on that subject.
|
||||
const decidedFile = "decided.go"
|
||||
var files int
|
||||
for _, root := range []string{"cmd", "internal"} {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
@@ -150,6 +155,13 @@ func TestServesNoRequestSubjectAndHoldsNoTicker(t *testing.T) { // 1240/AC3 (ADR
|
||||
return err
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if f == "Subscribe(" && filepath.Base(path) == decidedFile {
|
||||
if n := strings.Count(string(src), "conn.Subscribe("); n != 1 || !strings.Contains(string(src), "conn.Subscribe(asks.DecidedSubject(module),") ||
|
||||
strings.Count(string(src), f) != n {
|
||||
t.Errorf("%s holds %d subscriptions, and not just one to asks.DecidedSubject(module)", path, strings.Count(string(src), f))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(src), f) {
|
||||
t.Errorf("%s holds %s: the teller serves no request subject and holds no timer", path, f)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "1",
|
||||
"slug": "teller",
|
||||
"state": [
|
||||
{ "name": "answers", "history": 5 }
|
||||
{ "name": "answers", "history": 5 },
|
||||
{ "name": "asked", "history": 1 }
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-gate.asks"
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-view/internal/view"
|
||||
@@ -109,3 +111,59 @@ func mustHash(t *testing.T, h delivery.Head) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestPrintsTheOpenAsksInTheTableAndTheJSON: the command shows what waits for the operator's Approve (1345/AC2). The ask is
|
||||
// the gate's, as mesh-sdk go/delivery EncodeAsk writes it under the key MergeAskKey gives (as internal/view asks_test.go).
|
||||
func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2
|
||||
m := view.New()
|
||||
heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
|
||||
key, err := delivery.MergeAskKey("feat/x", heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator,
|
||||
Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held",
|
||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
|
||||
value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Apply(view.Entry{Bucket: delivery.AsksBucket, Key: key, Value: value})
|
||||
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
l, err := view.ListenSocket(filepath.Join(dir, "cli.sock"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go view.ServeSocket(ctx, l, m)
|
||||
t.Cleanup(cancel)
|
||||
socket := filepath.Join(dir, "cli.sock")
|
||||
var out, errOut bytes.Buffer
|
||||
if code := run(nil, socket, &out, &errOut); code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut.String())
|
||||
}
|
||||
for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("the table lacks %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
out.Reset()
|
||||
if code := run([]string{"--json"}, socket, &out, &errOut); code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, errOut.String())
|
||||
}
|
||||
var got struct {
|
||||
Asks []struct {
|
||||
Key string `json:"key"`
|
||||
Headline string `json:"headline"`
|
||||
How string `json:"how"`
|
||||
} `json:"asks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil || len(got.Asks) != 1 || got.Asks[0].Key != key ||
|
||||
got.Asks[0].Headline != "Merge into main: mesh-delivery#12" || got.Asks[0].How != "answer on the phone" {
|
||||
t.Errorf("json = %s (%v)", out.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-view/internal/view"
|
||||
@@ -299,3 +300,93 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124
|
||||
}
|
||||
t.Fatalf("the snapshot never held the indexer's keys; last: %+v", last)
|
||||
}
|
||||
|
||||
// TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes starts the program whole before the gate's asks bucket and the
|
||||
// teller's answers bucket exist, then creates them: an ask appears in the snapshot, and leaves it once the teller has an
|
||||
// answer under its key (1345/AC1 against a real bus; the retry is the view's own, every 5 s).
|
||||
func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { // 1345/AC1
|
||||
srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Start()
|
||||
if !srv.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(srv.Shutdown)
|
||||
nc, err := nats.Connect(srv.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
broker := filepath.Join(dir, "broker")
|
||||
cred, _ := json.Marshal(credential{URL: srv.ClientURL(), Node: "n", Module: "mesh-delivery-view", User: "mesh-delivery-view", Password: "x"})
|
||||
if err := os.WriteFile(broker, cred, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
socket := filepath.Join(dir, "run", "cli.sock")
|
||||
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
|
||||
gate, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AsksBucket, History: delivery.AsksHistory})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
teller, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AnswersBucket, History: delivery.AnswersHistory})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
|
||||
key, err := delivery.MergeAskKey("feat/x", heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator,
|
||||
Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held",
|
||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
|
||||
value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gate.Put(ctx, key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait := func(check func(view.Snapshot) bool, what string) {
|
||||
t.Helper()
|
||||
var last view.Snapshot
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
|
||||
if s, err := view.Ask(socket); err == nil {
|
||||
last = s
|
||||
if check(s) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s; last: %+v", what, last)
|
||||
}
|
||||
wait(func(s view.Snapshot) bool {
|
||||
return len(s.Asks) == 1 && s.Asks[0].Key == key && s.Asks[0].How == "answer on the phone"
|
||||
}, "the open ask never showed")
|
||||
answer, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: ask.ID + "@2026-10-18T12:00:00Z", Words: "the operator, via telegram, chose Approve"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := teller.Put(ctx, key, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait(func(s view.Snapshot) bool { return len(s.Asks) == 0 }, "the answered ask never left the list")
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket} {
|
||||
for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket} {
|
||||
go follow(ctx, js, bucket, model, logger)
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-view
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337 h1:cZyFnvDi
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g/Q86N8CCHq3OatJIJ0JSy6Kh980ekGxNLmE=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const askHeadA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
// askEntry is a key of the gate's asks as the gate writes it (mesh-sdk go/delivery EncodeAsk, MergeAskKey): the router's ask
|
||||
// whole, under the id its key gives.
|
||||
func askEntry(t *testing.T, group, headline, words string) Entry {
|
||||
t.Helper()
|
||||
heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: askHeadA}}
|
||||
key, err := delivery.MergeAskKey(group, heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := asks.Ask{ID: delivery.AskID(key), Headline: headline, Explanation: words, Who: asks.Operator, Expires: t0.Add(23 * time.Hour), OnExpiry: "the merge stays held",
|
||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
|
||||
v, err := delivery.EncodeAsk(delivery.Ask{Group: group, Heads: heads, Ask: a})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Entry{Bucket: delivery.AsksBucket, Key: key, Value: v, At: t0}
|
||||
}
|
||||
|
||||
// answerEntry is the teller's answer to the first asking of the key; answerToAsking to the asking with this router id. The
|
||||
// answer's warrant is `<router id>@<time>` (teller.go warrantID).
|
||||
func answerEntry(t *testing.T, key string) Entry { return answerToAsking(t, key, delivery.AskID(key)) }
|
||||
|
||||
func answerToAsking(t *testing.T, key, id string) Entry {
|
||||
t.Helper()
|
||||
v, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: id + "@2026-10-18T12:00:00Z", Words: "the operator, via telegram, chose Approve"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Entry{Bucket: delivery.AnswersBucket, Key: key, Value: v, At: t0}
|
||||
}
|
||||
|
||||
func TestAnAskWithNoAnswerIsListedAndAnAnsweredOneIsNot(t *testing.T) { // 1345/AC1
|
||||
m := New()
|
||||
e := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.\nReview: approved.")
|
||||
fill(m, e)
|
||||
s := m.Snapshot()
|
||||
if len(s.Asks) != 1 {
|
||||
t.Fatalf("asks = %+v, want the one open ask", s.Asks)
|
||||
}
|
||||
a := s.Asks[0]
|
||||
if a.Key != e.Key || a.Group != "feat/x" || a.Headline != "Merge into main: mesh-delivery#12" || !strings.Contains(a.Words, "Merges one pull request.") ||
|
||||
a.How != "answer on the phone" || len(a.Pulls) != 1 || !strings.Contains(a.Pulls[0], "novox/mesh-delivery#12") || !strings.Contains(a.Pulls[0], askHeadA[:12]) {
|
||||
t.Errorf("ask = %+v", a)
|
||||
}
|
||||
fill(m, answerEntry(t, e.Key))
|
||||
if got := m.Snapshot().Asks; len(got) != 0 {
|
||||
t.Errorf("an answered ask is listed: %+v", got)
|
||||
}
|
||||
// An answer taken away leaves the ask open again; an ask taken away leaves the list.
|
||||
fill(m, Entry{Bucket: delivery.AnswersBucket, Key: e.Key, Deleted: true})
|
||||
if got := m.Snapshot().Asks; len(got) != 1 {
|
||||
t.Errorf("asks after the answer was taken away = %+v", got)
|
||||
}
|
||||
fill(m, Entry{Bucket: delivery.AsksBucket, Key: e.Key, Deleted: true})
|
||||
if got := m.Snapshot().Asks; len(got) != 0 {
|
||||
t.Errorf("a withdrawn ask is listed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerHeardBeforeItsAskStillHidesTheAsk(t *testing.T) {
|
||||
m := New()
|
||||
e := askEntry(t, "feat/x", "Merge", "Merges.")
|
||||
fill(m, answerEntry(t, e.Key), e)
|
||||
if got := m.Snapshot().Asks; len(got) != 0 {
|
||||
t.Errorf("asks = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadableAskIsNamedAndNotListed(t *testing.T) { // never dropped silently
|
||||
m := New()
|
||||
fill(m, Entry{Bucket: delivery.AsksBucket, Key: "merge.g.0123456789ab", Value: []byte("{"), At: t0})
|
||||
s := m.Snapshot()
|
||||
if len(s.Asks) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != delivery.AsksBucket {
|
||||
t.Errorf("snapshot = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableAndPageShowTheAsks(t *testing.T) { // 1345/AC2
|
||||
m := New()
|
||||
fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request."))
|
||||
var out bytes.Buffer
|
||||
Table(&out, m.Snapshot())
|
||||
for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone", "novox/mesh-delivery#12"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("the table lacks %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("the page lacks %q:\n%s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// No ask, no section.
|
||||
out.Reset()
|
||||
Table(&out, New().Snapshot())
|
||||
if strings.Contains(out.String(), "ASKS") {
|
||||
t.Errorf("the table shows an ASKS section with no ask:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2
|
||||
m := New()
|
||||
fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request."))
|
||||
raw, err := json.Marshal(m.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back struct {
|
||||
Asks []struct {
|
||||
Headline string `json:"headline"`
|
||||
How string `json:"how"`
|
||||
} `json:"asks"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &back); err != nil || len(back.Asks) != 1 || back.Asks[0].How != "answer on the phone" {
|
||||
t.Errorf("json %s: %+v %v", raw, back, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHaveOfARemovedModuleIsShownRemovedNotLeftBehind(t *testing.T) { // 1370: the node-engine writes phase removed
|
||||
m := New()
|
||||
fill(m, haveE(t, "novox", "mesh-delivery", have(t, "b1", delivery.PhaseRemoved, delivery.HealthOK, ""), t0),
|
||||
haveE(t, "novox", "stray", have(t, "b2", delivery.PhaseRunning, delivery.HealthOK, ""), t0))
|
||||
s := m.Snapshot()
|
||||
if r := find(t, s, "novox", "mesh-delivery"); r.Status != StatusRemoved {
|
||||
t.Errorf("row = %+v, want status %q", r, StatusRemoved)
|
||||
}
|
||||
if r := find(t, s, "novox", "stray"); r.Status != StatusLeftBehind {
|
||||
t.Errorf("row = %+v, want left behind", r)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
Table(&out, s)
|
||||
line := ""
|
||||
for _, l := range strings.Split(out.String(), "\n") {
|
||||
if strings.Contains(l, "mesh-delivery") {
|
||||
line = l
|
||||
}
|
||||
}
|
||||
if !strings.Contains(line, "removed") || strings.Contains(line, "left behind") {
|
||||
t.Errorf("table line %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerToAnEarlierAskingDoesNotHideTheNextAsking(t *testing.T) { // R2-1 of review round 2
|
||||
m := New()
|
||||
e := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.")
|
||||
fill(m, e, answerEntry(t, e.Key))
|
||||
if got := m.Snapshot().Asks; len(got) != 0 {
|
||||
t.Fatalf("asking 1 is answered and listed: %+v", got)
|
||||
}
|
||||
// The gate rewrites the key at asking 2; only asking 1's answer exists.
|
||||
heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: askHeadA}}
|
||||
a := asks.Ask{ID: delivery.AskingID(e.Key, 2), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator,
|
||||
Expires: t0.Add(23 * time.Hour), OnExpiry: "the merge stays held",
|
||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
|
||||
v, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Asking: 2, Ask: a})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fill(m, Entry{Bucket: delivery.AsksBucket, Key: e.Key, Value: v, At: t0})
|
||||
if got := m.Snapshot().Asks; len(got) != 1 {
|
||||
t.Fatalf("asking 2 is not listed while only asking 1's answer exists: %+v", got)
|
||||
}
|
||||
fill(m, answerToAsking(t, e.Key, a.ID))
|
||||
if got := m.Snapshot().Asks; len(got) != 0 {
|
||||
t.Errorf("asking 2 is answered and listed: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -111,9 +111,9 @@ func rowText(r Row) []string {
|
||||
if r.Error != "" {
|
||||
what = "unreadable: " + r.Error
|
||||
}
|
||||
if r.Status == StatusLeftBehind && what == "" {
|
||||
if (r.Status == StatusLeftBehind || r.Status == StatusRemoved) && what == "" {
|
||||
what = r.Status
|
||||
} else if r.Status == StatusLeftBehind {
|
||||
} else if r.Status == StatusLeftBehind || r.Status == StatusRemoved {
|
||||
what = r.Status + "; " + what
|
||||
}
|
||||
return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what}
|
||||
@@ -152,6 +152,15 @@ func fingerprintText(s Snapshot) [][]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// askText is one line per open ask: its key, headline, words on one line, the pull requests it covers and how to answer.
|
||||
func askText(s Snapshot) [][]string {
|
||||
var out [][]string
|
||||
for _, a := range s.Asks {
|
||||
out = append(out, []string{a.Key, a.Headline, strings.Join(strings.Fields(strings.ReplaceAll(a.Words, "\n", " / ")), " "), strings.Join(a.Pulls, ", "), a.How})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"}
|
||||
|
||||
// Table prints the snapshot as a table.
|
||||
@@ -184,6 +193,13 @@ func Table(w io.Writer, s Snapshot) {
|
||||
row(cells)
|
||||
}
|
||||
}
|
||||
if a := askText(s); len(a) > 0 {
|
||||
fmt.Fprintln(tw)
|
||||
row([]string{"ASKS", "HEADLINE", "WORDS", "PULL REQUESTS", "ANSWER"})
|
||||
for _, cells := range a {
|
||||
row(cells)
|
||||
}
|
||||
}
|
||||
if f := fingerprintText(s); len(f) > 0 {
|
||||
fmt.Fprintln(tw)
|
||||
row([]string{"MODULE", "FINGERPRINT", "INDEXER AGAINST BUILD"})
|
||||
@@ -227,6 +243,7 @@ var page = template.Must(template.New("p").Parse(`<!doctype html><html><head><me
|
||||
<body><h1>What each node runs</h1><table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>
|
||||
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
|
||||
{{if .Groups}}<h2>CANDIDATES</h2><table><tr><th>GROUP</th><th>PULL REQUEST</th><th>HEAD</th><th>MODULES</th></tr>{{range .Groups}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Asks}}<h2>ASKS</h2><table><tr><th>ASK</th><th>HEADLINE</th><th>WORDS</th><th>PULL REQUESTS</th><th>ANSWER</th></tr>{{range .Asks}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Fingerprints}}<h2>FINGERPRINTS</h2><table><tr><th>MODULE</th><th>STATE</th><th>INDEXER AGAINST BUILD</th></tr>{{range .Fingerprints}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Offline}}<h2>OFFLINE</h2><table>{{range .Offline}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
{{if .Stuck}}<h2>STUCK</h2><table><tr><th>BUCKET</th><th>KEY</th><th>WAITED FOR</th><th>SINCE</th></tr>{{range .Stuck}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
|
||||
@@ -260,6 +277,6 @@ func Web(m *Model) http.Handler {
|
||||
for _, u := range snap.Unreadable {
|
||||
bad = append(bad, []string{u.Bucket, u.Key, u.Error})
|
||||
}
|
||||
page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad})
|
||||
page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package view
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -61,8 +62,25 @@ type Snapshot struct {
|
||||
Groups []GroupRow `json:"groups"`
|
||||
// Fingerprints is every module the indexer fingerprinted at a trunk merge, beside its newest successful trunk build's.
|
||||
Fingerprints []FingerprintRow `json:"fingerprints"`
|
||||
// Asks is every ask the gate holds that has no answer yet: what waits for the operator's Approve.
|
||||
Asks []AskRow `json:"asks"`
|
||||
}
|
||||
|
||||
// AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers.
|
||||
type AskRow struct {
|
||||
Key string `json:"key"`
|
||||
Group string `json:"group"`
|
||||
Headline string `json:"headline"`
|
||||
Words string `json:"words"`
|
||||
Pulls []string `json:"pulls"`
|
||||
Expires time.Time `json:"expires,omitzero"`
|
||||
How string `json:"how"`
|
||||
}
|
||||
|
||||
// AnswerOnThePhone is what an open ask says about answering it: the view only shows, and the Approve is answered on the
|
||||
// operator's phone (ADR 0437 §10: answering in the view is release 5's).
|
||||
const AnswerOnThePhone = "answer on the phone"
|
||||
|
||||
// GroupRow is a candidate group (novox/hq ADR 0437 §5): the branch its pull requests share and the pull requests.
|
||||
type GroupRow struct {
|
||||
Group string `json:"group"`
|
||||
@@ -129,6 +147,8 @@ type Model struct {
|
||||
builds map[string]buildEntry // by <module>.build.<id>
|
||||
groups map[string]groupEntry // the indexer's candidates, by group
|
||||
modules map[string]moduleEntry // the indexer's modules, by module
|
||||
asks map[string]askRecord // the gate's asks, by key
|
||||
answers map[string]string // by key: the router id of the asking the teller's answer is for ("" when unreadable)
|
||||
newest map[string]string // module -> id
|
||||
haves map[string]haveEntry
|
||||
nodes map[string]nodeEntry
|
||||
@@ -194,6 +214,11 @@ type moduleEntry struct {
|
||||
err string
|
||||
}
|
||||
|
||||
type askRecord struct {
|
||||
value delivery.Ask
|
||||
err string
|
||||
}
|
||||
|
||||
type haveEntry struct {
|
||||
have delivery.Have
|
||||
err string
|
||||
@@ -208,7 +233,7 @@ type nodeEntry struct {
|
||||
|
||||
// New is an empty model.
|
||||
func New() *Model {
|
||||
return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, newest: map[string]string{},
|
||||
return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, asks: map[string]askRecord{}, answers: map[string]string{}, newest: map[string]string{},
|
||||
haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{},
|
||||
presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}}
|
||||
}
|
||||
@@ -277,6 +302,25 @@ func (m *Model) Apply(e Entry) {
|
||||
}
|
||||
mod, err := delivery.DecodeModule(e.Value)
|
||||
m.modules[e.Key] = moduleEntry{value: mod, err: errText(err)}
|
||||
case delivery.AsksBucket:
|
||||
if e.Deleted {
|
||||
delete(m.asks, e.Key)
|
||||
return
|
||||
}
|
||||
a, err := delivery.DecodeAsk(e.Value)
|
||||
m.asks[e.Key] = askRecord{value: a, err: errText(err)}
|
||||
case delivery.AnswersBucket:
|
||||
if e.Deleted {
|
||||
delete(m.answers, e.Key)
|
||||
return
|
||||
}
|
||||
// The answer's warrant is `<router id>@<time>`: it ends that asking, and only that one (a later asking of the key is
|
||||
// still open). An answer that cannot be read ends none.
|
||||
id := ""
|
||||
if a, err := delivery.DecodeAnswer(e.Value); err == nil {
|
||||
id, _, _ = strings.Cut(a.Warrant, "@")
|
||||
}
|
||||
m.answers[e.Key] = id
|
||||
case BuildsBucket:
|
||||
module, rest, ok := strings.Cut(e.Key, ".")
|
||||
if !ok {
|
||||
@@ -357,6 +401,10 @@ func (m *Model) Snapshot() Snapshot {
|
||||
}
|
||||
node, module, _, _ := delivery.ParseHaveKey(key)
|
||||
r := Row{Node: node, Module: module, Status: StatusLeftBehind, Since: h.at, Error: h.err}
|
||||
if h.err == "" && h.have.Phase == delivery.PhaseRemoved {
|
||||
// The node-engine says it took the module away: plainly removed, not left behind.
|
||||
r.Status = StatusRemoved
|
||||
}
|
||||
if h.err == "" {
|
||||
r.Have, r.Phase, r.Health, r.Detail = h.have.Build, string(h.have.Phase), string(h.have.Health), h.have.Detail
|
||||
}
|
||||
@@ -414,6 +462,11 @@ func (m *Model) Snapshot() Snapshot {
|
||||
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.ModulesBucket, Key: key, Error: mod.err})
|
||||
}
|
||||
}
|
||||
for key, a := range m.asks {
|
||||
if a.err != "" {
|
||||
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AsksBucket, Key: key, Error: a.err})
|
||||
}
|
||||
}
|
||||
sort.Slice(offline, func(i, j int) bool {
|
||||
a, b := offline[i], offline[j]
|
||||
return a.Kind+a.Node+a.Part+a.Instance < b.Kind+b.Node+b.Part+b.Instance
|
||||
@@ -425,7 +478,7 @@ func (m *Model) Snapshot() Snapshot {
|
||||
return unreadable[i].Bucket+"."+unreadable[i].Key < unreadable[j].Bucket+"."+unreadable[j].Key
|
||||
})
|
||||
return Snapshot{Rows: rows, Nodes: nodes, Offline: offline, Stuck: stuck, Unreadable: unreadable,
|
||||
Groups: m.groupRows(), Fingerprints: m.fingerprintRows()}
|
||||
Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows()}
|
||||
}
|
||||
|
||||
// groupRows are the readable candidate groups, ordered by group, their pull requests by repository and number.
|
||||
@@ -523,4 +576,28 @@ const (
|
||||
const (
|
||||
StatusAssigned = "assigned"
|
||||
StatusLeftBehind = "left behind"
|
||||
StatusRemoved = "removed"
|
||||
)
|
||||
|
||||
// askRows are the readable asks with no answer under their key, ordered by key: each with its headline, its words, the
|
||||
// pull requests it covers (repository, number, head shortened to 12 digits) and how to answer.
|
||||
func (m *Model) askRows() []AskRow {
|
||||
out := []AskRow{}
|
||||
for key, a := range m.asks {
|
||||
if a.err != "" || m.answers[key] == a.value.Ask.ID {
|
||||
continue
|
||||
}
|
||||
row := AskRow{Key: key, Group: a.value.Group, Headline: a.value.Ask.Headline, Words: a.value.Ask.Explanation,
|
||||
Expires: a.value.Ask.Expires, How: AnswerOnThePhone, Pulls: []string{}}
|
||||
for _, h := range a.value.Heads {
|
||||
head := h.Head
|
||||
if len(head) > 12 {
|
||||
head = head[:12]
|
||||
}
|
||||
row.Pulls = append(row.Pulls, fmt.Sprintf("%s#%d @%s", h.Repository, h.Number, head))
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
if m.Module != "mesh-delivery-view" {
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4
|
||||
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3
|
||||
got := slices.Clone(m.Reads)
|
||||
slices.Sort(got)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-controller.builds",
|
||||
"mesh-delivery-gate.asks",
|
||||
"mesh-delivery-indexer.candidates",
|
||||
"mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-teller.answers",
|
||||
"mesh-delivery-watchman.presence",
|
||||
"mesh-delivery-watchman.stuck",
|
||||
"node-engine.have"
|
||||
|
||||
Reference in New Issue
Block a user