Let the view answer the assigner's asks that need no proof, under the ask's view key, and refuse the merge's Approve on the phone's behalf (issue 1297 B9a)
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -472,3 +473,132 @@ func TestProcessWholeListsEveryDifference(t *testing.T) { // 1297/B8 AC
|
||||
}
|
||||
wait(func(s view.Snapshot) bool { return len(s.Differences) == 1 }, "the difference taken away never left the list")
|
||||
}
|
||||
|
||||
// TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof starts the program whole, with the assigner's asks, the view's
|
||||
// answers and the teller's answers buckets, and a fixture hold ask (in release 5 the assigner writes none yet). The
|
||||
// operator's answer over the socket is written under `<ask key>.view` in the view's bucket; the ask shows the choice, and
|
||||
// leaves the list once the teller's answer is under its key. The merge's Approve is refused with its reason and writes
|
||||
// nothing (1297/B9a AC).
|
||||
func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 1297/B9a AC
|
||||
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()
|
||||
bucket := func(name string, history uint8) jetstream.KeyValue {
|
||||
t.Helper()
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
askBucket := bucket(delivery.AssignerAsksBucket, delivery.AssignerAsksHistory)
|
||||
viewBucket := bucket(delivery.ViewAnswersBucket, delivery.ViewAnswersHistory)
|
||||
teller := bucket(delivery.AnswersBucket, delivery.AnswersHistory)
|
||||
gate := bucket(delivery.AsksBucket, delivery.AsksHistory)
|
||||
|
||||
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 })
|
||||
|
||||
const fp = "src2:2222222222222222222222222222222222222222222222222222222222222222"
|
||||
key, err := delivery.HoldAskKey("plex", fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ask := asks.Ask{ID: delivery.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check.", Who: asks.Operator,
|
||||
Expires: time.Now().Add(time.Hour), OnExpiry: "the fingerprint stays held",
|
||||
Options: []asks.Option{{ID: "release", Label: "Release", Does: "lets the move go on", Level: asks.Approve}, {ID: "keep", Label: "Keep held", Does: "keeps it held", Level: asks.Approve}}}
|
||||
value, _ := json.Marshal(map[string]any{"v": 1, "ask": ask})
|
||||
if _, err := askBucket.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].How == view.AnswerInTheView }, "the open ask never showed")
|
||||
|
||||
if err := view.AnswerAsk(socket, key, "release", "the check was a flake"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := viewBucket.Get(ctx, key+".view")
|
||||
if err != nil {
|
||||
t.Fatalf("the view wrote no answer under %s.view: %v", key, err)
|
||||
}
|
||||
if a, err := delivery.DecodeViewAnswer(e.Value()); err != nil || a.Option != "release" {
|
||||
t.Fatalf("answer = %+v, %v", a, err)
|
||||
}
|
||||
wait(func(s view.Snapshot) bool { return len(s.Asks) == 1 && s.Asks[0].Chosen == "release" }, "the ask never showed the view's choice")
|
||||
|
||||
// The merge's Approve is answered on the phone alone.
|
||||
heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
|
||||
mkey, err := delivery.MergeAskKey("feat/x", heads)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
merge := asks.Ask{ID: delivery.AskID(mkey), Headline: "Merge", Explanation: "Merges.", Who: asks.Operator, Expires: time.Now().Add(time.Hour), OnExpiry: "held",
|
||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
|
||||
mv, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: merge})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := gate.Put(ctx, mkey, mv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait(func(s view.Snapshot) bool { return len(s.Asks) == 2 }, "the merge ask never showed")
|
||||
if err := view.AnswerAsk(socket, mkey, "approve", ""); err == nil || !strings.Contains(err.Error(), "answer it on the phone (issue 953)") {
|
||||
t.Errorf("merge Approve = %v, want the refusal naming the phone", err)
|
||||
}
|
||||
if _, err := viewBucket.Get(ctx, mkey+".view"); err == nil {
|
||||
t.Error("the view wrote an answer to the merge's ask")
|
||||
}
|
||||
|
||||
// The teller fills the ask: it leaves the list.
|
||||
answer, err := delivery.EncodeAnswer(delivery.Answer{Option: "release", Warrant: ask.ID + "@2026-10-23T12:00:00Z", Words: "answered in the view"})
|
||||
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) == 1 && s.Asks[0].Key == mkey }, "the answered ask never left the list")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Command mesh-delivery-view is the operator's view of delivery (novox/hq ADR 0429 §3): it watches the have, assignments
|
||||
// and builds buckets on its own bus credential, derives one row per module and node in memory, and serves them on a
|
||||
// local socket (for mesh-delivery-cli) and on a local web page. It writes nothing and starts, stops or retries nothing.
|
||||
// local socket (for mesh-delivery-cli) and on a local web page. It starts, stops or retries nothing; the one thing it writes is the operator's answer to an ask that needs no proof of who
|
||||
// answered, under `<ask key>.view` in its bucket answers (ADR 0528 clause 6).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -136,7 +137,8 @@ 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, delivery.AsksBucket, delivery.AnswersBucket, delivery.BuilderBuildsBucket, delivery.AssignerDifferencesBucket} {
|
||||
model.SetWriter(kvWriter{ctx: ctx, js: js})
|
||||
for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket, delivery.BuilderBuildsBucket, delivery.AssignerDifferencesBucket, delivery.AssignerAsksBucket, delivery.ViewAnswersBucket} {
|
||||
go follow(ctx, js, bucket, model, logger)
|
||||
}
|
||||
select {
|
||||
@@ -152,6 +154,22 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// kvWriter is the view's one write: a key of its own bucket answers (manifest state entry answers), which the teller reads.
|
||||
// The bucket is the controller's to create; a bucket not there yet is an error the operator is told.
|
||||
type kvWriter struct {
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
}
|
||||
|
||||
func (w kvWriter) Put(key string, value []byte) error {
|
||||
kv, err := w.js.KeyValue(w.ctx, delivery.ViewAnswersBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", delivery.ViewAnswersBucket, err)
|
||||
}
|
||||
_, err = kv.Put(w.ctx, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// follow watches a bucket with its last values first and then each change, for as long as ctx lives: a bucket that is
|
||||
// not there yet, or a watch that ends, is tried again.
|
||||
func follow(ctx context.Context, js jetstream.JetStream, bucket string, model *view.Model, logger *log.Logger) {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// memWriter is the view's bucket answers in memory.
|
||||
type memWriter struct {
|
||||
puts map[string][]byte
|
||||
fail error
|
||||
}
|
||||
|
||||
func (w *memWriter) Put(key string, value []byte) error {
|
||||
if w.fail != nil {
|
||||
return w.fail
|
||||
}
|
||||
if w.puts == nil {
|
||||
w.puts = map[string][]byte{}
|
||||
}
|
||||
w.puts[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// holdAsk is a key of the assigner's asks: the ask about a held fingerprint, as a fixture (in release 5 the assigner writes
|
||||
// none yet; the value is `{"v":1,"ask":<the router's ask>}`, as the gate's minus group and heads).
|
||||
func holdAsk(t *testing.T) (Entry, asks.Ask) {
|
||||
t.Helper()
|
||||
key, err := delivery.HoldAskKey("plex", fpNew)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := asks.Ask{ID: delivery.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check on the new build.", Who: asks.Operator,
|
||||
Expires: t0.Add(23 * time.Hour), OnExpiry: "the fingerprint stays held",
|
||||
Options: []asks.Option{{ID: "release", Label: "Release", Does: "lets the move go on", Level: asks.Approve}, {ID: "keep", Label: "Keep held", Does: "keeps it held", Level: asks.Approve}}}
|
||||
v, err := json.Marshal(map[string]any{"v": 1, "ask": a})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Entry{Bucket: delivery.AssignerAsksBucket, Key: key, Value: v, At: t0}, a
|
||||
}
|
||||
|
||||
func TestAnAskNeedingNoProofIsOpenInTheViewWithItsOptions(t *testing.T) { // 1297/B9
|
||||
m := New()
|
||||
e, a := holdAsk(t)
|
||||
fill(m, e)
|
||||
got := m.Snapshot().Asks
|
||||
if len(got) != 1 || got[0].Key != e.Key || got[0].Headline != a.Headline || got[0].How != AnswerInTheView || strings.Join(got[0].Options, ",") != "release,keep" {
|
||||
t.Fatalf("asks = %+v", got)
|
||||
}
|
||||
// Once the teller filled it (its answer's warrant names the ask's id) it is answered and leaves the list.
|
||||
fill(m, answerToAsking(t, e.Key, a.ID))
|
||||
if got := m.Snapshot().Asks; len(got) != 0 {
|
||||
t.Errorf("an answered ask is listed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheViewWritesItsAnswerUnderTheAsksViewKey(t *testing.T) { // 1297/B9a AC
|
||||
m, w := New(), &memWriter{}
|
||||
m.SetWriter(w)
|
||||
e, _ := holdAsk(t)
|
||||
fill(m, e)
|
||||
if err := m.Answer(e.Key, "release", "the check was a flake"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := w.puts[e.Key+".view"]
|
||||
if !ok || len(w.puts) != 1 {
|
||||
t.Fatalf("puts = %v, want the one key %s.view", w.puts, e.Key)
|
||||
}
|
||||
a, err := delivery.DecodeViewAnswer(raw)
|
||||
if err != nil || a.Option != "release" || a.Words != "the check was a flake" {
|
||||
t.Errorf("answer = %+v, %v", a, err)
|
||||
}
|
||||
// The key is the SDK's: the bucket's key parses back to the ask.
|
||||
if back, err := delivery.ParseViewAnswerKey(e.Key + ".view"); err != nil || back != e.Key {
|
||||
t.Errorf("parse = %q, %v", back, err)
|
||||
}
|
||||
// The view hears its own answer back from its bucket and shows the ask as answered, waiting for the teller.
|
||||
fill(m, Entry{Bucket: delivery.ViewAnswersBucket, Key: e.Key + ".view", Value: raw, At: t0})
|
||||
got := m.Snapshot().Asks
|
||||
if len(got) != 1 || got[0].Chosen != "release" {
|
||||
t.Errorf("asks = %+v, want the ask with chosen release", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheViewRefusesWhatItMayNotAnswer(t *testing.T) { // 1297/B9a AC
|
||||
e, a := holdAsk(t)
|
||||
merge := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges.")
|
||||
cases := []struct {
|
||||
name, key, option, want string
|
||||
prep []Entry
|
||||
}{
|
||||
{"the merge's Approve", merge.Key, "approve", "answer it on the phone (issue 953)", []Entry{merge}},
|
||||
{"an option the ask does not offer", e.Key, "delete", `offers no option "delete"`, []Entry{e}},
|
||||
{"an ask the view does not hold", e.Key, "release", "no open ask", nil},
|
||||
{"an ask that is no ask key", "hold.x", "release", "not an ask key", nil},
|
||||
{"an ask the teller has filled", e.Key, "release", "no open ask", []Entry{e, answerToAsking(t, e.Key, a.ID)}},
|
||||
{"no option", e.Key, " ", "no option", []Entry{e}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m, w := New(), &memWriter{}
|
||||
m.SetWriter(w)
|
||||
fill(m, c.prep...)
|
||||
err := m.Answer(c.key, c.option, "")
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: err = %v, want it to say %q", c.name, err, c.want)
|
||||
}
|
||||
if len(w.puts) != 0 {
|
||||
t.Errorf("%s: wrote %v", c.name, w.puts)
|
||||
}
|
||||
}
|
||||
m := New()
|
||||
e2, _ := holdAsk(t)
|
||||
fill(m, e2)
|
||||
if err := m.Answer(e2.Key, "release", ""); err == nil || !strings.Contains(err.Error(), "cannot write") {
|
||||
t.Errorf("a view with no writer: %v", err)
|
||||
}
|
||||
m.SetWriter(&memWriter{fail: fmt.Errorf("the bus said no")})
|
||||
if err := m.Answer(e2.Key, "release", ""); err == nil || !strings.Contains(err.Error(), "the bus said no") {
|
||||
t.Errorf("a failed write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSocketAnswersAnAsk(t *testing.T) { // 1297/B9b
|
||||
path, m := served(t)
|
||||
w := &memWriter{}
|
||||
m.SetWriter(w)
|
||||
e, _ := holdAsk(t)
|
||||
fill(m, e)
|
||||
if err := AnswerAsk(path, e.Key, "keep", "wait for the next build"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := w.puts[e.Key+".view"]; !ok {
|
||||
t.Fatalf("puts = %v", w.puts)
|
||||
}
|
||||
merge := askEntry(t, "feat/x", "Merge", "Merges.")
|
||||
fill(m, merge)
|
||||
err := AnswerAsk(path, merge.Key, "approve", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "answer it on the phone (issue 953)") {
|
||||
t.Errorf("merge over the socket: %v", err)
|
||||
}
|
||||
// the old asks stay refused
|
||||
c, err := dial(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Write([]byte(`{"ask":"answer"}` + "\n"))
|
||||
line, _ := bufio.NewReader(c).ReadString('\n')
|
||||
c.Close()
|
||||
if !strings.Contains(line, `"error"`) {
|
||||
t.Errorf("an answer naming nothing: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func postAnswer(h http.Handler, form url.Values, mod func(*http.Request)) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/answer", strings.NewReader(form.Encode()))
|
||||
req.Host = "127.0.0.1:8471"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if mod != nil {
|
||||
mod(req)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestThePageAnswersAnAskAndRefusesAForeignPost(t *testing.T) { // 1297/B9a
|
||||
m, w := New(), &memWriter{}
|
||||
m.SetWriter(w)
|
||||
e, _ := holdAsk(t)
|
||||
fill(m, e)
|
||||
h := Web(m)
|
||||
page := httptest.NewRecorder()
|
||||
h.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
for _, want := range []string{`action="/answer"`, `value="release"`, `value="keep"`, e.Key} {
|
||||
if !strings.Contains(page.Body.String(), want) {
|
||||
t.Errorf("the page lacks %q:\n%s", want, page.Body.String())
|
||||
}
|
||||
}
|
||||
form := url.Values{"key": {e.Key}, "option": {"release"}, "words": {"ok"}}
|
||||
// A post from another origin, or to a host name that is not the loopback's, is refused and writes nothing.
|
||||
for name, mod := range map[string]func(*http.Request){
|
||||
"another origin": func(r *http.Request) { r.Header.Set("Origin", "http://evil.example") },
|
||||
"another host": func(r *http.Request) { r.Host = "evil.example" },
|
||||
} {
|
||||
if rec := postAnswer(h, form, mod); rec.Code != http.StatusForbidden || len(w.puts) != 0 {
|
||||
t.Errorf("%s: %d, puts %v", name, rec.Code, w.puts)
|
||||
}
|
||||
}
|
||||
rec := postAnswer(h, form, func(r *http.Request) { r.Header.Set("Origin", "http://127.0.0.1:8471") })
|
||||
if rec.Code != http.StatusSeeOther || len(w.puts) != 1 {
|
||||
t.Fatalf("answer = %d %q, puts %v", rec.Code, rec.Body.String(), w.puts)
|
||||
}
|
||||
// The merge's Approve is refused with its reason, and the rest of the page stays read-only.
|
||||
merge := askEntry(t, "feat/x", "Merge", "Merges.")
|
||||
fill(m, merge)
|
||||
rec = postAnswer(h, url.Values{"key": {merge.Key}, "option": {"approve"}}, nil)
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "answer it on the phone (issue 953)") || len(w.puts) != 1 {
|
||||
t.Errorf("merge = %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := httptest.NewRecorder(); true {
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/answer", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET /answer = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,14 @@ func ListenSocket(path string) (net.Listener, error) {
|
||||
}
|
||||
|
||||
type ask struct {
|
||||
Ask string `json:"ask"`
|
||||
Ask string `json:"ask"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Option string `json:"option,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
// ServeSocket answers {"ask":"rows"} on l, line-delimited JSON, until ctx ends. Any other ask is refused.
|
||||
// ServeSocket answers {"ask":"rows"} on l, line-delimited JSON, until ctx ends, and {"ask":"answer","key":…,"option":…,"words":…}
|
||||
// with the model's Answer (the one thing the view writes, issue 1297 B9). Any other ask is refused.
|
||||
func ServeSocket(ctx context.Context, l net.Listener, m *Model) error {
|
||||
go func() { <-ctx.Done(); l.Close() }()
|
||||
for {
|
||||
@@ -68,13 +72,46 @@ func answer(c net.Conn, m *Model) {
|
||||
return
|
||||
}
|
||||
var a ask
|
||||
if json.Unmarshal(line, &a) != nil || a.Ask != "rows" {
|
||||
json.NewEncoder(c).Encode(map[string]string{"error": `refused: this socket answers {"ask":"rows"} and nothing else`})
|
||||
if json.Unmarshal(line, &a) != nil || (a.Ask != "rows" && a.Ask != "answer") {
|
||||
json.NewEncoder(c).Encode(map[string]string{"error": `refused: this socket answers {"ask":"rows"} and {"ask":"answer"} and nothing else`})
|
||||
return
|
||||
}
|
||||
if a.Ask == "answer" {
|
||||
if err := m.Answer(a.Key, a.Option, a.Words); err != nil {
|
||||
json.NewEncoder(c).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(c).Encode(map[string]string{"answered": a.Key})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(c).Encode(m.Snapshot())
|
||||
}
|
||||
|
||||
// AnswerAsk asks the socket at path to write the view's answer to the open ask at key. The view's refusal is the error.
|
||||
func AnswerAsk(path, key, option, words string) error {
|
||||
c, err := net.DialTimeout("unix", path, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reach the view at %s: %w", path, err)
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
req, _ := json.Marshal(ask{Ask: "answer", Key: key, Option: option, Words: words})
|
||||
if _, err := c.Write(append(req, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
var reply struct {
|
||||
Answered string `json:"answered"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(c).Decode(&reply); err != nil {
|
||||
return fmt.Errorf("the view's reply is not an answer: %w", err)
|
||||
}
|
||||
if reply.Error != "" {
|
||||
return errors.New(reply.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ask asks the socket at path for the rows.
|
||||
func Ask(path string) (Snapshot, error) {
|
||||
c, err := net.DialTimeout("unix", path, 5*time.Second)
|
||||
@@ -160,7 +197,14 @@ func fingerprintText(s Snapshot) [][]string {
|
||||
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})
|
||||
how := a.How
|
||||
switch {
|
||||
case a.Chosen != "":
|
||||
how = "answered " + a.Chosen + " in the view; the teller fills it"
|
||||
case len(a.Options) > 0:
|
||||
how += ": " + strings.Join(a.Options, " or ")
|
||||
}
|
||||
out = append(out, []string{a.Key, a.Headline, strings.Join(strings.Fields(strings.ReplaceAll(a.Words, "\n", " / ")), " "), strings.Join(a.Pulls, ", "), how})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -284,7 +328,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 .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 .Cells}}<td>{{.}}</td>{{end}}<td>{{if .Options}}<form method="post" action="/answer"><input type="hidden" name="key" value="{{.Key}}"><input name="words" placeholder="words (optional)">{{range .Options}} <button name="option" value="{{.}}">{{.}}</button>{{end}}</form>{{end}}</td></tr>{{end}}</table>{{end}}
|
||||
{{if .Differences}}<h2>WANTS AGAINST SENDS</h2><table><tr>{{range .DifferenceHead}}<th>{{.}}</th>{{end}}</tr>{{range .Differences}}<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}}
|
||||
@@ -300,6 +344,15 @@ var wantsPage = template.Must(template.New("w").Parse(`<!doctype html><html><hea
|
||||
// Web is the read-only web view of the same rows, refreshed by the browser.
|
||||
func Web(m *Model) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/answer" && r.Method == http.MethodPost {
|
||||
answerForm(m, w, r)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/answer" {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "an answer is posted from the form of the page", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "this view is read-only", http.StatusMethodNotAllowed)
|
||||
@@ -330,6 +383,62 @@ 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), "Asks": askText(snap), "Fingerprints": fingerprintText(snap), "Differences": differenceText(snap), "DifferenceHead": differenceHead, "Offline": off, "Stuck": stuck, "Unreadable": bad})
|
||||
page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askPage(snap), "Fingerprints": fingerprintText(snap), "Differences": differenceText(snap), "DifferenceHead": differenceHead, "Offline": off, "Stuck": stuck, "Unreadable": bad})
|
||||
})
|
||||
}
|
||||
|
||||
// askPageRow is an open ask on the page: its cells, and the options a form offers when the view may answer it.
|
||||
type askPageRow struct {
|
||||
Cells []string
|
||||
Key string
|
||||
Options []string
|
||||
}
|
||||
|
||||
func askPage(s Snapshot) []askPageRow {
|
||||
var out []askPageRow
|
||||
cells := askText(s)
|
||||
for i, a := range s.Asks {
|
||||
row := askPageRow{Cells: cells[i], Key: a.Key}
|
||||
if a.Chosen == "" {
|
||||
row.Options = a.Options
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loopback says whether a Host header names the machine itself, as the web view listens on 127.0.0.1 only: a page of another
|
||||
// origin, or a name an attacker's DNS points here, writes nothing.
|
||||
func loopback(host string) bool {
|
||||
h, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
h = host
|
||||
}
|
||||
return h == "127.0.0.1" || h == "localhost" || h == "[::1]" || h == "::1"
|
||||
}
|
||||
|
||||
// answerForm is the one write the web view makes: POST /answer with key, option and words, from a form of this page.
|
||||
func answerForm(m *Model, w http.ResponseWriter, r *http.Request) {
|
||||
if !loopback(r.Host) {
|
||||
http.Error(w, "this view answers only on its own address", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if o := r.Header.Get("Origin"); o != "" && o != "http://"+r.Host {
|
||||
http.Error(w, "this view answers only a form of its own page", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||||
http.Error(w, "this view answers only a form of its own page", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8192)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := m.Answer(r.PostFormValue("key"), r.PostFormValue("option"), r.PostFormValue("words")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ package view
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -93,12 +95,19 @@ type AskRow struct {
|
||||
Pulls []string `json:"pulls"`
|
||||
Expires time.Time `json:"expires,omitzero"`
|
||||
How string `json:"how"`
|
||||
// Options is what the view offers to answer with, for an ask that needs no proof of who answered (the assigner's hold and
|
||||
// restore, ADR 0528 clause 6); Chosen is the option the view wrote that the teller has not filled yet.
|
||||
Options []string `json:"options,omitempty"`
|
||||
Chosen string `json:"chosen,omitempty"`
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
// AnswerInTheView is what an open ask that needs no proof of who answered says (ADR 0528 clause 6): the operator answers it here.
|
||||
const AnswerInTheView = "answer in the view"
|
||||
|
||||
// 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"`
|
||||
@@ -168,14 +177,17 @@ type UnreadableRow struct {
|
||||
type Model struct {
|
||||
mu sync.Mutex
|
||||
assigns map[string]assignment
|
||||
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
|
||||
keys map[string]keyEntry // the builder's build keys, by <module>.<fingerprint>
|
||||
asks map[string]askRecord // the gate's asks, by key
|
||||
diffs map[string]diffEntry // the assigner's differences, by <node>.<module>
|
||||
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
|
||||
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
|
||||
keys map[string]keyEntry // the builder's build keys, by <module>.<fingerprint>
|
||||
asks map[string]askRecord // the gate's asks, by key
|
||||
diffs map[string]diffEntry // the assigner's differences, by <node>.<module>
|
||||
aasks map[string]assignerAskRecord // the assigner's asks (hold, restore), by key
|
||||
chosen map[string]string // the options the view wrote, by ask key
|
||||
writer Writer
|
||||
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
|
||||
// the watchman's buckets, by key
|
||||
@@ -245,6 +257,11 @@ type diffEntry struct {
|
||||
err string
|
||||
}
|
||||
|
||||
type assignerAskRecord struct {
|
||||
ask asks.Ask
|
||||
err string
|
||||
}
|
||||
|
||||
type moduleEntry struct {
|
||||
value delivery.Module
|
||||
err string
|
||||
@@ -269,7 +286,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{}, keys: map[string]keyEntry{}, asks: map[string]askRecord{}, diffs: map[string]diffEntry{}, answers: map[string]string{}, newest: map[string]string{},
|
||||
return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, keys: map[string]keyEntry{}, asks: map[string]askRecord{}, diffs: map[string]diffEntry{}, aasks: map[string]assignerAskRecord{}, chosen: map[string]string{}, answers: map[string]string{}, newest: map[string]string{},
|
||||
haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{},
|
||||
presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}}
|
||||
}
|
||||
@@ -345,6 +362,25 @@ func (m *Model) Apply(e Entry) {
|
||||
}
|
||||
a, err := delivery.DecodeAsk(e.Value)
|
||||
m.asks[e.Key] = askRecord{value: a, err: errText(err)}
|
||||
case delivery.AssignerAsksBucket:
|
||||
if e.Deleted {
|
||||
delete(m.aasks, e.Key)
|
||||
return
|
||||
}
|
||||
a, err := decodeAssignerAsk(e.Key, e.Value)
|
||||
m.aasks[e.Key] = assignerAskRecord{ask: a, err: errText(err)}
|
||||
case delivery.ViewAnswersBucket:
|
||||
askKey, err := delivery.ParseViewAnswerKey(e.Key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if e.Deleted {
|
||||
delete(m.chosen, askKey)
|
||||
return
|
||||
}
|
||||
if a, err := delivery.DecodeViewAnswer(e.Value); err == nil {
|
||||
m.chosen[askKey] = a.Option
|
||||
}
|
||||
case delivery.AssignerDifferencesBucket:
|
||||
if e.Deleted {
|
||||
delete(m.diffs, e.Key)
|
||||
@@ -521,6 +557,11 @@ func (m *Model) Snapshot() Snapshot {
|
||||
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AsksBucket, Key: key, Error: a.err})
|
||||
}
|
||||
}
|
||||
for key, a := range m.aasks {
|
||||
if a.err != "" {
|
||||
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AssignerAsksBucket, Key: key, Error: a.err})
|
||||
}
|
||||
}
|
||||
for key, d := range m.diffs {
|
||||
if d.err != "" {
|
||||
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AssignerDifferencesBucket, Key: key, Error: d.err})
|
||||
@@ -691,10 +732,96 @@ func (m *Model) askRows() []AskRow {
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
for key, a := range m.aasks {
|
||||
if a.err != "" || m.answers[key] == a.ask.ID {
|
||||
continue
|
||||
}
|
||||
row := AskRow{Key: key, Headline: a.ask.Headline, Words: a.ask.Explanation, Expires: a.ask.Expires, How: AnswerInTheView, Pulls: []string{},
|
||||
Chosen: m.chosen[key]}
|
||||
for _, o := range a.ask.Options {
|
||||
row.Options = append(row.Options, o.ID)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
// decodeAssignerAsk reads a key of the assigner's asks: the router's ask under "ask" (`{"v":1,"ask":{…}}`, the gate's ask
|
||||
// without its group and heads; the layout of the assigner's asks is the assigner's, issue 1297 B5). The key must be a kind of
|
||||
// ask that needs no proof, and the ask must offer an option.
|
||||
func decodeAssignerAsk(key string, value []byte) (asks.Ask, error) {
|
||||
k, err := delivery.ParseAnyAskKey(key)
|
||||
if err != nil {
|
||||
return asks.Ask{}, err
|
||||
}
|
||||
if proof, _ := delivery.AskNeedsProof(k.Kind); proof {
|
||||
return asks.Ask{}, fmt.Errorf("%s needs proof of who answered and is no ask of the assigner", key)
|
||||
}
|
||||
var v struct {
|
||||
Ask asks.Ask `json:"ask"`
|
||||
}
|
||||
if err := json.Unmarshal(value, &v); err != nil {
|
||||
return asks.Ask{}, fmt.Errorf("the assigner's ask %s is not readable: %w", key, err)
|
||||
}
|
||||
if v.Ask.ID == "" || len(v.Ask.Options) == 0 {
|
||||
return asks.Ask{}, fmt.Errorf("the assigner's ask %s names no id or no option", key)
|
||||
}
|
||||
return v.Ask, nil
|
||||
}
|
||||
|
||||
// Writer puts a key of the view's bucket answers, the only state the view writes.
|
||||
type Writer interface {
|
||||
Put(key string, value []byte) error
|
||||
}
|
||||
|
||||
// SetWriter gives the model the bucket its answers are written to.
|
||||
func (m *Model) SetWriter(w Writer) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.writer = w
|
||||
}
|
||||
|
||||
// Answer writes the view's answer to the open ask at askKey: option, one the ask offers, and the operator's words. It writes
|
||||
// `<ask key>.view` and nothing else; an ask that needs proof of who answered (the merge's Approve, issue 953) is refused with
|
||||
// its reason, as is an ask the view does not hold open, an option the ask does not offer, and one the view answered already.
|
||||
func (m *Model) Answer(askKey, option, words string) error {
|
||||
key, err := delivery.ViewAnswerKey(askKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
option = strings.TrimSpace(option)
|
||||
if option == "" {
|
||||
return fmt.Errorf("an answer names no option")
|
||||
}
|
||||
m.mu.Lock()
|
||||
a, held := m.aasks[askKey]
|
||||
open := held && a.err == "" && m.answers[askKey] != a.ask.ID
|
||||
chosen, w := m.chosen[askKey], m.writer
|
||||
m.mu.Unlock()
|
||||
if !open {
|
||||
return fmt.Errorf("the view holds no open ask %s", askKey)
|
||||
}
|
||||
if !slices.ContainsFunc(a.ask.Options, func(o asks.Option) bool { return o.ID == option }) {
|
||||
ids := []string{}
|
||||
for _, o := range a.ask.Options {
|
||||
ids = append(ids, o.ID)
|
||||
}
|
||||
return fmt.Errorf("the ask %s offers no option %q (it offers %s)", askKey, option, strings.Join(ids, ", "))
|
||||
}
|
||||
if chosen != "" {
|
||||
return fmt.Errorf("the view answered %s with %q already: the teller fills the ask from it", askKey, chosen)
|
||||
}
|
||||
if w == nil {
|
||||
return fmt.Errorf("the view cannot write its answers here")
|
||||
}
|
||||
body, err := delivery.EncodeViewAnswer(delivery.ViewAnswer{Option: option, Words: strings.TrimSpace(words)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Put(key, body)
|
||||
}
|
||||
|
||||
// differenceRows are the readable differences, ordered by node and module.
|
||||
func (m *Model) differenceRows() []DifferenceRow {
|
||||
out := []DifferenceRow{}
|
||||
|
||||
@@ -36,7 +36,13 @@ type item struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
type state struct {
|
||||
Name string `json:"name"`
|
||||
History int `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
State []state `json:"state"`
|
||||
Module string `json:"module"`
|
||||
Moved bool `json:"moved"`
|
||||
Version string `json:"version"`
|
||||
@@ -76,17 +82,21 @@ 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-assigner.differences", "mesh-delivery-builder.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, 1297/B8
|
||||
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-builder.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, 1297/B8, B9
|
||||
got := slices.Clone(m.Reads)
|
||||
slices.Sort(got)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("reads = %v, want exactly %v", got, want)
|
||||
}
|
||||
for _, k := range []string{"state", "emits", "consumes", "provides", "claims"} {
|
||||
for _, k := range []string{"emits", "consumes", "provides", "claims"} {
|
||||
if _, ok := top[k]; ok {
|
||||
t.Errorf("the manifest declares %q: the view writes nothing", k)
|
||||
t.Errorf("the manifest declares %q: the view writes only its answers", k)
|
||||
}
|
||||
}
|
||||
// The one state the view writes: the operator's answers to the asks that need no proof (ADR 0528 clause 6, 1297/B9), which the teller reads.
|
||||
if want := []state{{Name: "answers", History: 5}}; !reflect.DeepEqual(m.State, want) {
|
||||
t.Errorf("state = %+v, want %+v", m.State, want)
|
||||
}
|
||||
if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 {
|
||||
t.Errorf("own-secrets = %v, want exactly broker (or no credential is issued)", m.OwnSecrets)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,13 @@
|
||||
"builder_build",
|
||||
"builder_builds"
|
||||
],
|
||||
"state": [
|
||||
{ "name": "answers", "history": 5 }
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-controller.builds",
|
||||
"mesh-delivery-assigner.asks",
|
||||
"mesh-delivery-assigner.differences",
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-gate.asks",
|
||||
|
||||
Reference in New Issue
Block a user