Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d1de0475 | ||
|
|
db26f7e8b6 | ||
|
|
d4ae4b6790 | ||
|
|
2f6741b7f8 | ||
|
|
20a6dc4920 | ||
|
|
c4f37c279a | ||
|
|
7f89cb51de | ||
|
|
518dae5ec9 | ||
|
|
e8f8ef63fa | ||
|
|
4f4d5ca9c9 | ||
|
|
a8fbabbfc3 | ||
|
|
b9911dc1c1 | ||
|
|
e6b2df6506 | ||
|
|
f9d11acf23 | ||
|
|
10b6e20232 | ||
|
|
da7f045575 | ||
|
|
748c564cdf | ||
|
|
312878befb | ||
|
|
d7a9b9688b | ||
|
|
7f9cc0e55c | ||
|
|
e45bdc0891 | ||
|
|
a523a8cbb0 | ||
|
|
5da87a0dda | ||
|
|
e64486b0e6 | ||
|
|
64a502d16a | ||
|
|
de5b33843f | ||
|
|
d8da8f1d36 | ||
|
|
d506f2196a | ||
|
|
d6cef5829b | ||
|
|
826fd73cf2 | ||
|
|
aee971c198 | ||
|
|
bfc3883af9 | ||
|
|
19c1d35939 | ||
|
|
bc0c25e510 | ||
|
|
8c6e18e822 | ||
|
|
d9fa5536a8 | ||
|
|
dd0d156b9e | ||
|
|
51bc6a65b6 | ||
|
|
66bf26c0d7 | ||
|
|
34f210e5eb | ||
|
|
4784762a70 | ||
|
|
668fc2a722 | ||
|
|
20d18eb4f5 |
@@ -42,11 +42,20 @@ func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func()
|
||||
}
|
||||
setting, _ = s[CapacitySetting].(string)
|
||||
}
|
||||
caps, err := buildrun.ParseCapacity(setting)
|
||||
n, err := capacityOf(setting, node)
|
||||
if err != nil {
|
||||
say("%v, so %s pulls nothing", fmt.Errorf("the build capacity: %w", err), node)
|
||||
say("%v, so %s pulls nothing", err, node)
|
||||
return 0
|
||||
}
|
||||
return (&buildrun.Places{Capacity: caps}).CapacityOf(node)
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
// capacityOf is node's capacity in a setting's `<node>=<asks>` pairs: DefaultCapacity for a node it does not name.
|
||||
func capacityOf(setting, node string) (int, error) {
|
||||
caps, err := buildrun.ParseCapacity(setting)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the build capacity: %w", err)
|
||||
}
|
||||
return (&buildrun.Places{Capacity: caps}).CapacityOf(node), nil
|
||||
}
|
||||
|
||||
+104
-9
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -64,10 +65,17 @@ type Puller struct {
|
||||
PullWait time.Duration
|
||||
Heartbeat time.Duration
|
||||
Logf func(format string, args ...any)
|
||||
// Load reads one key of the module's steps state (nil and no error when it is absent). When set, Pull reads the
|
||||
// node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with Put, so a pause outlives
|
||||
// a restart.
|
||||
// Load reads one key of the state the node's pause is kept in (nil and no error when it is absent). When set, Pull
|
||||
// reads the node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with PausePut, so a
|
||||
// pause outlives a restart.
|
||||
Load func(key string) ([]byte, error)
|
||||
// PausePut writes the node's pause key: the module's pauses state (delivery.PausesState), which has no age, so a
|
||||
// pause never ends by itself while the steps state drops its old keys. Put when nil.
|
||||
PausePut Put
|
||||
// WatchConfig, when set, follows the module's config value `capacity` (CapacitySetting) as mesh-config resolves it
|
||||
// for this node, and wakes the puller on each change (watchCapacity): WatchConfigServed in a module, a test's own
|
||||
// double. Until it hands a value the puller judges by Capacity.
|
||||
WatchConfig func(key string, handle func(stdio.ProvisionChange) error) error
|
||||
|
||||
mu sync.Mutex
|
||||
inHand int
|
||||
@@ -75,6 +83,9 @@ type Puller struct {
|
||||
paused bool
|
||||
wake chan struct{}
|
||||
once sync.Once
|
||||
// watched is the capacity the config watch handed last, used in place of Capacity once hasWatched.
|
||||
watched int
|
||||
hasWatched bool
|
||||
}
|
||||
|
||||
func (p *Puller) init() {
|
||||
@@ -113,7 +124,11 @@ func (p *Puller) setPaused(paused bool, by string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.Put(key, json.RawMessage(raw)); err != nil {
|
||||
put := p.PausePut
|
||||
if put == nil {
|
||||
put = p.Put
|
||||
}
|
||||
if _, err := put(key, json.RawMessage(raw)); err != nil {
|
||||
return fmt.Errorf("buildstep: writing the pause of %s: %w", p.Node, err)
|
||||
}
|
||||
}
|
||||
@@ -186,13 +201,29 @@ func (p *Puller) Running() []RunningAsk {
|
||||
|
||||
// room says whether the puller may take one more ask now.
|
||||
func (p *Puller) room() bool {
|
||||
capacity := 0
|
||||
if p.Capacity != nil {
|
||||
ok, _, _ := p.roomNow()
|
||||
return ok
|
||||
}
|
||||
|
||||
// roomNow is room, with the capacity and the asks in hand it was judged on.
|
||||
func (p *Puller) roomNow() (ok bool, capacity, inHand int) {
|
||||
p.mu.Lock()
|
||||
watched, has := p.watched, p.hasWatched
|
||||
p.mu.Unlock()
|
||||
if has {
|
||||
capacity = watched
|
||||
} else if p.Capacity != nil {
|
||||
capacity = p.Capacity()
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return !p.paused && p.inHand < capacity
|
||||
return !p.paused && p.inHand < capacity, capacity, p.inHand
|
||||
}
|
||||
|
||||
// CapacityNow is the capacity the puller judges by: the config watch's last value, else Capacity.
|
||||
func (p *Puller) CapacityNow() int {
|
||||
_, capacity, _ := p.roomNow()
|
||||
return capacity
|
||||
}
|
||||
|
||||
// Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream
|
||||
@@ -203,8 +234,11 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
return errors.New("buildstep: a puller needs its source, its steps' put and its run")
|
||||
}
|
||||
if err := p.restore(); err != nil {
|
||||
return err // pulling on a pause that cannot be read could build on a node paused on purpose
|
||||
// An unreadable pause degrades to not paused, said once: a puller that stops silently is the worse failure, and a
|
||||
// node kept from building is held by its capacity (0), not by a pause.
|
||||
p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err)
|
||||
}
|
||||
go p.watchCapacity() // WatchConfigServed waits for Serve, which a module starts after its pull
|
||||
wait := p.PullWait
|
||||
if wait <= 0 {
|
||||
wait = DefaultPullWait
|
||||
@@ -213,12 +247,17 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.room() {
|
||||
if ok, capacity, inHand := p.roomNow(); !ok {
|
||||
// Parked until something wakes it: a capacity change (the config watch), a resume, or a step's end. Every
|
||||
// park logs once, so a puller that sits idle says why (issue 1887).
|
||||
p.logf("the %s puller on %s parks: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-p.wake:
|
||||
}
|
||||
_, capacity, inHand = p.roomNow()
|
||||
p.logf("the %s puller on %s wakes: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
|
||||
continue
|
||||
}
|
||||
msg, err := p.next(ctx, wait)
|
||||
@@ -348,3 +387,59 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
p.logf("the ask %s ended on %s and its acknowledgement failed: %v", ask.ID, p.Node, aerr)
|
||||
}
|
||||
}
|
||||
|
||||
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: g14's
|
||||
// puller started at capacity 0 sat parked with capacity 2 set, because nothing woke it to read its settings file
|
||||
// again). The value comes as mesh-config resolved it for this node, the event of the change itself: the runtime hands
|
||||
// the value now and then each change its machine's provisioner client says moved, so nothing polls, and the puller does
|
||||
// not wait for the node-engine to fill its settings file again. Each value handed is kept, used in place of Capacity,
|
||||
// and wakes the puller. A watch that cannot start leaves the puller on Capacity, said once.
|
||||
func (p *Puller) watchCapacity() {
|
||||
if p.WatchConfig == nil {
|
||||
return
|
||||
}
|
||||
err := p.WatchConfig(CapacitySetting, func(c stdio.ProvisionChange) error {
|
||||
n, why := p.capacityFrom(c)
|
||||
p.mu.Lock()
|
||||
before, had := p.watched, p.hasWatched
|
||||
p.watched, p.hasWatched = n, true
|
||||
p.mu.Unlock()
|
||||
switch {
|
||||
case why != "":
|
||||
p.logf("the %s puller on %s pulls nothing: %s", p.Language, p.Node, why)
|
||||
case !had || before != n:
|
||||
p.logf("the %s build capacity of %s is %d, as mesh-config resolved it", p.Language, p.Node, n)
|
||||
}
|
||||
p.Wake()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logf("the %s build capacity of %s cannot be followed on the bus, so it is read from the settings file when the "+
|
||||
"puller wakes: %v", p.Language, p.Node, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WatchConfigServed is stdio.WatchConfig once Serve has the runtime's channel (stdio.Served): a build module starts its
|
||||
// pull before it serves, and a watch asked before that would be refused.
|
||||
func WatchConfigServed(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
<-stdio.Served()
|
||||
return stdio.WatchConfig(key, handle)
|
||||
}
|
||||
|
||||
// capacityFrom is the node's capacity in one value the watch handed, and why it is 0 when it cannot be read.
|
||||
func (p *Puller) capacityFrom(c stdio.ProvisionChange) (int, string) {
|
||||
if c.Refused != "" {
|
||||
return 0, "its capacity is no longer answered: " + c.Refused
|
||||
}
|
||||
setting := ""
|
||||
if len(c.Value) > 0 && string(c.Value) != "null" {
|
||||
if err := json.Unmarshal(c.Value, &setting); err != nil {
|
||||
return 0, fmt.Sprintf("its capacity %s is not a string", c.Value)
|
||||
}
|
||||
}
|
||||
n, err := capacityOf(setting, p.Node)
|
||||
if err != nil {
|
||||
return 0, err.Error()
|
||||
}
|
||||
return n, ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// configWatch is a double of stdio.WatchConfig: it hands the value now at the start, as the runtime does, and a test
|
||||
// hands each change after with set.
|
||||
type configWatch struct {
|
||||
mu sync.Mutex
|
||||
value string
|
||||
handle func(stdio.ProvisionChange) error
|
||||
asked string
|
||||
}
|
||||
|
||||
func (w *configWatch) watch(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
w.mu.Lock()
|
||||
w.asked, w.handle = key, handle
|
||||
value := w.value
|
||||
w.mu.Unlock()
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)})
|
||||
}
|
||||
|
||||
func (w *configWatch) set(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
w.mu.Lock()
|
||||
handle, key := w.handle, w.asked
|
||||
w.mu.Unlock()
|
||||
if handle == nil {
|
||||
t.Fatal("the puller watches no config value")
|
||||
}
|
||||
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+103
-29
@@ -3,11 +3,15 @@ package buildstep
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -127,11 +131,8 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) {
|
||||
}
|
||||
release <- struct{}{}
|
||||
waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
|
||||
mu.Lock()
|
||||
if started != 3 {
|
||||
t.Errorf("started %d, want 3", started)
|
||||
}
|
||||
mu.Unlock()
|
||||
// Taken from the source comes a moment before its run starts: wait for the start, not only the take.
|
||||
waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 })
|
||||
close(release)
|
||||
waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
|
||||
}
|
||||
@@ -178,36 +179,85 @@ func (k *kv) load(key string) ([]byte, error) {
|
||||
return k.vals[key], nil
|
||||
}
|
||||
|
||||
// 1887/A2b, 1887/AC-zero: at capacity 0 the puller takes nothing; a capacity raised takes effect at the next pull.
|
||||
// 1887/A2b, 1887/AC-zero, 1887/AC-wake: at capacity 0 the puller takes nothing; a capacity raised takes effect at the
|
||||
// next pull, or, with the config watch (g14 idle at capacity 2, 2026-10-25), at once when the value moves, with no
|
||||
// pause, resume or step end to wake it; a refused value pulls nothing, and a watch that cannot start leaves the
|
||||
// settings file's capacity.
|
||||
func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
||||
src, st := NewFakeAsks(), &kv{}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
var mu sync.Mutex
|
||||
capacity := 0
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node at capacity 0 took an ask")
|
||||
t.Run("settings file", func(t *testing.T) {
|
||||
src, st := NewFakeAsks(), &kv{}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
var mu sync.Mutex
|
||||
capacity := 0
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node at capacity 0 took an ask")
|
||||
}
|
||||
mu.Lock()
|
||||
capacity = 1
|
||||
mu.Unlock()
|
||||
p.Wake()
|
||||
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
})
|
||||
t.Run("config watch wakes", func(t *testing.T) {
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
w := &configWatch{value: "shanks=3,g14=0"}
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { return 0 }, // the settings file, not refilled yet
|
||||
WatchConfig: w.watch,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
waitFor(t, "the watch of the capacity", func() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.handle != nil })
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node at capacity 0 took an ask")
|
||||
}
|
||||
if w.asked != CapacitySetting {
|
||||
t.Fatalf("the puller watches %q, want %q", w.asked, CapacitySetting)
|
||||
}
|
||||
w.set(t, "shanks=3,g14=2")
|
||||
waitFor(t, "the ask taken once the capacity moved to 2", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
watch func(string, func(stdio.ProvisionChange) error) error
|
||||
room bool
|
||||
}{
|
||||
{"refused value leaves no room", func(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Refused: "the module is no longer on g14"})
|
||||
}, false},
|
||||
{"watch that cannot start keeps the settings file", func(string, func(stdio.ProvisionChange) error) error {
|
||||
return errors.New("no runtime")
|
||||
}, true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p := &Puller{Source: NewFakeAsks(), Language: delivery.AskGo, Node: "g14", Put: newSteps().put,
|
||||
Capacity: func() int { return 1 }, WatchConfig: c.watch,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
p.watchCapacity()
|
||||
if p.room() != c.room {
|
||||
t.Fatalf("room = %v, want %v", p.room(), c.room)
|
||||
}
|
||||
})
|
||||
}
|
||||
mu.Lock()
|
||||
capacity = 1
|
||||
mu.Unlock()
|
||||
p.Wake()
|
||||
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
}
|
||||
|
||||
// 1887/A2b: a paused puller takes nothing, its pause is kept on the pause key, and a puller started again on that state
|
||||
// stays paused until resumed.
|
||||
func TestPullPauseOutlivesARestart(t *testing.T) {
|
||||
src, st := NewFakeAsks(), &kv{}
|
||||
src, st, steps := NewFakeAsks(), &kv{}, &kv{}
|
||||
run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { return 2 }, Run: run}
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load,
|
||||
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run}
|
||||
if err := p.Pause("the operator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -216,9 +266,12 @@ func TestPullPauseOutlivesARestart(t *testing.T) {
|
||||
if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" {
|
||||
t.Fatalf("the pause key holds %s (%v)", raw, err)
|
||||
}
|
||||
if raw, _ := steps.load(key); raw != nil {
|
||||
t.Fatalf("the pause was written in the steps state, whose keys age out: %s", raw)
|
||||
}
|
||||
// The restart: a new puller on the same state.
|
||||
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { return 2 }, Run: run}
|
||||
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load,
|
||||
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -231,6 +284,27 @@ func TestPullPauseOutlivesARestart(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the ask taken after the resume", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
|
||||
t.Run("a pause that cannot be read is said and pulled as not paused", func(t *testing.T) {
|
||||
src := NewFakeAsks()
|
||||
var said atomic.Value
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put,
|
||||
Load: func(string) ([]byte, error) { return nil, errors.New("no runtime yet") },
|
||||
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run,
|
||||
Logf: func(f string, a ...any) {
|
||||
if m := fmt.Sprintf(f, a...); strings.Contains(m, "cannot be read") {
|
||||
said.Store(m)
|
||||
}
|
||||
}}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
waitFor(t, "the ask taken", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
if m, _ := said.Load().(string); !strings.Contains(m, "pulling as not paused") {
|
||||
t.Fatalf("the unreadable pause was not said: %q", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1887/A2c: a long step tells the bus it is still being worked; a holder that stops mid-step neither ends nor
|
||||
|
||||
@@ -14,11 +14,7 @@ func SeatTools(seat, what string, p *Puller) []stdio.Tool {
|
||||
Run: func(map[string]any) (any, error) { return run() }}
|
||||
}
|
||||
current := func() (any, error) {
|
||||
capacity := 0
|
||||
if p.Capacity != nil {
|
||||
capacity = p.Capacity()
|
||||
}
|
||||
return map[string]any{"node": p.Node, "capacity": capacity, "running": p.Running(), "paused": p.Paused()}, nil
|
||||
return map[string]any{"node": p.Node, "capacity": p.CapacityNow(), "running": p.Running(), "paused": p.Paused()}, nil
|
||||
}
|
||||
return []stdio.Tool{
|
||||
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity, "+
|
||||
|
||||
@@ -9,7 +9,26 @@ package catalogue
|
||||
// DeliverySeat is the seat mesh-delivery holds.
|
||||
const DeliverySeat = "mesh-delivery"
|
||||
|
||||
// deliveryVerbs are the delivery seat's tools: six that read, and the acts of a person, a reviewer and healer H2.
|
||||
// RemovedDeliveryVerbs are the delivery seat's verbs release 6 removed (novox/hq ADR 0539 clause 17): with no plan the
|
||||
// gate keeps the merge's states and derives nothing after the merge. Each stays in deliveryVerbs as an optional row
|
||||
// with no input, so the holder of before and the holder of release 6 both hold the seat and a caller is answered by
|
||||
// name with its replacement, given here.
|
||||
var RemovedDeliveryVerbs = map[string]string{
|
||||
"times": "the delivery view (nox-delivery): each machine's want and have, and since when",
|
||||
"what-if": "the merge check's composition, which composes every machine a change touches",
|
||||
"release": "the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view",
|
||||
"stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
|
||||
"retire-history": "nothing to retire: the gate keeps no delivery after its merge",
|
||||
}
|
||||
|
||||
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
|
||||
func deliveryRemovedInR6(name string) Verb {
|
||||
return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
|
||||
RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
|
||||
}
|
||||
|
||||
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
|
||||
// on a merge (ADR 0539 clause 17).
|
||||
func deliveryVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
||||
@@ -18,15 +37,7 @@ func deliveryVerbs() []Verb {
|
||||
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
||||
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
||||
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
||||
// Delivery time against the delivery budgets (novox/hq ADR 0282): what probe D16 reads, optional until its
|
||||
// holder serves it.
|
||||
{Name: "times", Description: "Delivery time: from a merge to every machine of its walk running the build. " +
|
||||
"Each class's delivery budget (a leaf module three minutes, a core module ten), the last days' median and " +
|
||||
"worst, how many within and over, the newest delivery of each class, every delivery over its budget with its " +
|
||||
"longest phase, and the delivered ones whose time is not known. Given a delivery's id, its time phase by phase.",
|
||||
Input: schema(map[string]string{"days": "how many days back (default 7)", "id": "one delivery's id: its phases"}, nil),
|
||||
// Optional until mesh-delivery serves it: its holder lives in the catalogue (design 33 §7).
|
||||
Optional: true},
|
||||
deliveryRemovedInR6("times"),
|
||||
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
||||
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
||||
"steps of its walk, its group and its order.",
|
||||
@@ -35,12 +46,7 @@ func deliveryVerbs() []Verb {
|
||||
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
|
||||
"state, derived from its members.",
|
||||
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
|
||||
{Name: "what-if", Description: "The delivery plan a change would have, asked of the controller's planner " +
|
||||
"and kept nowhere: the modules it moves and their dependents in tiers, what each machine would receive, " +
|
||||
"the steps that are not an ordinary send.",
|
||||
Input: schema(map[string]string{"repository": "owner/repository",
|
||||
"paths": "the files it changes, comma-separated, from the repository's root",
|
||||
"base": "the branch it merges into (default main)"}, []string{"repository", "paths"})},
|
||||
deliveryRemovedInR6("what-if"),
|
||||
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
|
||||
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
|
||||
"mesh/delivery, …) with its state, " +
|
||||
@@ -62,40 +68,13 @@ func deliveryVerbs() []Verb {
|
||||
"controller is asked for its check. With why.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||
[]string{"id", "why"})},
|
||||
{Name: "release", Description: "A person's word that a held delivery goes on: it starts delivering. With why.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||
[]string{"id", "why"})},
|
||||
{Name: "stop", Description: "Stop a delivery that is not final, with why; its walk is ended through the " +
|
||||
"controller, and in a group every member after it is stopped too, naming it.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("release"),
|
||||
deliveryRemovedInR6("stop"),
|
||||
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
|
||||
"past its bound. Refused for any other.",
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||||
[]string{"id", "why"})},
|
||||
{Name: "retire-history", Description: "Retire held deliveries that are history, on a person's word, with " +
|
||||
"why: each made from the forge's word for a merge on the trunk heard long after it happened — replayed " +
|
||||
"when the holder first held the seat — with no walk of it open. Never released, delivered or put back: " +
|
||||
"retired, final. One by id, refused when the rule does not take it; or every held one, of one " +
|
||||
"repository when named, answering how many it retired and naming those it left held. dry \"true\" " +
|
||||
"answers what it would do and changes nothing.",
|
||||
Input: schema(map[string]string{"why": "why, kept with every transition",
|
||||
"id": "one delivery's id; every held one when empty",
|
||||
"repository": "owner/repository: only its held deliveries",
|
||||
"dry": "\"true\": what it would retire, changing nothing"}, []string{"why"}, "dry"),
|
||||
// Added after the seat's holder shipped (novox/hq issue 313): optional until mesh-delivery serves it.
|
||||
Optional: true},
|
||||
{Name: "mark-ready", Description: "A reviewer's word that a delivery's head is approved: the holder puts the " +
|
||||
"label mesh/ready on its pull request and then writes \"ready at <head>\", the order the forge counts, " +
|
||||
"as the mesh's own forge account, naming who reviewed it and why. Refused when the delivery's commit is " +
|
||||
"no longer its pull request's head. mesh-delivery then merges it once its checks pass (novox/hq ADR 0364).",
|
||||
Input: schema(map[string]string{"id": "the delivery's id (owner/repository@commit): the head reviewed",
|
||||
"reviewed-by": "who reviewed it, as who@node", "why": "the review's verdict in a line"},
|
||||
[]string{"id", "reviewed-by", "why"}),
|
||||
// Kept one more release, optional (novox/hq issue 1022): mesh-delivery no longer serves it, but the manifest
|
||||
// it still has on the mesh does until its catalogue member merges, and a seat refuses a holder that serves
|
||||
// a verb it does not promise. The next release removes this entry.
|
||||
Optional: true},
|
||||
deliveryRemovedInR6("retire-history"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,107 +58,3 @@ func TestTheDeliverySeatPromisesChecks(t *testing.T) {
|
||||
t.Fatalf("a holder without %s: %v", before[0], err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat retires history on a person's word (novox/hq issue 313): `retire-history`, with why, one delivery by
|
||||
// id or every held one, dry to see first. Added after the holder shipped, it is optional, so the holder that
|
||||
// does not serve it yet still holds the seat, and one that does is not refused for a verb the seat lacks.
|
||||
func TestTheDeliverySeatPromisesRetireHistoryOptionally(t *testing.T) {
|
||||
seat, _ := SeatNamed(DeliverySeat)
|
||||
var retire *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "retire-history" {
|
||||
retire = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if retire == nil || !retire.Optional {
|
||||
t.Fatalf("the delivery seat promises %v, retire-history optionally", VerbNames(seat.Serves))
|
||||
}
|
||||
props, _ := retire.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"why", "id", "repository", "dry"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("retire-history takes no %q", arg)
|
||||
}
|
||||
}
|
||||
if req, _ := retire.Input["required"].([]string); !reflect.DeepEqual(req, []string{"why"}) {
|
||||
t.Errorf("retire-history requires %v, wanted only why", req)
|
||||
}
|
||||
var without []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "retire-history" {
|
||||
without = append(without, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without retire-history yet: %v", err)
|
||||
}
|
||||
m.Claims[0].Serves = VerbNames(seat.Serves)
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder serving retire-history: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat says delivery times (novox/hq ADR 0282, issue 382): `times`, over some days or for one delivery. Added
|
||||
// after the holder shipped, it is optional, so the holder that does not serve it yet still holds the seat, and one
|
||||
// that does is not refused for a verb the seat lacks.
|
||||
func TestTheDeliverySeatPromisesTimesOptionally(t *testing.T) {
|
||||
seat, _ := SeatNamed(DeliverySeat)
|
||||
var times *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "times" {
|
||||
times = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if times == nil || !times.Optional {
|
||||
t.Fatalf("the delivery seat promises %v, times optionally", VerbNames(seat.Serves))
|
||||
}
|
||||
props, _ := times.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"days", "id"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("times takes no %q", arg)
|
||||
}
|
||||
}
|
||||
var without []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "times" {
|
||||
without = append(without, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without times yet: %v", err)
|
||||
}
|
||||
m.Claims[0].Serves = VerbNames(seat.Serves)
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder serving times: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat still promises `mark-ready`, optionally, for one more release (issue 1022); it was added by novox/hq issue 913: `mark-ready`, with the delivery's id, who reviewed it
|
||||
// and why, so a reviewer marks a pull request through one audited verb, label and comment in the order the forge
|
||||
// counts, instead of two raw forge calls. Added after the holder shipped, it is optional.
|
||||
func TestTheDeliverySeatPromisesMarkReadyOptionally(t *testing.T) {
|
||||
seat, _ := SeatNamed(DeliverySeat)
|
||||
var mark *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "mark-ready" {
|
||||
mark = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if mark == nil || !mark.Optional {
|
||||
t.Fatalf("the delivery seat promises %v, mark-ready optionally", VerbNames(seat.Serves))
|
||||
}
|
||||
if req, _ := mark.Input["required"].([]string); !reflect.DeepEqual(req, []string{"id", "reviewed-by", "why"}) {
|
||||
t.Errorf("mark-ready requires %v, wanted id, reviewed-by and why", req)
|
||||
}
|
||||
var without []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "mark-ready" {
|
||||
without = append(without, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without mark-ready yet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +110,18 @@ var defaultSeats = append([]Seat{
|
||||
"rolled-back",
|
||||
// A pull request's merge check, judged (novox/hq to-be 45 §9).
|
||||
"checked",
|
||||
// A walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||
// A walk kept in a new state, and a tier sent to a machine: kept until the catalogue's main consumes them no
|
||||
// more; nothing emits them (ADR 0539).
|
||||
"plan-moved",
|
||||
// A tier sent to a machine, once the send is kept (novox/hq ADR 0406 §5, issue 974).
|
||||
"sent",
|
||||
// A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed"},
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed",
|
||||
// A builder's key not taken in because the artifact store does not hold an artifact it names (novox/hq
|
||||
// issue 1937): the builder builds it again.
|
||||
"artifacts-gone",
|
||||
// A bucket of a state this release deleted, deleted at the controller's first start (novox/hq ADR 0539
|
||||
// clause 13): the merge window's, the silent sends' and the building plans' hold, each said once.
|
||||
"bucket-deleted"},
|
||||
Serves: ControllerVerbs},
|
||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
||||
// served by whichever module holds the seat with tools of these names.
|
||||
|
||||
+55
-106
@@ -74,6 +74,29 @@ func VerbNames(verbs []Verb) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// RemovedControllerVerbs are the controller seat's verbs release 6 removed (novox/hq ADR 0539, ADR 0429 §8): no
|
||||
// delivery plan, batch, tier or push exists, so nothing starts, stops, retries, follows or reads one. Each stays in
|
||||
// ControllerVerbs as a row with no input, so a caller is answered by name with its replacement, given here.
|
||||
var RemovedControllerVerbs = map[string]string{
|
||||
"plans": "the delivery view (nox-delivery): each machine's want and have, read from the assigner's wants and the " +
|
||||
"node-engine's haves",
|
||||
"plan": "the node's compositions: mesh-controller_composed holds each module's digest and judgement per node, and " +
|
||||
"mesh-hand-compose prints one module's composition for one node",
|
||||
"push": "a want: the assigner writes want.<node>.<module> by its rules, and by hand the operator's credential writes " +
|
||||
"it, naming a kept composition (ADR 0539 clause 10)",
|
||||
"deliver": "nothing to start: a merged change reaches each machine by its want",
|
||||
"delivery-stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
|
||||
"delivery-backups-again": "the assigner's backup ask, answered on the phone or in the delivery view (ADR 0539 clause 16)",
|
||||
"delivery-walks": "the delivery view (nox-delivery) and the wants, haves and compositions on the bus",
|
||||
"delivery-order": "no order to keep: each module reaches its machines by its own want, its providers first by rule 5",
|
||||
}
|
||||
|
||||
// removedInR6 is the row of a verb release 6 removed from the controller's seat: no input, refused by name.
|
||||
func removedInR6(name string) Verb {
|
||||
return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
|
||||
RemovedControllerVerbs[name], Input: schema(nil, nil)}
|
||||
}
|
||||
|
||||
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
|
||||
const ControllerSeatName = "mesh-controller"
|
||||
|
||||
@@ -115,87 +138,23 @@ var ControllerVerbs = []Verb{
|
||||
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
|
||||
"limit": "how many builds to list (default 20); not with log",
|
||||
}, nil)},
|
||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id; " +
|
||||
"or, given wait, one plan followed until it moves.",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||
"wait": "a plan's id: wait until it moves, then answer it as data — the plan, and what ended the wait " +
|
||||
"(moved, tier, done, or bound); ask again to follow it further (novox/hq issue 229)",
|
||||
"until": "with wait: moved (any change, the default), tier (it reaches another tier) or done (it ends)",
|
||||
"bound": "with wait: how long to wait at most, as 15s or seconds — 20s, the default, is the longest, " +
|
||||
"inside what a call is answered within",
|
||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
||||
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
||||
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
||||
"go": "a walk's id that waits for its delivery's word: start it by hand, in place of mesh-delivery — with why, " +
|
||||
"recorded in the hand-act log (novox/hq ADR 0239)",
|
||||
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
|
||||
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
|
||||
"modules": "with repository: or the modules it would change, comma-separated",
|
||||
"module-dirs": "with repository and paths: the directories holding a module.json at the commit, comma-separated, " +
|
||||
"as the forge's announcer says them (novox/hq issue 278); without it, a directory is a module only when the paths hold its manifest",
|
||||
"data": "with repository and paths: true answers the change plan as data, the document delivery-plan answers " +
|
||||
"(moved, dependents, new, tiers, machines with what each receives and waits for, steps, summary, class, head, base); " +
|
||||
"without it the answer is text (novox/hq ADR 0450 §2)",
|
||||
"head": "with data: the commit at hand",
|
||||
"base": "with data: the branch it merges into (default main)",
|
||||
"removed": "with data: the files among paths the change deletes, comma-separated",
|
||||
"limit": "how many plans to list (default 10); only when listing",
|
||||
"why": "with stop, close or go: why it is done by hand — required, and recorded in the hand-act log (novox/hq to-be 45 §7)",
|
||||
"cause": "with stop, close or go: the cause in a word, or a condition's kind (optional)",
|
||||
}, nil, "data")},
|
||||
removedInR6("plans"),
|
||||
// The verbs the delivery's owner asks with (novox/hq ADR 0239): the planner's answer, a group's order and
|
||||
// composed check, and the start and end of a walk. Callable by a person too; mesh-delivery is who needs them.
|
||||
{Name: "delivery-plan", Description: "Removed in R4e (ADR 0501 §5 / ADR 0503 §3): refused by name, naming its replacement",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0419): its members in the order " +
|
||||
"they are delivered, every pair and why — built by, verb before caller, version skew, engine before " +
|
||||
"controller — read from the module graph and the manifests' change alone; no `after:` line is read. The " +
|
||||
"cycle, with every module and edge of it, is the one refusal.",
|
||||
Input: schema(map[string]string{"members": "the members, as JSON: [{id, repository, base, head, number, paths, " +
|
||||
"module_dirs, module_dirs_said, removed}]"}, []string{"members"})},
|
||||
removedInR6("delivery-order"),
|
||||
{Name: "delivery-check", Description: "Answers that the checker (mesh-delivery-checker) judges a group (novox/hq " +
|
||||
"ADR 0503 §3); asks nothing. Its inputs stay, since hq's repository check still names it (ADR 0513 §4).",
|
||||
Input: schema(map[string]string{"group": "the group's id (none for one head checked again)",
|
||||
"members": "the members, as JSON, as delivery-order takes them",
|
||||
"ready": "\"true\" when the pull request is marked ready: its check is queued before every other check (novox/hq ADR 0406 §3)"},
|
||||
[]string{"members"})},
|
||||
{Name: "deliver", Description: "The delivery's word that a walk waiting for it may start (novox/hq ADR 0239): its " +
|
||||
"first tier is asked at the controller's next pass. Refused for a walk that waits for nobody.",
|
||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "what lets it go: its turn, a person's release"},
|
||||
[]string{"plan"})},
|
||||
{Name: "delivery-stop", Description: "End a walk on its delivery's word (novox/hq ADR 0239): failed, said as stopped " +
|
||||
"by whom and why; what it asked still builds and registers, nothing further is asked or sent. Given merges to " +
|
||||
"leave out, every other merge the walk answered is walked again — in the open batch of its kind, or a new one — " +
|
||||
"without them, without each later merge of their repository, which contains one, and without each merge moving " +
|
||||
"a module one moves, each left out too and named (novox/hq ADR 0299); a batch not yet cut drops the merges. The " +
|
||||
"answer names the batch, the merges walked again, the merges left out, the modules the stopped walk sent that " +
|
||||
"stay on their machines, and the merges elsewhere that will still deliver a stopped change.",
|
||||
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery",
|
||||
"without": "merges to leave out, <repository>@<commit>, comma-separated"},
|
||||
[]string{"plan", "why"})},
|
||||
{Name: "delivery-backups-again", Description: "Ask again the backups a walk was held for (novox/hq ADR 0404 " +
|
||||
"decision 4): a walk whose backup before its send failed or did not answer is held, nothing asked or sent; this " +
|
||||
"clears those failed backups and the next look at the plans asks them again, and only them. Refused for a walk " +
|
||||
"with no failed backup. The delivery's release calls it. The answer names the walk and the backups asked again.",
|
||||
Input: schema(map[string]string{"plan": "the held walk's id", "why": "a person's reason, as release passes it",
|
||||
"by": "who released the delivery"}, []string{"plan", "why"})},
|
||||
{Name: "delivery-walks", Description: "The walks the controller keeps (novox/hq ADR 0239): every open one and the " +
|
||||
"last ended ones, each whole — its tiers, each module's state, first machines and first-node gate with its readings, and its " +
|
||||
"phases from its merge to every machine running it (novox/hq ADR 0282) — and whether the delivery seat has a " +
|
||||
"holder on record. Given a plan, that one.",
|
||||
Input: schema(map[string]string{"plan": "one walk's id", "limit": "how many ended walks beside the open ones (default 50)"},
|
||||
nil)},
|
||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it — " +
|
||||
"or, with files, the files it would be given; or, with diff, what a push would change there against what " +
|
||||
"it was last sent: resources added, removed and changed, a container's changed fields (it is recreated), " +
|
||||
"and any mount whose directory on the machine would move (novox/hq ADR 0217).",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name",
|
||||
"files": "\"true\": the files this machine would be given, instead of the declaration as JSON",
|
||||
"diff": "\"true\": what a push would change on this machine, against what it was last sent; not with files",
|
||||
}, []string{"node"}, "files", "diff")},
|
||||
removedInR6("deliver"),
|
||||
removedInR6("delivery-stop"),
|
||||
removedInR6("delivery-backups-again"),
|
||||
removedInR6("delivery-walks"),
|
||||
removedInR6("plan"),
|
||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there, " +
|
||||
"or when a seat its resources are applied through is held by nothing on the machine (novox/hq ADR 0207). " +
|
||||
"A module not registered yet is looked for (novox/hq issue 325, ADR 0261): while its build runs — a merge " +
|
||||
@@ -221,22 +180,7 @@ var ControllerVerbs = []Verb{
|
||||
}, []string{"node", "provision", "from", "module"})},
|
||||
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
|
||||
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
||||
{Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " +
|
||||
"WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " +
|
||||
"Answers at once that it is running, with a call id: `calls` with that id says what it sent and, for a " +
|
||||
"machine named, how that machine applied it — applied, refused with its reason, or each failed resource — " +
|
||||
"once it reports within wait (a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
|
||||
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " +
|
||||
"running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " +
|
||||
"`plan` with diff, and name the machine with move to send it.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name; without it, every machine that is behind",
|
||||
"move": "with node: the modules whose data may move with this push, comma-separated (optional)",
|
||||
"wait": "with node: how long the call waits for the machine to report how it applied what it was sent, as \"90s\" or \"2m\"; \"0\" waits for nothing; without it, the controller's setting push-wait (optional)",
|
||||
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
|
||||
"why": "why this is pushed by hand: recorded in the hand-act log",
|
||||
"cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)",
|
||||
}, []string{"why"}, "behind")},
|
||||
removedInR6("push"),
|
||||
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine and module, " +
|
||||
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
||||
"name: made anew and the machine sent, so the module starts again on it — for a secret its definition " +
|
||||
@@ -457,26 +401,22 @@ var ControllerVerbs = []Verb{
|
||||
"probe": "one probe's id (D14): its verdict alone, with every finding — the verdict shows at most twenty a probe",
|
||||
}, nil, "run", "probes", "signals")},
|
||||
// How a module's new builds reach its machines, and the bus's planned step (novox/hq ADR 0236).
|
||||
{Name: "upgrade", Description: "How each module's new builds reach its machines (novox/hq ADR 0236): rolled " +
|
||||
"out one machine first and judged there at the gate, then the rest — or recorded, waiting for a person's " +
|
||||
"push — with where that comes from (a person, the module, the bus, its irreplaceable data, the default) and " +
|
||||
"why. With module, that one; with policy, a person's choice for it — roll-out, record (with why) or default " +
|
||||
"to take the choice back. The bus is never rolled out.",
|
||||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person (novox/hq ADR 0236, ADR 0539 clause 14): " +
|
||||
"for a module of policy record, the assigner writes no want naming a composition judged to recreate a container or move " +
|
||||
"data until the operator answers its ask; with where the policy comes from (a person, the module, the bus, its " +
|
||||
"irreplaceable data, the default) and why. With module, that one; with policy, a person's choice for it — roll-out, " +
|
||||
"record (with why) or default to take the choice back. The bus is never moved by the assigner (ADR 0539 clause 15).",
|
||||
Input: schema(map[string]string{
|
||||
"module": "one module",
|
||||
"policy": "with module: roll-out, record or default",
|
||||
"together": "\"true\": with roll-out, every machine at once instead of one machine first",
|
||||
"why": "with policy or release-backlog: why — required for record and for a release, kept",
|
||||
"backlog": "\"true\": every build waiting for a gate, per machine — what a release plan would send",
|
||||
"release-backlog": "\"true\": release what waits now, one machine at a time, each judged — after a release " +
|
||||
"plan failed, the mesh waits for this; with why",
|
||||
"cause": "with release-backlog: the cause in a word (optional)",
|
||||
}, nil, "together", "backlog", "release-backlog")},
|
||||
{Name: "bus", Description: "The bus as a planned step (novox/hq to-be 45 §8, ADR 0236): what a bus upgrade " +
|
||||
"would do — the bus's build on each machine against the one the mesh holds — and how the last step went. " +
|
||||
"With upgrade, start one: a person's act with why, after the streams are snapshotted (snapshot-taken says " +
|
||||
"where, while the mesh takes none itself), saying first whether it can be reverted; bus-maintenance is open " +
|
||||
"while it runs and every stream, consumer and a round trip are checked after.",
|
||||
"module": "one module",
|
||||
"policy": "with module: roll-out, record or default",
|
||||
"why": "with policy: why — required for record, kept",
|
||||
}, nil)},
|
||||
{Name: "bus", Description: "The bus as a person's step (novox/hq to-be 45 §8, ADR 0236, ADR 0539 clause 15): what a bus " +
|
||||
"upgrade would do — the bus's build on each machine against the one the mesh holds — and how the last step went. " +
|
||||
"With upgrade, check one: a person's act with why, after the streams are snapshotted (snapshot-taken says where, " +
|
||||
"while the mesh takes none itself), saying first whether it can be reverted; then it answers the want the operator " +
|
||||
"writes with the operator's credential, want.<node>.<bus module>, recorded — nothing is sent, and the assigner never " +
|
||||
"moves the bus.",
|
||||
Input: schema(map[string]string{
|
||||
"upgrade": "\"true\": start the bus's upgrade",
|
||||
"why": "with upgrade: why — required, recorded in the hand-act log",
|
||||
@@ -566,6 +506,15 @@ var ControllerVerbs = []Verb{
|
||||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
||||
"collected": "\"true\": list the collected references too (they only grow; counted always)",
|
||||
}, nil, "collected")},
|
||||
// Whether the artifact store holds what a builder's done key names, before the builder reuses it (novox/hq issue
|
||||
// 1937): the store's collector deletes a blob nothing holds, and a key the controller never took in holds nothing.
|
||||
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
||||
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key, and " +
|
||||
"builds a key whose artifact is gone again. Reads only: nothing is held or written (novox/hq issue 1937).",
|
||||
Input: listed(schema(map[string]string{
|
||||
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
||||
"or one text separated by commas",
|
||||
}, []string{"references"}), "references")},
|
||||
{Name: "collect", Description: "The artifact store's sweep, now: what the mesh made and keeps for no reason " +
|
||||
"is let go of. Without confirm a dry run — every kept archive asked about with HEADs, what would be let go " +
|
||||
"listed, nothing held or deleted. With confirm and why: every kept archive held first, then each eligible " +
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1943/AC4: the controller's seat no longer offers a verb that starts, stops, retries, follows or reads a delivery
|
||||
// plan, nor a push: each is a row with no input whose description refuses it by name and names its replacement, and
|
||||
// each is in the removed list the holder answers from. upgrade keeps its policy and drops the plan's backlog.
|
||||
func TestTheControllerSeatHasNoPlanVerbs(t *testing.T) {
|
||||
rows := map[string]Verb{}
|
||||
for _, v := range ControllerVerbs {
|
||||
rows[v.Name] = v
|
||||
}
|
||||
gone := []string{"plans", "plan", "push", "deliver", "delivery-stop", "delivery-backups-again", "delivery-walks", "delivery-order"}
|
||||
for _, name := range gone {
|
||||
v, ok := rows[name]
|
||||
if !ok {
|
||||
t.Errorf("%s is not in the table, so a caller is not answered by name", name)
|
||||
continue
|
||||
}
|
||||
replacement, removed := RemovedControllerVerbs[name]
|
||||
if !removed || replacement == "" {
|
||||
t.Errorf("%s is not in the removed list with its replacement", name)
|
||||
}
|
||||
if !strings.HasPrefix(v.Description, "Removed in R6 (ADR 0539): refused by name") || !strings.Contains(v.Description, replacement) {
|
||||
t.Errorf("%s = %q, want refused by name, naming %q", name, v.Description, replacement)
|
||||
}
|
||||
if props, _ := v.Input["properties"].(map[string]any); len(props) > 0 {
|
||||
t.Errorf("%s still takes %v", name, props)
|
||||
}
|
||||
}
|
||||
props, _ := rows["upgrade"].Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"together", "backlog", "release-backlog", "cause"} {
|
||||
if _, has := props[arg]; has {
|
||||
t.Errorf("upgrade still takes %s, a plan's", arg)
|
||||
}
|
||||
}
|
||||
for _, arg := range []string{"module", "policy", "why"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("upgrade no longer takes %s", arg)
|
||||
}
|
||||
}
|
||||
for _, word := range []string{"rolled", "gate", "backlog", "push"} {
|
||||
if strings.Contains(rows["upgrade"].Description, word) {
|
||||
t.Errorf("upgrade's description still speaks of %q: %q", word, rows["upgrade"].Description)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rows["bus"].Description, "want") {
|
||||
t.Errorf("bus does not say the operator writes the bus's want: %q", rows["bus"].Description)
|
||||
}
|
||||
for name := range RemovedControllerVerbs {
|
||||
if v := rows[name]; !strings.HasPrefix(v.Description, "Removed in R6") {
|
||||
t.Errorf("%s is in the removed list and its row says %q", name, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC4: the delivery seat keeps the merge's verbs and no longer offers times, what-if, release, stop or
|
||||
// retire-history: each is a row with no input, optional so the holder of before and the holder of release 6 both hold
|
||||
// the seat, refusing it by name; mark-ready, served by nobody since issue 1022, is gone.
|
||||
func TestTheDeliverySeatHasNoPlanVerbs(t *testing.T) {
|
||||
seat, ok := SeatNamed(DeliverySeat)
|
||||
if !ok {
|
||||
t.Fatal("the delivery seat is not in the set")
|
||||
}
|
||||
rows := map[string]Verb{}
|
||||
for _, v := range seat.Serves {
|
||||
rows[v.Name] = v
|
||||
}
|
||||
for _, name := range []string{"times", "what-if", "release", "stop", "retire-history"} {
|
||||
v, ok := rows[name]
|
||||
replacement, removed := RemovedDeliveryVerbs[name]
|
||||
if !ok || !removed || replacement == "" {
|
||||
t.Errorf("%s: in the table %v, in the removed list %v", name, ok, removed)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(v.Description, "Removed in R6 (ADR 0539): refused by name") || !strings.Contains(v.Description, replacement) || !v.Optional {
|
||||
t.Errorf("%s = %q (optional %v), want an optional row refusing it by name", name, v.Description, v.Optional)
|
||||
}
|
||||
if props, _ := v.Input["properties"].(map[string]any); len(props) > 0 {
|
||||
t.Errorf("%s still takes %v", name, props)
|
||||
}
|
||||
}
|
||||
if _, ok := rows["mark-ready"]; ok {
|
||||
t.Errorf("mark-ready is still promised")
|
||||
}
|
||||
for _, kept := range []string{"deliveries", "show", "groups", "checks", "table", "recheck", "close", "stalled"} {
|
||||
if v, ok := rows[kept]; !ok || strings.HasPrefix(v.Description, "Removed") {
|
||||
t.Errorf("%s is not kept (ADR 0539 clause 17)", kept)
|
||||
}
|
||||
}
|
||||
gate := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh,
|
||||
Serves: []string{"deliveries", "show", "groups", "checks", "table", "stalled", "recheck", "close"}}}}
|
||||
if err := CanHold(gate, seat); err != nil {
|
||||
t.Errorf("the gate of release 6 cannot hold the seat: %v", err)
|
||||
}
|
||||
gate.Claims[0].Serves = append(gate.Claims[0].Serves, "times", "what-if", "release", "stop", "retire-history")
|
||||
if err := CanHold(gate, seat); err != nil {
|
||||
t.Errorf("the gate of release 5 cannot hold the seat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1943 (for 1945/AC14): the controller's seat says a bucket it deleted at its first start as its event, bucket-deleted
|
||||
// (ADR 0539 clause 13).
|
||||
func TestTheControllerSaysABucketItDeleted(t *testing.T) {
|
||||
seat, ok := SeatNamed(ControllerSeatName)
|
||||
if !ok {
|
||||
t.Fatal("no controller seat")
|
||||
}
|
||||
if !SeatSays(seat.Emits, "bucket-deleted") {
|
||||
t.Errorf("the controller's seat does not emit bucket-deleted: %v", seat.Emits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// mesh-compose is mesh-sdk's composer command (novox/hq ADR 0482 clause 4, issue 1621): it reads a composer.Request on
|
||||
// standard input, validates every manifest with this mesh-sdk's reader, composes the machines named in each case and
|
||||
// writes one composer.Verdict on standard output. Built at any commit of mesh-sdk (or at the mesh-sdk a module pins),
|
||||
// it answers what that commit's composition answers, so a program that links one mesh-sdk can have another's judged.
|
||||
//
|
||||
// mesh-compose < request.json > verdict.json
|
||||
// mesh-compose -request request.json > verdict.json
|
||||
// mesh-compose -catalogue <dir> [-node <name>]... [-depth resolve|compose] [-declarations] [< request.json]
|
||||
//
|
||||
// -catalogue reads <dir>/modules/*/module.json and a module.json at <dir>'s root as the first case's manifests (a
|
||||
// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to
|
||||
// compose; -declarations answers each machine's declared paths with their owners and modes.
|
||||
//
|
||||
// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue
|
||||
// directory, and reads
|
||||
// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error);
|
||||
// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
type nodes []string
|
||||
|
||||
func (n *nodes) String() string { return strings.Join(*n, ",") }
|
||||
func (n *nodes) Set(v string) error { *n = append(*n, v); return nil }
|
||||
|
||||
// run is the command: its arguments, standard input, output and error; it answers the exit code.
|
||||
func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
catalogueDir := fs.String("catalogue", "", "a catalogue checkout: <dir>/modules/*/module.json are the first case's manifests")
|
||||
var machines nodes
|
||||
fs.Var(&machines, "node", "a machine to compose (repeatable)")
|
||||
depth := fs.String("depth", "", "resolve or compose (default: the request's, else compose)")
|
||||
declarations := fs.Bool("declarations", false, "answer each composed machine's declared paths, owners and modes")
|
||||
requestFile := fs.String("request", "", "read the request from this file instead of standard input (a run step that hands no standard input)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
fmt.Fprintf(stderr, "mesh-compose: unexpected arguments %q\n", fs.Args())
|
||||
return 2
|
||||
}
|
||||
var raw []byte
|
||||
var err error
|
||||
if *requestFile != "" {
|
||||
raw, err = os.ReadFile(*requestFile)
|
||||
} else {
|
||||
raw, err = io.ReadAll(stdin)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose: the request cannot be read:", err)
|
||||
return 1
|
||||
}
|
||||
var req composer.Request
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
if *catalogueDir == "" {
|
||||
fmt.Fprintln(stderr, "mesh-compose: no request on standard input and no -catalogue")
|
||||
return 1
|
||||
}
|
||||
req = composer.Request{V: composer.Version}
|
||||
} else if req, err = composer.Decode(raw); err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose:", err)
|
||||
return 1
|
||||
}
|
||||
if *catalogueDir != "" {
|
||||
read, err := readCatalogue(*catalogueDir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose:", err)
|
||||
return 1
|
||||
}
|
||||
if len(req.Cases) == 0 {
|
||||
req.Cases = []composer.Case{{Name: "catalogue", Manifests: map[string]composer.Manifest{}}}
|
||||
}
|
||||
if req.Cases[0].Manifests == nil {
|
||||
req.Cases[0].Manifests = map[string]composer.Manifest{}
|
||||
}
|
||||
for name, m := range read {
|
||||
if _, given := req.Cases[0].Manifests[name]; !given {
|
||||
req.Cases[0].Manifests[name] = m
|
||||
}
|
||||
}
|
||||
// The catalogue's list of predefined keys is the one its manifests' fallbacks are judged against (ADR 0348 §7).
|
||||
if req.Predefined == nil {
|
||||
list, err := catalogue.ReadPredefined(*catalogueDir)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose:", err)
|
||||
return 1
|
||||
}
|
||||
req.Predefined = list
|
||||
}
|
||||
}
|
||||
req.Machines = append(req.Machines, machines...)
|
||||
if *depth != "" {
|
||||
req.Depth = *depth
|
||||
}
|
||||
if *declarations {
|
||||
req.Declarations = true
|
||||
}
|
||||
v, err := composer.Run(req)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose:", err)
|
||||
return 1
|
||||
}
|
||||
enc := json.NewEncoder(stdout)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-compose: the verdict cannot be written:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of
|
||||
// modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said.
|
||||
func readCatalogue(dir string) (map[string]composer.Manifest, error) {
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "modules", "*", "module.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "module.json")); err == nil {
|
||||
paths = append(paths, filepath.Join(dir, "module.json"))
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, fmt.Errorf("the catalogue %s holds no module.json", dir)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
out := map[string]composer.Manifest{}
|
||||
for _, p := range paths {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var probe struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
name := filepath.Base(filepath.Dir(p))
|
||||
if json.Unmarshal(raw, &probe) == nil && probe.Module != "" {
|
||||
name = probe.Module
|
||||
}
|
||||
if _, twice := out[name]; twice {
|
||||
return nil, errors.New("the catalogue " + dir + " defines " + name + " twice")
|
||||
}
|
||||
out[name] = composer.Manifest{Raw: raw}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go/build"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
)
|
||||
|
||||
// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name.
|
||||
func captured(t *testing.T) map[string]composer.Manifest {
|
||||
t.Helper()
|
||||
read, err := readCatalogue(filepath.Dir(beside.Catalogue(t)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(read) < 100 {
|
||||
t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read))
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner
|
||||
// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a
|
||||
// desktop's modules, ace a module whose requirement is provided elsewhere.
|
||||
func meshOf() compose.Input {
|
||||
return compose.Input{Nodes: []compose.Node{
|
||||
{Name: "anchor", Assigned: []string{"nats", "postgres"}},
|
||||
{Name: "laptop", Assigned: []string{"sudo", "zsh"}},
|
||||
{Name: "ace", Assigned: []string{"mesh-reads", "nats"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func request(t *testing.T, depth string) composer.Request {
|
||||
t.Helper()
|
||||
return composer.Request{V: composer.Version, Input: meshOf(), Depth: depth, HoldingsFromClaims: true,
|
||||
Machines: []string{"anchor", "laptop", "ace"},
|
||||
Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}}
|
||||
}
|
||||
|
||||
func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) {
|
||||
t.Helper()
|
||||
var out, errOut bytes.Buffer
|
||||
code := run(args, bytes.NewReader(in), &out, &errOut)
|
||||
var v composer.Verdict
|
||||
if code == 0 {
|
||||
if err := json.Unmarshal(out.Bytes(), &v); err != nil {
|
||||
t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
}
|
||||
return code, v, errOut.String()
|
||||
}
|
||||
|
||||
func encode(t *testing.T, r composer.Request) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes
|
||||
// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field
|
||||
// refused.
|
||||
func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) {
|
||||
r := request(t, composer.DepthResolve)
|
||||
r.Cases[0].Manifests["broken"] = composer.Manifest{Raw: json.RawMessage(`{"module":"broken","version":"1","shares":["x"]}`)}
|
||||
code, v, stderr := runJSON(t, nil, encode(t, r))
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
}
|
||||
if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
c := v.Cases[0]
|
||||
if got := c.Manifests["broken"]; got.Accepted || !strings.Contains(got.Refused, `"shares"`) {
|
||||
t.Errorf("the manifest with an unknown field reads %+v, want refused naming the field", got)
|
||||
}
|
||||
if got := c.Manifests["nats"]; !got.Accepted {
|
||||
t.Errorf("nats reads %+v, want accepted", got)
|
||||
}
|
||||
if len(c.Machines) != 3 {
|
||||
t.Fatalf("machines = %v, want the three named", c.Machines)
|
||||
}
|
||||
for name, m := range c.Machines {
|
||||
if !m.Composes && m.Reason == "" {
|
||||
t.Errorf("%s does not compose and says no reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for
|
||||
// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths.
|
||||
func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) {
|
||||
for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} {
|
||||
r := request(t, depth)
|
||||
code, v, stderr := runJSON(t, nil, encode(t, r))
|
||||
if code != 0 {
|
||||
t.Fatalf("%s: exit %d: %s", depth, code, stderr)
|
||||
}
|
||||
in := meshOf()
|
||||
in.Catalogue = map[string]catalogue.Manifest{}
|
||||
for name, m := range r.Cases[0].Manifests {
|
||||
// A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined).
|
||||
parsed, err := catalogue.ParseManifestForInputs(m.Raw)
|
||||
got := v.Cases[0].Manifests[name]
|
||||
if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) {
|
||||
t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got)
|
||||
}
|
||||
if err == nil {
|
||||
in.Catalogue[name] = parsed
|
||||
}
|
||||
}
|
||||
in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes)
|
||||
composed := 0
|
||||
for _, node := range r.Machines {
|
||||
got := v.Cases[0].Machines[node]
|
||||
var want composer.Machine
|
||||
if depth == composer.DepthResolve {
|
||||
if _, err := compose.Resolve(in, node); err != nil {
|
||||
want = composer.Machine{Reason: err.Error()}
|
||||
} else {
|
||||
want = composer.Machine{Composes: true}
|
||||
}
|
||||
} else {
|
||||
d, problems, err := compose.Compose(in, node)
|
||||
switch {
|
||||
case err != nil:
|
||||
want = composer.Machine{Reason: err.Error()}
|
||||
default:
|
||||
want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
|
||||
if len(problems) > 0 {
|
||||
want.Reason = "the node-engine's validator refuses the declaration"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want)
|
||||
}
|
||||
if got.Composes {
|
||||
composed++
|
||||
} else {
|
||||
t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines))
|
||||
}
|
||||
}
|
||||
|
||||
// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches
|
||||
// nothing) for a node and reads its declaration: each path with its kind, owner and mode.
|
||||
func TestACatalogueCheckoutIsComposedForANode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` +
|
||||
`"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := encode(t, composer.Request{V: composer.Version, Input: compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}}})
|
||||
code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
}
|
||||
m := v.Cases[0].Machines["laptop"]
|
||||
want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}}
|
||||
if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" ||
|
||||
m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" {
|
||||
t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A run step hands the command no standard input: the request is read from the file -request names, and a file that is
|
||||
// not there is a request it cannot read.
|
||||
func TestTheRequestIsReadFromAFile(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "request.json")
|
||||
r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{
|
||||
"demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}}
|
||||
if err := os.WriteFile(file, encode(t, r), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, v, stderr := runJSON(t, []string{"-request", file}, nil)
|
||||
if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted {
|
||||
t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v)
|
||||
}
|
||||
if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") {
|
||||
t.Errorf("a request file that is not there: exit %d, %q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error.
|
||||
func TestTheExitCodesSayWhatFailed(t *testing.T) {
|
||||
unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
args []string
|
||||
in string
|
||||
code int
|
||||
says string
|
||||
}{
|
||||
{"not JSON", nil, "{", 1, "not JSON"},
|
||||
{"another version", nil, `{"v":2}`, 1, "version 2"},
|
||||
{"a digest left unfilled", nil, string(unfilled), 1, "digest"},
|
||||
{"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"},
|
||||
{"nothing to read", nil, "", 1, "no request"},
|
||||
{"a wrong flag", []string{"-nope"}, "", 2, "nope"},
|
||||
{"an argument", []string{"extra"}, "", 2, "unexpected"},
|
||||
} {
|
||||
var out, errOut bytes.Buffer
|
||||
if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) {
|
||||
t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sdkPackages is every package of this module the command imports, directly or not, with the packages each imports.
|
||||
func sdkPackages(t *testing.T) map[string][]string {
|
||||
t.Helper()
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
root := filepath.Join(filepath.Dir(file), "..", "..")
|
||||
const module = "git.novox.be/novox/mesh-sdk/go"
|
||||
out := map[string][]string{}
|
||||
var walk func(path string)
|
||||
walk = func(path string) {
|
||||
if _, done := out[path]; done {
|
||||
return
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/")
|
||||
pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
out[path] = pkg.Imports
|
||||
for _, imp := range pkg.Imports {
|
||||
if strings.HasPrefix(imp, module+"/") {
|
||||
walk(imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(composer.Package)
|
||||
return out
|
||||
}
|
||||
|
||||
// 1621/AC2 and AC4: the command imports, through every package of mesh-sdk it reaches, only the standard library and
|
||||
// mesh-sdk's own packages (so `go build` from mesh-sdk's module needs nothing beyond its lock), and none that reaches
|
||||
// a bus, a store, a network service or another process.
|
||||
func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
|
||||
forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"}
|
||||
for path, imports := range sdkPackages(t) {
|
||||
for _, imp := range imports {
|
||||
std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
|
||||
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go/") {
|
||||
t.Errorf("%s imports %s, which is neither the standard library nor mesh-sdk", path, imp)
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if imp == f {
|
||||
t.Errorf("%s imports %s", path, imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test
|
||||
// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox
|
||||
// allows no new namespace (a build module's run step is already one with no network), it says so and skips.
|
||||
func TestTheCommandRunsWithNoNetwork(t *testing.T) {
|
||||
if os.Getenv("MESH_COMPOSE_HELPER") == "1" {
|
||||
os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
unshare, err := exec.LookPath("unshare")
|
||||
if err != nil {
|
||||
t.Skip("no unshare on this machine, so no empty network namespace can be made here")
|
||||
}
|
||||
if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil {
|
||||
t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out)
|
||||
}
|
||||
r := request(t, composer.DepthResolve)
|
||||
cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$")
|
||||
cmd.Env = []string{"MESH_COMPOSE_HELPER=1"}
|
||||
cmd.Stdin = bytes.NewReader(encode(t, r))
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String())
|
||||
}
|
||||
var v composer.Verdict
|
||||
if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 {
|
||||
t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String())
|
||||
}
|
||||
names := make([]string, 0, len(v.Cases[0].Machines))
|
||||
for n := range v.Cases[0].Machines {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
t.Logf("composed with no network: %v", names)
|
||||
}
|
||||
|
||||
// -catalogue judges a fallback to a predefined key against the predefined.json at the checkout's root (ADR 0348 §7):
|
||||
// xterm (mesh-catalog at 835bf22b) falls back to "font-size", accepted where the root lists it and refused where the
|
||||
// root's list does not.
|
||||
func TestACatalogueCheckoutJudgesFallbacksAgainstItsPredefinedKeys(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
list string
|
||||
want bool
|
||||
}{
|
||||
{`{"font-size": {"type": "number", "description": "the desktop's font size"}}`, true},
|
||||
{`{"font-family": {"type": "string", "description": "the desktop's font"}}`, false},
|
||||
} {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "modules", "xterm"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "modules", "xterm", "module.json"), raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, catalogue.PredefinedFile), []byte(c.list), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, v, stderr := runJSON(t, []string{"-catalogue", dir}, nil)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
}
|
||||
if got := v.Cases[0].Manifests["xterm"]; got.Accepted != c.want {
|
||||
t.Errorf("predefined.json %s: xterm %+v, want accepted %v", c.list, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1,209 @@
|
||||
// mesh-hand-compose composes one module for one node by hand, from the inputs the controller keeps, and signs it with the
|
||||
// operator's key, for the hand path of novox/hq ADR 0539 clause 10: a fingerprint the controller never composed (a
|
||||
// hand build), while the controller cannot compose its own fix. It composes with mesh-sdk's composition, the one the
|
||||
// controller writes with (compose.Compositions), and prints the composition, its key and its digest; only with -write
|
||||
// does it write them, with the operator's credential: the composition into the node's bucket composed-<node>, each
|
||||
// file over 64 KiB into the node's object bucket, and the digest into mesh-controller_composed. The want is the
|
||||
// operator's next act, naming that digest; each act is recorded in the hand-act log.
|
||||
//
|
||||
// mesh-hand-compose -input <inputs.json> -node <node> -module <module> -fp <src2:...> -key <operator key>
|
||||
// [-write -nats <url> -creds <operator credential>]
|
||||
//
|
||||
// The inputs are a compose.Input as JSON. The key is the operator's ed25519 private key, PKCS#8 in PEM; the
|
||||
// node-engine trusts it beside the controller's (ADR 0539 clause 3).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mesh-hand-compose:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// printed is what mesh-hand-compose prints: where the composition goes, its digest, the composition, and whether it was
|
||||
// written.
|
||||
type printed struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
Digest string `json:"digest"`
|
||||
Composition json.RawMessage `json:"composition"`
|
||||
Objects []string `json:"objects,omitempty"`
|
||||
Written bool `json:"written"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
func run(args []string, stdout io.Writer) error {
|
||||
fs := flag.NewFlagSet("mesh-hand-compose", flag.ContinueOnError)
|
||||
input := fs.String("input", "", "the controller's kept inputs, a compose.Input as JSON")
|
||||
node := fs.String("node", "", "the node to compose for")
|
||||
module := fs.String("module", "", "the module to compose")
|
||||
fp := fs.String("fp", "", "the module's fingerprint, src2: and 64 hex digits")
|
||||
keyFile := fs.String("key", "", "the operator's ed25519 private key, PKCS#8 in PEM")
|
||||
write := fs.Bool("write", false, "write the composition, its files and its digest, with the operator's credential")
|
||||
url := fs.String("nats", nats.DefaultURL, "with -write: the bus")
|
||||
creds := fs.String("creds", "", "with -write: the operator's credential file")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case *input == "" || *node == "" || *module == "" || *fp == "":
|
||||
return errors.New("-input, -node, -module and -fp are each required")
|
||||
case *keyFile == "":
|
||||
return errors.New("-key is required: a composition made by hand is signed by the operator's key, and the node-engine applies no other")
|
||||
case *write && *creds == "":
|
||||
return errors.New("-write needs -creds: the operator's credential is the only hand writer of a composition")
|
||||
}
|
||||
key, err := readKey(*keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key2, err := delivery.ComposedKey(*node, *module, *fp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bucket, err := delivery.ComposedBucketFor(*node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(*input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in compose.Input
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return fmt.Errorf("the inputs in %s are not a compose.Input: %w", *input, err)
|
||||
}
|
||||
all, _, err := compose.Compositions(in, *node, map[string]string{*module: *fp}, compose.Reading)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, ok := all[key2]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not run %s: its composition holds none of it", *node, *module)
|
||||
}
|
||||
c, objects, err := c.ByDigest()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c, err = delivery.SignComposed(c, key); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := delivery.EncodeComposed(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest, err := delivery.ComposedDigestOf(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := printed{Bucket: bucket, Key: key2, Digest: digest, Composition: encoded,
|
||||
Next: fmt.Sprintf("write want.%s.%s naming fp %s and composed %s with the operator's credential, and record each act in the hand-act log", *node, *module, *fp, digest)}
|
||||
for d := range objects {
|
||||
out.Objects = append(out.Objects, d)
|
||||
}
|
||||
if *write {
|
||||
if err := writeIt(*url, *creds, *node, bucket, key2, encoded, digest, objects); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Written = true
|
||||
}
|
||||
enc := json.NewEncoder(stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func readKey(path string) (ed25519.PrivateKey, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%s holds no PEM block", path)
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not an ed25519 key", path)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// writeIt writes the files by digest, then the composition, then its digest: a reader that finds the digest finds the
|
||||
// composition and its files already there.
|
||||
func writeIt(url, creds, node, bucket, key string, encoded []byte, digest string, objects map[string][]byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
nc, err := nats.Connect(url, nats.UserCredentials(creds), nats.Name("mesh-hand-compose"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer nc.Close()
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(objects) > 0 {
|
||||
filesBucket, err := declaration.FileBucketFor(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := js.ObjectStore(ctx, filesBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the node's object bucket %s: %w", filesBucket, err)
|
||||
}
|
||||
for d, body := range objects {
|
||||
name, err := declaration.ObjectName(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := store.PutBytes(ctx, name, body); err != nil {
|
||||
return fmt.Errorf("the file %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
composed, err := js.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", bucket, err)
|
||||
}
|
||||
if _, err := composed.Put(ctx, key, encoded); err != nil {
|
||||
return fmt.Errorf("%s in %s: %w", key, bucket, err)
|
||||
}
|
||||
value, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot,
|
||||
Words: "composed by hand with mesh-hand-compose (ADR 0539 clause 10): the operator writes the want"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digests, err := js.KeyValue(ctx, delivery.ControllerComposedBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the bucket %s: %w", delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
if _, err := digests.Put(ctx, key, value); err != nil {
|
||||
return fmt.Errorf("%s in %s: %w", key, delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// aMesh is the Input of mesh-controller's aMesh, as mesh-sdk's compose tests make it (compose/fixture_test.go): two
|
||||
// machines on the private network, the module the mesh provides for itself assigned to both. The manifest is the one
|
||||
// the controller provides (overlay.Manifest), read back as the catalogue reads it.
|
||||
func aMesh(t *testing.T) compose.Input {
|
||||
t.Helper()
|
||||
var m catalogue.Manifest
|
||||
b, _ := json.Marshal(overlay.Manifest())
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: m}}
|
||||
for i, name := range []string{"anchor", "laptop"} {
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
in.Nodes = append(in.Nodes, compose.Node{Name: name,
|
||||
Place: compose.Place{Key: base64.StdEncoding.EncodeToString(pub), Endpoint: name + ".example:51820", Site: "here", Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]},
|
||||
Assigned: []string{overlay.Name}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func writeKey(t *testing.T, dir string) ed25519.PublicKey {
|
||||
t.Helper()
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "operator.key"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub
|
||||
}
|
||||
|
||||
// 1943/AC5: mesh-hand-compose, given the kept inputs, a node, a module and its fingerprint, prints that module's
|
||||
// composition for that node signed by the operator's key and its digest: its resources are the controller's for that
|
||||
// module on that node (compose.Compose), and it verifies against the operator's key. Without --write it writes nothing.
|
||||
func TestMeshComposeGivesTheControllersResourcesSignedByTheOperator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
in := aMesh(t)
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := filepath.Join(dir, "input.json")
|
||||
if err := os.WriteFile(input, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operator := writeKey(t, dir)
|
||||
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := run([]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp,
|
||||
"-key", filepath.Join(dir, "operator.key")}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var printed struct {
|
||||
Key string `json:"key"`
|
||||
Bucket string `json:"bucket"`
|
||||
Digest string `json:"digest"`
|
||||
Composition json.RawMessage `json:"composition"`
|
||||
Written bool `json:"written"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &printed); err != nil {
|
||||
t.Fatalf("%s: %v", out.String(), err)
|
||||
}
|
||||
c, err := delivery.DecodeComposed(printed.Composition)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if digest, err := delivery.VerifyComposed(c, []ed25519.PublicKey{operator}); err != nil || digest != printed.Digest {
|
||||
t.Errorf("the composition verifies %q, %v against the operator's key; printed %s", digest, err, printed.Digest)
|
||||
}
|
||||
wantKey, _ := delivery.ComposedKey("laptop", overlay.Name, fp)
|
||||
if printed.Key != wantKey || printed.Bucket != "composed-laptop" || printed.Written {
|
||||
t.Errorf("printed key %s in %s, written %v", printed.Key, printed.Bucket, printed.Written)
|
||||
}
|
||||
|
||||
d, _, err := compose.Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want, got []string
|
||||
for _, r := range d.Resources {
|
||||
want = append(want, r["id"].(string))
|
||||
}
|
||||
for _, r := range c.Resources {
|
||||
var head struct{ ID string }
|
||||
_ = json.Unmarshal(r, &head)
|
||||
got = append(got, head.ID)
|
||||
}
|
||||
sort.Strings(want)
|
||||
sort.Strings(got)
|
||||
if len(got) == 0 || strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("mesh-hand-compose gives %v, the controller's composition %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC5: mesh-hand-compose refuses what it cannot compose, naming why: no key, a fingerprint not of the new form, a
|
||||
// module the node does not run, a node the mesh does not have.
|
||||
func TestMeshComposeRefuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
raw, _ := json.Marshal(aMesh(t))
|
||||
input := filepath.Join(dir, "input.json")
|
||||
_ = os.WriteFile(input, raw, 0o600)
|
||||
writeKey(t, dir)
|
||||
key := filepath.Join(dir, "operator.key")
|
||||
fp := delivery.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
for _, c := range []struct {
|
||||
args []string
|
||||
why string
|
||||
}{
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp}, "-key"},
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", "src2:1", "-key", key}, "fingerprint"},
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", "mesh-mail", "-fp", fp, "-key", key}, "does not run mesh-mail"},
|
||||
{[]string{"-input", input, "-node", "nowhere", "-module", overlay.Name, "-fp", fp, "-key", key}, "nowhere"},
|
||||
{[]string{"-input", input, "-node", "laptop", "-module", overlay.Name, "-fp", fp, "-key", key, "-write"}, "-creds"},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
if err := run(c.args, &out); err == nil || !strings.Contains(err.Error(), c.why) {
|
||||
t.Errorf("mesh-hand-compose %v = %v, want a refusal saying %q", c.args, err, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
|
||||
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
|
||||
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
|
||||
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
|
||||
// (mesh-hand-compose) gets that one. Each module's Data says whether its manifest declares data. The compositions are not
|
||||
// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest).
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
found := false
|
||||
for i := range reading.Nodes {
|
||||
if reading.Nodes[i].Name == node {
|
||||
reading.Nodes[i].ReadsModules = true
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
}
|
||||
d, problems, err := ComposeIn(reading, node, mode)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
Adoption *declaration.Adoption `json:"adoption"`
|
||||
Filled []string `json:"filled_files"`
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var kept []json.RawMessage
|
||||
keptPaths := map[string]bool{}
|
||||
left := map[string]bool{}
|
||||
for _, raw := range envelope.Resources {
|
||||
var head struct {
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, ok := fps[head.Module]; head.Module != "" && !ok {
|
||||
left[head.Module] = true
|
||||
continue
|
||||
}
|
||||
if head.Type == string(declaration.TypeFile) {
|
||||
keptPaths[head.Path] = true
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
var filled []string
|
||||
for _, p := range envelope.Filled {
|
||||
if keptPaths[p] {
|
||||
filled = append(filled, p)
|
||||
}
|
||||
}
|
||||
out, err = delivery.SplitComposed(node, kept, fps, filled,
|
||||
delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for key, c := range out {
|
||||
if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
|
||||
c.Data = len(m.DataItems()) > 0
|
||||
out[key] = c
|
||||
}
|
||||
}
|
||||
for m := range left {
|
||||
skipped = append(skipped, m)
|
||||
}
|
||||
sort.Strings(skipped)
|
||||
return out, skipped, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
|
||||
// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs
|
||||
// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on
|
||||
// it; a module given no fingerprint is named as skipped and composed nowhere.
|
||||
func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
in := aSmallMesh(t)
|
||||
d, _, err := Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want []string
|
||||
for _, r := range d.Resources {
|
||||
want = append(want, r["type"].(string)+":"+r["id"].(string))
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')}
|
||||
out, skipped, err := Compositions(in, "laptop", fps, Foreseeing)
|
||||
if err != nil || len(skipped) != 0 {
|
||||
t.Fatalf("Compositions = %v, skipped %v", err, skipped)
|
||||
}
|
||||
var got []string
|
||||
for key, c := range out {
|
||||
node, module, fp, isNode, err := delivery.ParseComposedKey(key)
|
||||
if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) {
|
||||
t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err)
|
||||
}
|
||||
if !isNode && fp != fps[module] {
|
||||
t.Errorf("%s is composed at %s, given %s", module, fp, fps[module])
|
||||
}
|
||||
if _, err := delivery.EncodeComposed(c); err != nil {
|
||||
t.Errorf("%s: %v", key, err)
|
||||
}
|
||||
for _, raw := range c.Resources {
|
||||
var r struct{ ID, Type, Module string }
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if r.Module != module {
|
||||
t.Errorf("%s holds %s of %q", key, r.ID, r.Module)
|
||||
}
|
||||
got = append(got, r.Type+":"+r.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("the compositions hold %v, the declaration %v", got, want)
|
||||
}
|
||||
|
||||
one, skipped, err := Compositions(in, "laptop", map[string]string{"files": fpFor('b')}, Foreseeing)
|
||||
if err != nil || strings.Join(skipped, ",") != "mesh-wireguard" {
|
||||
t.Fatalf("one module: %v, skipped %v", err, skipped)
|
||||
}
|
||||
for _, c := range one {
|
||||
if c.Module == "mesh-wireguard" {
|
||||
t.Errorf("a module given no fingerprint was composed")
|
||||
}
|
||||
}
|
||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||
t.Errorf("a node the mesh does not have was composed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||
func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) {
|
||||
for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} {
|
||||
for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} {
|
||||
v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from}
|
||||
if _, err := delivery.EncodeComposedDigest(v); err != nil {
|
||||
t.Errorf("%s from %s: %v", p, from, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if delivery.PolicyRecord != catalogue.PolicyRecord {
|
||||
t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
// Package composer is mesh-sdk's composition and validator as one function over a plain request (novox/hq ADR 0482
|
||||
// clause 4, issue 1621): what the command mesh-compose runs, so a program that links any one mesh-sdk can have the
|
||||
// composition of another commit judged by building that commit's command and reading its verdict.
|
||||
//
|
||||
// **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the
|
||||
// request (raw, as their repository holds them), the rest of the mesh is a compose.Input, and the answer is a value.
|
||||
// A manifest named only by its digest in the delivery indexer's bucket is filled before the run by Fill, by whoever
|
||||
// holds the bus (the Go build module's compose job); Run refuses a digest left unfilled.
|
||||
package composer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/sdkversion"
|
||||
)
|
||||
|
||||
// Version is the version of a Request and of a Verdict; a reader refuses another.
|
||||
const Version = 1
|
||||
|
||||
// Package is the import path of the command, which a compose job builds at a commit (in mesh-sdk's own module, or in
|
||||
// a module that requires mesh-sdk, at the version it pins).
|
||||
const Package = "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose"
|
||||
|
||||
// The depths of a composition.
|
||||
const (
|
||||
// DepthResolve resolves each machine's set over the rest of the mesh (compose.Resolve): what the assigner judges
|
||||
// while the facts snapshot is not on the bus (issue 1461).
|
||||
DepthResolve = "resolve"
|
||||
// DepthCompose composes each machine's whole declaration and runs the node-engine's validator over it
|
||||
// (compose.Compose).
|
||||
DepthCompose = "compose"
|
||||
)
|
||||
|
||||
// Request is everything one run composes: the rest of the mesh, one or more cases of manifests composed over it, and
|
||||
// the machines composed in each case.
|
||||
type Request struct {
|
||||
V int `json:"v"`
|
||||
// Input is the composition's input without its catalogue (and without each node's kept manifests): the cases give
|
||||
// those. A Catalogue or Kept it holds is ignored.
|
||||
Input compose.Input `json:"input"`
|
||||
// Cases are composed one after the other over Input, each with its own manifests; their names are unique.
|
||||
Cases []Case `json:"cases"`
|
||||
// Machines are the nodes composed in every case; none means the manifests are only read.
|
||||
Machines []string `json:"machines,omitempty"`
|
||||
// Depth is DepthResolve or DepthCompose; empty is DepthCompose.
|
||||
Depth string `json:"depth,omitempty"`
|
||||
// HoldingsFromClaims adds to Input's holdings every seat an assigned module claims, held on its node (the
|
||||
// assigner's input, which has no holdings of record).
|
||||
HoldingsFromClaims bool `json:"holdings_from_claims,omitempty"`
|
||||
// Declarations asks each composed machine's declared paths (DepthCompose only).
|
||||
Declarations bool `json:"declarations,omitempty"`
|
||||
// Predefined is the list of predefined keys at the catalogue's root (predefined.json, novox/hq ADR 0348 §7) that
|
||||
// every config value falling back to a key is judged against, when the asker has it (mesh-compose -catalogue
|
||||
// reads it). Without it a fallback is read as catalogue.ParseManifestForInputs reads it: against the list the mesh
|
||||
// published when this process holds one, else each key as its value declares it, for the merge gate judges the
|
||||
// fallback in the catalogue and a manifest that only carries one is not refused here.
|
||||
Predefined catalogue.PredefinedList `json:"predefined,omitempty"`
|
||||
}
|
||||
|
||||
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
|
||||
type Case struct {
|
||||
Name string `json:"name"`
|
||||
Manifests map[string]Manifest `json:"manifests"`
|
||||
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps
|
||||
// it under (Digest), which Fill replaces by the bytes. On the wire it is the manifest itself, or {"digest": "..."}.
|
||||
type Manifest struct {
|
||||
Raw json.RawMessage
|
||||
Digest string
|
||||
}
|
||||
|
||||
// MarshalJSON writes the raw manifest, or {"digest": ...} when only the digest is known.
|
||||
func (m Manifest) MarshalJSON() ([]byte, error) {
|
||||
if len(m.Raw) > 0 {
|
||||
return m.Raw, nil
|
||||
}
|
||||
return json.Marshal(map[string]string{"digest": m.Digest})
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads {"digest": "..."} (an object with that one key) as a digest, and any other value as the raw
|
||||
// manifest.
|
||||
func (m *Manifest) UnmarshalJSON(raw []byte) error {
|
||||
var probe map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &probe) == nil && len(probe) == 1 {
|
||||
if d, ok := probe["digest"]; ok {
|
||||
var digest string
|
||||
if err := json.Unmarshal(d, &digest); err != nil {
|
||||
return fmt.Errorf("a manifest's digest is not a string: %w", err)
|
||||
}
|
||||
*m = Manifest{Digest: digest}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
*m = Manifest{Raw: slices.Clone(raw)}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verdict is what one run answers: the mesh-sdk the command was built with, and each case's verdict in the order asked.
|
||||
type Verdict struct {
|
||||
V int `json:"v"`
|
||||
SDK string `json:"sdk"`
|
||||
Cases []CaseVerdict `json:"cases"`
|
||||
}
|
||||
|
||||
// CaseVerdict is one case's: every manifest read or refused, every machine composed or not.
|
||||
type CaseVerdict struct {
|
||||
Name string `json:"name"`
|
||||
Manifests map[string]ManifestRead `json:"manifests"`
|
||||
Machines map[string]Machine `json:"machines,omitempty"`
|
||||
}
|
||||
|
||||
// ManifestRead is one manifest as this mesh-sdk reads it: accepted, or refused with the reader's words.
|
||||
type ManifestRead struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// Machine is one machine composed: whether it composes (resolves, at DepthResolve), why not, the node-engine
|
||||
// validator's problems and the composition's refusals (DepthCompose), and its declared paths when asked.
|
||||
type Machine struct {
|
||||
Composes bool `json:"composes"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
Refusals []string `json:"refusals,omitempty"`
|
||||
Declared []Path `json:"declared,omitempty"`
|
||||
}
|
||||
|
||||
// Path is one path a declaration puts on the machine, with its kind (the resource's type), owner, group and mode as
|
||||
// declared, and the module it is of.
|
||||
type Path struct {
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
|
||||
// ErrUnfilled is a manifest named by a digest that nobody replaced by its bytes.
|
||||
var ErrUnfilled = errors.New("composer: a manifest is named by its digest and was not filled")
|
||||
|
||||
// Decode reads a request; it refuses one that is not JSON or of another version.
|
||||
func Decode(data []byte) (Request, error) {
|
||||
var r Request
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return Request{}, fmt.Errorf("composer: the request is not JSON of its shape: %w", err)
|
||||
}
|
||||
if r.V != Version {
|
||||
return Request{}, fmt.Errorf("composer: a request of version %d, this composer reads %d", r.V, Version)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Fill replaces every manifest named by its digest by the bytes get answers for that digest; a digest get cannot answer
|
||||
// stops it, named.
|
||||
func Fill(r *Request, get func(digest string) ([]byte, error)) error {
|
||||
fill := func(m map[string]Manifest) error {
|
||||
for name, man := range m {
|
||||
if len(man.Raw) > 0 {
|
||||
continue
|
||||
}
|
||||
raw, err := get(man.Digest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("composer: the manifest of %s (digest %s): %w", name, man.Digest, err)
|
||||
}
|
||||
m[name] = Manifest{Raw: raw}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, c := range r.Cases {
|
||||
if err := fill(c.Manifests); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, kept := range c.Kept {
|
||||
if err := fill(kept); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run composes every case of r and answers the verdict, its SDK this binary's mesh-sdk. An error is a request it
|
||||
// cannot run (an unknown depth, a case named twice, a manifest left as a digest); a machine that does not compose is a
|
||||
// verdict.
|
||||
func Run(r Request) (Verdict, error) {
|
||||
depth := r.Depth
|
||||
if depth == "" {
|
||||
depth = DepthCompose
|
||||
}
|
||||
if depth != DepthResolve && depth != DepthCompose {
|
||||
return Verdict{}, fmt.Errorf("composer: the depth %q is not resolve or compose", r.Depth)
|
||||
}
|
||||
v := Verdict{V: Version, SDK: sdkversion.Version()}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range r.Cases {
|
||||
if seen[c.Name] {
|
||||
return Verdict{}, fmt.Errorf("composer: the case %q is named twice", c.Name)
|
||||
}
|
||||
seen[c.Name] = true
|
||||
cv, err := runCase(r, c, depth)
|
||||
if err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
v.Cases = append(v.Cases, cv)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// readManifest reads one manifest as this run reads every one: against the request's list of predefined keys when it
|
||||
// has one, else as the indexer and the builder read it (Request.Predefined).
|
||||
func readManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.Manifest, error) {
|
||||
if predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
|
||||
func parse(m map[string]Manifest, reads map[string]ManifestRead, predefined catalogue.PredefinedList) (map[string]catalogue.Manifest, error) {
|
||||
out := map[string]catalogue.Manifest{}
|
||||
for name, man := range m {
|
||||
if len(man.Raw) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest)
|
||||
}
|
||||
parsed, err := readManifest(man.Raw, predefined)
|
||||
if reads != nil {
|
||||
if err != nil {
|
||||
reads[name] = ManifestRead{Refused: err.Error()}
|
||||
} else {
|
||||
reads[name] = ManifestRead{Accepted: true}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
out[name] = parsed
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
|
||||
cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}}
|
||||
shelf, err := parse(c.Manifests, cv.Manifests, r.Predefined)
|
||||
if err != nil {
|
||||
return CaseVerdict{}, err
|
||||
}
|
||||
in := r.Input
|
||||
in.Catalogue = shelf
|
||||
in.Nodes = slices.Clone(in.Nodes)
|
||||
in.Holdings = slices.Clone(in.Holdings)
|
||||
for i := range in.Nodes {
|
||||
in.Nodes[i].Kept = nil
|
||||
if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 {
|
||||
k, err := parse(kept, nil, r.Predefined)
|
||||
if err != nil {
|
||||
return CaseVerdict{}, err
|
||||
}
|
||||
in.Nodes[i].Kept = k
|
||||
}
|
||||
}
|
||||
if r.HoldingsFromClaims {
|
||||
in.Holdings = append(in.Holdings, HoldingsOf(shelf, in.Nodes)...)
|
||||
}
|
||||
if len(r.Machines) == 0 {
|
||||
return cv, nil
|
||||
}
|
||||
cv.Machines = map[string]Machine{}
|
||||
for _, node := range r.Machines {
|
||||
cv.Machines[node] = machine(in, node, depth, r.Declarations)
|
||||
}
|
||||
return cv, nil
|
||||
}
|
||||
|
||||
// HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings
|
||||
// of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input).
|
||||
func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held {
|
||||
var out []catalogue.Held
|
||||
sorted := slices.Clone(nodes)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
for _, n := range sorted {
|
||||
for _, mod := range n.Assigned {
|
||||
for _, cl := range shelf[mod].Claims {
|
||||
scope := cl.Scope
|
||||
if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" {
|
||||
scope = seat.Scope
|
||||
}
|
||||
out = append(out, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n.Name, Module: mod})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func machine(in compose.Input, node, depth string, declarations bool) Machine {
|
||||
if depth == DepthResolve {
|
||||
if _, err := compose.Resolve(in, node); err != nil {
|
||||
return Machine{Reason: err.Error()}
|
||||
}
|
||||
return Machine{Composes: true}
|
||||
}
|
||||
d, problems, err := compose.Compose(in, node)
|
||||
if err != nil {
|
||||
return Machine{Reason: err.Error()}
|
||||
}
|
||||
m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
|
||||
if len(problems) > 0 {
|
||||
m.Reason = "the node-engine's validator refuses the declaration"
|
||||
}
|
||||
if declarations {
|
||||
m.Declared = pathsOf(d)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by
|
||||
// path.
|
||||
func pathsOf(d compose.Declared) []Path {
|
||||
var out []Path
|
||||
str := func(r map[string]any, key string) string {
|
||||
if v, ok := r[key]; ok && v != nil {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
p := str(r, "path")
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Path{Path: p, Kind: str(r, "type"), Owner: str(r, "owner"), Group: str(r, "group"),
|
||||
Mode: str(r, "mode"), Module: d.ModuleOf[str(r, "id")]})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Path != out[j].Path {
|
||||
return out[i].Path < out[j].Path
|
||||
}
|
||||
return out[i].Kind < out[j].Kind
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package composer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
)
|
||||
|
||||
const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}`
|
||||
|
||||
// A manifest named by its digest goes on the wire as {"digest": ...}, and Fill puts the bytes the indexer keeps in its
|
||||
// place; a raw manifest goes as itself.
|
||||
func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
|
||||
r := Request{V: Version, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{
|
||||
"demo": {Digest: "d1"}, "raw": {Raw: json.RawMessage(`{"module":"raw","version":"1"}`)}},
|
||||
Kept: map[string]map[string]Manifest{"laptop": {"demo": {Digest: "d1"}}}}}}
|
||||
wire, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(wire), `"demo":{"digest":"d1"}`) || !strings.Contains(string(wire), `"raw":{"module":"raw","version":"1"}`) {
|
||||
t.Fatalf("on the wire: %s", wire)
|
||||
}
|
||||
back, err := Decode(wire)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Run(back); !errors.Is(err, ErrUnfilled) {
|
||||
t.Fatalf("a digest left unfilled runs: %v", err)
|
||||
}
|
||||
asked := 0
|
||||
if err := Fill(&back, func(d string) ([]byte, error) {
|
||||
asked++
|
||||
if d != "d1" {
|
||||
return nil, errors.New("no such digest")
|
||||
}
|
||||
return []byte(demo), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo {
|
||||
t.Fatalf("filled %d times: %+v", asked, back.Cases[0])
|
||||
}
|
||||
bad := Request{V: Version, Cases: []Case{{Name: "c", Manifests: map[string]Manifest{"x": {Digest: "nope"}}}}}
|
||||
if err := Fill(&bad, func(string) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") {
|
||||
t.Fatalf("a digest the indexer does not hold: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The input travels as JSON and comes back equal, so the command composes what its caller meant.
|
||||
func TestTheInputTravelsAsJSON(t *testing.T) {
|
||||
in := compose.Input{
|
||||
Nodes: []compose.Node{{Name: "anchor", Assigned: []string{"nats"}, Capabilities: map[string]bool{"systemd": true},
|
||||
Place: compose.Place{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"},
|
||||
Pins: map[string]catalogue.Chosen{"bus": {Node: "anchor", Module: "nats"}},
|
||||
Settings: map[string][]catalogue.Layer{"nats": {{Values: map[string]any{"port": float64(4222)}}}}}},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: "mesh", Node: "anchor", Module: "nats"}},
|
||||
Issued: []compose.Issued{{Provision: "bus", Consumer: "laptop", ConsumerModule: "x", Provider: "anchor"}},
|
||||
}
|
||||
raw, err := json.Marshal(Request{V: Version, Input: in})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(back.Input, in) {
|
||||
t.Fatalf("the input came back as\n%+v\nwant\n%+v", back.Input, in)
|
||||
}
|
||||
}
|
||||
|
||||
// The seats a node's assigned modules claim are held there when the request says the holdings come from the claims.
|
||||
func TestHoldingsComeFromTheClaims(t *testing.T) {
|
||||
m, err := catalogue.ParseManifest([]byte(demo))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := HoldingsOf(map[string]catalogue.Manifest{"demo": m}, []compose.Node{{Name: "b", Assigned: []string{"demo"}}, {Name: "a", Assigned: []string{"demo"}}})
|
||||
if len(got) != 2 || got[0].Node != "a" || got[0].Claim != "node-files" || got[0].Scope != "node" {
|
||||
t.Fatalf("holdings = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A request the command cannot run is refused, never answered as a verdict: another version, a case named twice, an
|
||||
// unknown depth.
|
||||
func TestARequestItCannotRunIsRefused(t *testing.T) {
|
||||
if _, err := Decode([]byte(`{"v": 2}`)); err == nil {
|
||||
t.Error("a request of version 2 is read")
|
||||
}
|
||||
if _, err := Run(Request{V: Version, Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil {
|
||||
t.Error("a case named twice runs")
|
||||
}
|
||||
if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil {
|
||||
t.Error("an unknown depth runs")
|
||||
}
|
||||
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
|
||||
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
|
||||
t.Fatalf("a request with no machines: %+v, %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest whose config value falls back to a predefined key (novox/hq ADR 0348 §7) is read, not refused for the
|
||||
// list of predefined keys at the catalogue's root that a run is never handed with its manifests: the merge gate judges
|
||||
// the fallback in the catalogue. xterm's module.json from mesh-catalog at 835bf22b falls back to "font-size"; a group
|
||||
// touching xterm or pacman failed its composition on this (group check 18df81380db3). A list in the request is the one
|
||||
// judged against, so a fallback it does not name is still refused.
|
||||
func TestAManifestFallingBackToAPredefinedKeyIsRead(t *testing.T) {
|
||||
raw, err := os.ReadFile("../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"predefined"`) {
|
||||
t.Fatal("the fixture names no predefined key")
|
||||
}
|
||||
r := Request{V: Version, Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}}
|
||||
v, err := Run(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := v.Cases[0].Manifests["xterm"]; !got.Accepted {
|
||||
t.Fatalf("xterm: %+v, want accepted", got)
|
||||
}
|
||||
r.Predefined = catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}}
|
||||
if v, err = Run(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := v.Cases[0].Manifests["xterm"]; got.Accepted || !strings.Contains(got.Refused, "not on the list of predefined keys") {
|
||||
t.Fatalf("xterm judged against a list without its key: %+v, want refused naming the list", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ParseResource reads one resource as Parse reads each of a declaration's resources: by its type, strictly, refusing
|
||||
// a field its kind does not use and anything its kind refuses, and never an action (the link may not carry one, ADR
|
||||
// 0005). It judges the resource alone: what Parse judges across resources (an opening on a node not adopted, the
|
||||
// files the provisioner fills, what replaces what) is for the whole a node applies (novox/hq ADR 0539 clause 1: a
|
||||
// composition is one module's resources, and a node applies its compositions together with its own key).
|
||||
func ParseResource(raw json.RawMessage) (Resource, error) {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Type Type `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, &RefusalError{Problems: []string{"not a resource: " + err.Error()}}
|
||||
}
|
||||
where := "resource"
|
||||
if head.ID == "" {
|
||||
return nil, &RefusalError{Problems: []string{where + ": no id. Identity is what lets the host know this is " +
|
||||
"the same resource it applied last time"}}
|
||||
}
|
||||
where = fmt.Sprintf("resource %q", head.ID)
|
||||
resource := newOf(head.Type)
|
||||
if resource == nil {
|
||||
return nil, &RefusalError{Problems: []string{fmt.Sprintf("%s: unknown type %q. This host understands %s",
|
||||
where, head.Type, vocabulary())}}
|
||||
}
|
||||
if unknown := unknownFields(raw, resource); len(unknown) > 0 {
|
||||
var problems []string
|
||||
for _, field := range unknown {
|
||||
problems = append(problems, fmt.Sprintf("%s: a %s does not use %q, and it is set. Refused rather "+
|
||||
"than ignored", where, head.Type, field))
|
||||
}
|
||||
return nil, &RefusalError{Problems: problems}
|
||||
}
|
||||
if err := json.Unmarshal(raw, resource); err != nil {
|
||||
return nil, &RefusalError{Problems: []string{fmt.Sprintf("%s: %s", where, err)}}
|
||||
}
|
||||
if problems := resource.validate(where, false); len(problems) > 0 {
|
||||
return nil, &RefusalError{Problems: problems}
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
+118
-16
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
// only compared; the node agent applying them is release 6 (ADR 0429 §7). Only the assigner's credential writes these
|
||||
// buckets; the watchman, the view and the controller read them.
|
||||
//
|
||||
// **Release 6** (ADR 0539 clauses 1, 6 and 16): a want also names the digest of the one composition it allows, written
|
||||
// only once that composition exists (the seventh rule, RuleComposed), and carries the answer to a failed backup; the
|
||||
// node's own want `want.<node>.node` names the digest of the node's own composition (NodeWant).
|
||||
//
|
||||
// **The bucket is the first word, the key the rest** (as the builder's `builds.<module>.<fp>` is the key
|
||||
// `<module>.<fp>` in its builds bucket): the differences bucket's keys are `<node>.<module>`. The wants bucket holds
|
||||
// two kinds of key, so each keeps its word: `want.<node>.<module>` and `held.<module>.<fingerprint hex>`.
|
||||
@@ -50,7 +55,33 @@ func WantKey(node, module string) (string, error) {
|
||||
return wantWord + "." + have, nil
|
||||
}
|
||||
|
||||
// ParseWantKey is the inverse of WantKey; it refuses a held key, a have key and any key of another bucket, naming it.
|
||||
// WantNodeKey is the key, in AssignerWantsBucket, of a node's own want: `want.<node>.node`, naming the digest of the
|
||||
// node's own composition (ADR 0539 clause 6; ADR 0429 §4's node key). The assigner writes it once the node's own
|
||||
// composition exists.
|
||||
func WantNodeKey(node string) (string, error) {
|
||||
own, err := HaveNodeKey(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return wantWord + "." + own, nil
|
||||
}
|
||||
|
||||
// ParseWantNodeKey is the inverse of WantNodeKey; it refuses a module's want key and any key of another bucket,
|
||||
// naming it.
|
||||
func ParseWantNodeKey(key string) (node string, err error) {
|
||||
rest, ok := strings.CutPrefix(key, wantWord+".")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("delivery: %q is not a node's own want key, want.<node>.node", key)
|
||||
}
|
||||
node, _, isNode, err := ParseHaveKey(rest)
|
||||
if err != nil || !isNode {
|
||||
return "", fmt.Errorf("delivery: %q is not a node's own want key, want.<node>.node", key)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// ParseWantKey is the inverse of WantKey; it refuses a held key, a have key, the node's own want key and any key of
|
||||
// another bucket, naming it.
|
||||
func ParseWantKey(key string) (node, module string, err error) {
|
||||
rest, ok := strings.CutPrefix(key, wantWord+".")
|
||||
if !ok {
|
||||
@@ -100,7 +131,8 @@ func ParseDifferenceKey(key string) (node, module string, err error) {
|
||||
return node, module, nil
|
||||
}
|
||||
|
||||
// The six rules of ADR 0429 §6, by number, as a want or a difference names the one that holds a node back.
|
||||
// The rules of ADR 0429 §6 and ADR 0539 clause 6, by number, as a want or a difference names the one that holds a node
|
||||
// back.
|
||||
const (
|
||||
RuleBuildDone = 1 // the build of F is done
|
||||
RuleNotHeld = 2 // F is not held
|
||||
@@ -108,15 +140,25 @@ const (
|
||||
RuleStep = 4 // at most step other nodes are ahead (step 0: no limit, ADR 0528 §3)
|
||||
RuleProviders = 5 // every provider the module requires runs healthy on that node, or at its canary
|
||||
RuleGrant = 6 // the module's credential for F exists (grants.<module>.<fp>)
|
||||
RuleComposed = 7 // the module's composition for that node at F exists (mesh-controller_composed, ADR 0539 §6)
|
||||
)
|
||||
|
||||
func checkRule(rule int, of string) error {
|
||||
if rule < RuleBuildDone || rule > RuleGrant {
|
||||
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the six (1 to 6)", of, rule)
|
||||
if rule < RuleBuildDone || rule > RuleComposed {
|
||||
return fmt.Errorf("delivery: %s names the rule %d, which is not one of the seven (1 to 7)", of, rule)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The answers a want carries to a failed backup before an apply (ADR 0539 clause 16): the node agent reads them from
|
||||
// its own want and from nothing else.
|
||||
const (
|
||||
// BackupAgain is the answer to back the module up again before the apply.
|
||||
BackupAgain = "again"
|
||||
// BackupSkip is the answer to go on without a backup.
|
||||
BackupSkip = "skip"
|
||||
)
|
||||
|
||||
func checkFingerprint(fp, of string) error {
|
||||
if _, err := FingerprintKeyPart(fp); err != nil {
|
||||
return fmt.Errorf("delivery: %s: %w", of, err)
|
||||
@@ -124,29 +166,54 @@ func checkFingerprint(fp, of string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Want is a node's want for a module: the fingerprint FP the node should run, or Removed for a module that left the
|
||||
// node's modules (then FP is empty). When the module's newest fingerprint is not yet allowed on this node, Newest names
|
||||
// it and Rule the rule that holds the node back; both are empty when FP is the newest. Words say why in a sentence.
|
||||
// The entry's own time says since when.
|
||||
// Want is a node's want for a module: the fingerprint FP the node should run and the digest Composed of the one
|
||||
// composition of it the node may apply (ADR 0539 clause 1), or Removed for a module that left the node's modules (then
|
||||
// both are empty). When the module's newest fingerprint is not yet allowed on this node, Newest names it and Rule the
|
||||
// rule that holds the node back; both are empty when FP is the newest. Backup is the answer to a failed backup before
|
||||
// the apply, BackupAgain or BackupSkip (clause 16), empty when none was asked. Words say why in a sentence. The
|
||||
// entry's own time says since when.
|
||||
type Want struct {
|
||||
V int `json:"v"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Newest string `json:"newest,omitempty"`
|
||||
Rule int `json:"rule,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
V int `json:"v"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
Newest string `json:"newest,omitempty"`
|
||||
Rule int `json:"rule,omitempty"`
|
||||
Backup string `json:"backup,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// Put says the want was written by a put (ADR 0546), and Hold that the put holds it: the rules pass over it until it
|
||||
// is released. A put's want names no newest fingerprint or rule.
|
||||
Put bool `json:"put,omitempty"`
|
||||
Hold bool `json:"hold,omitempty"`
|
||||
}
|
||||
|
||||
func (w Want) check() error {
|
||||
if w.Removed {
|
||||
if w.FP != "" || w.Newest != "" || w.Rule != 0 {
|
||||
return fmt.Errorf("delivery: a removed want names no fingerprint and no rule")
|
||||
if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold {
|
||||
return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule, backup answer or put")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if w.Hold && !w.Put {
|
||||
return fmt.Errorf("delivery: only a put holds a want")
|
||||
}
|
||||
if w.Put && (w.Newest != "" || w.Rule != 0) {
|
||||
return fmt.Errorf("delivery: a put's want is held back by no rule")
|
||||
}
|
||||
if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.Composed == "" {
|
||||
return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1)", w.FP)
|
||||
}
|
||||
if err := checkDigest(w.Composed, "a want's composition digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
switch w.Backup {
|
||||
case "", BackupAgain, BackupSkip:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the backup answer %q is not %s or %s", w.Backup, BackupAgain, BackupSkip)
|
||||
}
|
||||
if w.Newest == "" && w.Rule == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -236,6 +303,41 @@ func DecodeWant(data []byte) (Want, error) {
|
||||
// SameWant says whether two wants are the same value, so the assigner writes only when they differ.
|
||||
func SameWant(a, b Want) bool { return a == b }
|
||||
|
||||
// NodeWant is a node's own want (WantNodeKey): the digest Composed of the node's own composition it may apply. Words
|
||||
// say why in a sentence.
|
||||
type NodeWant struct {
|
||||
V int `json:"v"`
|
||||
Composed string `json:"composed"`
|
||||
Words string `json:"words,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form.
|
||||
func EncodeNodeWant(w NodeWant) ([]byte, error) {
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.V = Version
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// DecodeNodeWant reads a node's own want; it refuses an unknown version (ErrUnknownVersion), a field a node's own want
|
||||
// does not have (a fingerprint among them), and what EncodeNodeWant refuses.
|
||||
func DecodeNodeWant(data []byte) (NodeWant, error) {
|
||||
if err := checkVersion(data); err != nil {
|
||||
return NodeWant{}, err
|
||||
}
|
||||
var w NodeWant
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&w); err != nil {
|
||||
return NodeWant{}, fmt.Errorf("delivery: a node's own want does not fit its shape: %w", err)
|
||||
}
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil {
|
||||
return NodeWant{}, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// EncodeHeld is h as JSON at Version; it refuses a node name a key refuses.
|
||||
func EncodeHeld(h Held) ([]byte, error) {
|
||||
if err := h.check(); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1297/B1a: a want key round-trips through its parser; a held key, a have key and a node's own key are refused,
|
||||
@@ -74,9 +75,10 @@ func TestDifferenceKey(t *testing.T) {
|
||||
// 1297/B1a: wants, holds and differences round-trip through their encoders unchanged, at Version.
|
||||
func TestAssignerValuesRoundTrip(t *testing.T) {
|
||||
wants := []Want{
|
||||
{FP: fp2of('a')},
|
||||
{FP: fp2of('a'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"},
|
||||
{FP: fp2of('a'), Composed: hashOf('1')},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"},
|
||||
{Removed: true, Words: "the module left g14"},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Hold: true, Words: "put by the operator: the bus's user list"},
|
||||
}
|
||||
for _, w := range wants {
|
||||
data, err := EncodeWant(w)
|
||||
@@ -89,6 +91,24 @@ func TestAssignerValuesRoundTrip(t *testing.T) {
|
||||
t.Errorf("want %+v came back %+v, %v", w, got, err)
|
||||
}
|
||||
}
|
||||
at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)
|
||||
p := Put{FP: fp2of('a'), Hold: true, Why: "the bus's user list", By: "shanks.node-tools", At: at}.Said(at, PutAsked, "put asked")
|
||||
p = p.Said(at, PutAsked, "put asked").Said(at.Add(time.Second), PutWanted, "want written")
|
||||
p.Composed = hashOf('1')
|
||||
if len(p.Steps) != 2 || p.Step != PutWanted || PutEnded(p.Step) || !PutEnded(PutReleased) {
|
||||
t.Errorf("a put's steps are %+v at %s", p.Steps, p.Step)
|
||||
}
|
||||
pkey, _ := PutKey("novox", "nats")
|
||||
if n, m, err := ParsePutKey(pkey); err != nil || n != "novox" || m != "nats" {
|
||||
t.Errorf("the put key %s reads %s %s, %v", pkey, n, m, err)
|
||||
}
|
||||
pdata, err := EncodePut(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotP, err := DecodePut(pdata); err != nil || gotP.FP != p.FP || gotP.Step != PutWanted || len(gotP.Steps) != 2 || !gotP.Hold {
|
||||
t.Errorf("the put came back %+v, %v", gotP, err)
|
||||
}
|
||||
h := Held{Node: "ace", Words: "its health check failed: connection refused"}
|
||||
data, err := EncodeHeld(h)
|
||||
if err != nil {
|
||||
@@ -127,7 +147,10 @@ func TestAssignerValuesRefused(t *testing.T) {
|
||||
{Removed: true, FP: fp2of('a')},
|
||||
{FP: fp2of('a'), Newest: fp2of('b')},
|
||||
{FP: fp2of('a'), Newest: fp2of('a'), Rule: 3},
|
||||
{FP: fp2of('a'), Newest: fp2of('b'), Rule: 7},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Hold: true},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3},
|
||||
{Removed: true, Put: true},
|
||||
} {
|
||||
if _, err := EncodeWant(w); err == nil {
|
||||
t.Errorf("EncodeWant(%+v) accepted", w)
|
||||
@@ -148,6 +171,19 @@ func TestAssignerValuesRefused(t *testing.T) {
|
||||
t.Errorf("EncodeDifference(%+v) accepted", d)
|
||||
}
|
||||
}
|
||||
for _, p := range []Put{
|
||||
{FP: fp2of('a'), Why: "w", Step: "walking"},
|
||||
{FP: fp2of('a'), Step: PutAsked},
|
||||
{FP: "src2:abc", Why: "w", Step: PutAsked},
|
||||
{FP: fp2of('a'), Why: "w", Step: PutWanted, Composed: "sha256:x"},
|
||||
} {
|
||||
if _, err := EncodePut(p); err == nil {
|
||||
t.Errorf("EncodePut(%+v) accepted", p)
|
||||
}
|
||||
}
|
||||
if _, _, err := ParsePutKey("ace.node"); err == nil {
|
||||
t.Errorf("a node's own key was read as a put")
|
||||
}
|
||||
if _, err := DecodeWant([]byte(`{"v":2,"fp":"` + fp2of('a') + `"}`)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("a want of version 2 was read: %v", err)
|
||||
}
|
||||
@@ -262,4 +298,20 @@ func TestControllerGrant(t *testing.T) {
|
||||
if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil {
|
||||
t.Errorf("a grant naming an empty account was accepted")
|
||||
}
|
||||
g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
|
||||
for _, c := range []struct {
|
||||
g ControllerGrant
|
||||
node, module string
|
||||
names bool
|
||||
}{
|
||||
{g, "ace", "mesh-build-go", true},
|
||||
{g, "shanks", "mesh-mail", true},
|
||||
{g, "g14", "mesh-build-go", false},
|
||||
{g, "ace", "mesh-build", false},
|
||||
{ControllerGrant{}, "novox", "nats", true},
|
||||
} {
|
||||
if got := c.g.NamesNode(c.node, c.module); got != c.names {
|
||||
t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The controller's compositions (novox/hq ADR 0539 clauses 1 to 4). A **composition** is one module's resources
|
||||
// composed for one node at one fingerprint, written by the controller, which keeps the inputs. It lives in a bucket of
|
||||
// each node, `composed-<node>`, which only that node's node-engine reads (a composition can carry a sealed value), under
|
||||
// `<node>.<module>.<fingerprint hex>`, and the node's own resources (adoption, firewall openings, accounts, the
|
||||
// resolver, identities and groups) under `<node>.node`. Beside it, under the same key in `mesh-controller_composed`,
|
||||
// which the assigner reads, the controller writes the composition's digest and its judgement of the move from the
|
||||
// composition the node runs now (ComposedDigest). The controller writes both by compare-and-set while it holds the
|
||||
// lease; the operator's credential is a second writer on the hand path alone (clause 10, mesh-hand-compose).
|
||||
//
|
||||
// A composition is derived state (clause 4): its key and its digest are deleted by the controller when its fingerprint
|
||||
// is no longer among the module's five newest and no want names it, and each bucket keeps five revisions of a key.
|
||||
const (
|
||||
// ComposedBucketPrefix starts the name of a node's bucket of compositions, ComposedBucketFor(node).
|
||||
ComposedBucketPrefix = "composed-"
|
||||
// ComposedHistory is the revisions a node's composition bucket keeps per key (ADR 0539 clause 4).
|
||||
ComposedHistory = 5
|
||||
// ControllerModule is the module of the controller, the writer of the compositions.
|
||||
ControllerModule = "mesh-controller"
|
||||
// ControllerComposedBucket is the controller's bucket of each composition's digest and judgement, `<owner
|
||||
// module>_<name>`, read by the assigner (ADR 0539 clauses 2 and 6).
|
||||
ControllerComposedBucket = ControllerModule + "_composed"
|
||||
// ControllerComposedHistory is the revisions that bucket keeps per key.
|
||||
ControllerComposedHistory = 5
|
||||
// ComposedKeep is how many of a module's newest fingerprints keep their compositions though no want names them
|
||||
// (ADR 0539 clause 4).
|
||||
ComposedKeep = 5
|
||||
)
|
||||
|
||||
// nodeWord is the last part of a node's own key, in the have bucket and in a node's composition bucket alike.
|
||||
const nodeWord = "node"
|
||||
|
||||
var bucketNode = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// ComposedBucketFor is the name of a node's bucket of compositions, "composed-<node>", as declaration.FileBucketFor
|
||||
// names a node's object bucket (ADR 0488 §4). The node name must be one a bucket and a subject token accept: letters,
|
||||
// digits, '_' and '-'.
|
||||
func ComposedBucketFor(node string) (string, error) {
|
||||
if node == "" {
|
||||
return "", fmt.Errorf("delivery: the node name is empty; a node's composition bucket is named for its node")
|
||||
}
|
||||
if !bucketNode.MatchString(node) {
|
||||
return "", fmt.Errorf("delivery: the node name %q is not one a bucket accepts: letters, digits, '_' and '-' only", node)
|
||||
}
|
||||
return ComposedBucketPrefix + node, nil
|
||||
}
|
||||
|
||||
// ComposedKey is the key, in ComposedBucketFor(node) and in ControllerComposedBucket, of a module's composition for a
|
||||
// node at a fingerprint: `<node>.<module>.<hex>`, the hex being the fingerprint's key part (FingerprintKeyPart). A
|
||||
// module named `node` is refused, since `<node>.node` is the node's own key.
|
||||
func ComposedKey(node, module, fingerprint string) (string, error) {
|
||||
have, err := HaveKey(node, module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
part, err := FingerprintKeyPart(fingerprint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return have + "." + part, nil
|
||||
}
|
||||
|
||||
// ComposedNodeKey is the key of a node's own composition: `<node>.node`.
|
||||
func ComposedNodeKey(node string) (string, error) {
|
||||
return HaveNodeKey(node)
|
||||
}
|
||||
|
||||
// ParseComposedKey is the inverse of ComposedKey and ComposedNodeKey: isNode is true for `<node>.node`, with module
|
||||
// and fingerprint empty. It refuses any other shape, naming the key.
|
||||
func ParseComposedKey(key string) (node, module, fingerprint string, isNode bool, err error) {
|
||||
refused := func(why error) (string, string, string, bool, error) {
|
||||
return "", "", "", false, fmt.Errorf("delivery: %q is not a composition key, <node>.<module>.<fingerprint hex> or <node>.node: %w", key, why)
|
||||
}
|
||||
parts := strings.Split(key, ".")
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
if parts[1] != nodeWord {
|
||||
return refused(fmt.Errorf("a module's composition names its fingerprint"))
|
||||
}
|
||||
if err := checkName("node", parts[0]); err != nil {
|
||||
return refused(err)
|
||||
}
|
||||
return parts[0], "", "", true, nil
|
||||
case 3:
|
||||
node, module, isNode, err := ParseHaveKey(parts[0] + "." + parts[1])
|
||||
if err != nil {
|
||||
return refused(err)
|
||||
}
|
||||
if isNode {
|
||||
return refused(fmt.Errorf("the node's own key names no fingerprint"))
|
||||
}
|
||||
fp, err := ParseFingerprintKeyPart(parts[2])
|
||||
if err != nil {
|
||||
return refused(err)
|
||||
}
|
||||
return node, module, fp, false, nil
|
||||
}
|
||||
return refused(fmt.Errorf("it has %d parts", len(parts)))
|
||||
}
|
||||
|
||||
// Judgement is the controller's judgement of a move from the composition a node runs, as its have names it, to a new
|
||||
// one (ADR 0539 clauses 2 and 14, by ADR 0377 §3's tests).
|
||||
type Judgement string
|
||||
|
||||
// The judgements of a move.
|
||||
const (
|
||||
// JudgedRecreates is a move that recreates a container.
|
||||
JudgedRecreates Judgement = "recreates"
|
||||
// JudgedMovesData is a move that gives the module's data another place.
|
||||
JudgedMovesData Judgement = "moves-data"
|
||||
// JudgedNeither is a move that does neither, and the judgement of a composition the node never ran.
|
||||
JudgedNeither Judgement = "neither"
|
||||
// JudgedCannot is a move that cannot be judged, because the composition the node runs is gone or cannot be read.
|
||||
JudgedCannot Judgement = "cannot-judge"
|
||||
// JudgedUnchanged is a composition equal to the module's slice of the declaration the node last applied, for a node
|
||||
// whose have names no composition yet (the first want at the switch, novox/hq ADR 0540 clause 9): applying it
|
||||
// changes nothing on the node, so it needs no person's yes.
|
||||
JudgedUnchanged Judgement = "unchanged"
|
||||
)
|
||||
|
||||
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
func checkDigest(digest, of string) error {
|
||||
if !digestForm.MatchString(digest) {
|
||||
return fmt.Errorf("delivery: %s %q is not sha256:<64 lowercase hex digits>", of, digest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComposedDigest is the value, in ControllerComposedBucket, beside a composition: its Digest (ComposedDigestOf), and
|
||||
// the Judgement of the move to it from the composition the node runs now, whose digest is Against (empty when the node
|
||||
// runs no composition of the module). Words say why, and are required for a move that cannot be judged. Rewritten when
|
||||
// the node's have changes.
|
||||
//
|
||||
// Policy is the module's upgrade policy as the controller holds it (ADR 0236, ADR 0539 clause 14), with PolicyFrom
|
||||
// where it comes from and PolicyWhy why: the assigner holds a move of a module of policy record from it, the operator's
|
||||
// own choice (`upgrade` with policy) included. Rewritten when the policy changes. Empty on a composition made by hand
|
||||
// (mesh-hand-compose), whose want the operator writes.
|
||||
type ComposedDigest struct {
|
||||
V int `json:"v"`
|
||||
Digest string `json:"digest"`
|
||||
Judgement Judgement `json:"judgement"`
|
||||
Against string `json:"against,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// UserListOnly says the composition differs from the one the node's have names only in the bus's user list (the
|
||||
// bus module's file `<module>.bus-users`), whatever the judgement says of its mount: the assigner writes such a
|
||||
// revision into the bus node's want at once, outside the record hold, at the fingerprint the node runs (ADR 0539
|
||||
// clause 7).
|
||||
UserListOnly bool `json:"user-list-only,omitempty"`
|
||||
Policy string `json:"policy,omitempty"`
|
||||
PolicyFrom string `json:"policy-from,omitempty"`
|
||||
PolicyWhy string `json:"policy-why,omitempty"`
|
||||
}
|
||||
|
||||
// The upgrade policies and their sources a composition's value names: catalogue.PolicyRoll, PolicyTogether and
|
||||
// PolicyRecord, and catalogue.FromPerson, FromModule, FromBus, FromData and FromDefault, the same words (the compose
|
||||
// package's test holds them equal; this package does not import the catalogue).
|
||||
var (
|
||||
composedPolicies = []string{"roll", "together", "record"}
|
||||
composedPolicyFroms = []string{"person", "module", "the bus", "irreplaceable data", "default"}
|
||||
)
|
||||
|
||||
func oneOf(v string, of []string) bool {
|
||||
for _, o := range of {
|
||||
if v == o {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c ComposedDigest) check() error {
|
||||
if err := checkDigest(c.Digest, "the composition's digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Against != "" {
|
||||
if err := checkDigest(c.Against, "the digest a move is judged against"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch c.Judgement {
|
||||
case JudgedRecreates, JudgedMovesData:
|
||||
if c.Against == "" {
|
||||
return fmt.Errorf("delivery: a move judged %s names the composition it is judged against", c.Judgement)
|
||||
}
|
||||
case JudgedNeither, JudgedUnchanged:
|
||||
case JudgedCannot:
|
||||
if c.Words == "" {
|
||||
return fmt.Errorf("delivery: a move that cannot be judged says why")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the judgement %q is not %s, %s, %s, %s or %s", c.Judgement, JudgedRecreates,
|
||||
JudgedMovesData, JudgedNeither, JudgedCannot, JudgedUnchanged)
|
||||
}
|
||||
if c.Policy != "" || c.PolicyFrom != "" {
|
||||
if !oneOf(c.Policy, composedPolicies) {
|
||||
return fmt.Errorf("delivery: the upgrade policy %q is not one of %v", c.Policy, composedPolicies)
|
||||
}
|
||||
if !oneOf(c.PolicyFrom, composedPolicyFroms) {
|
||||
return fmt.Errorf("delivery: the upgrade policy's source %q is not one of %v", c.PolicyFrom, composedPolicyFroms)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PolicyRecord is the policy whose moves the assigner holds for a person's yes (ADR 0539 clause 14).
|
||||
const PolicyRecord = "record"
|
||||
|
||||
// EncodeComposedDigest is c as JSON at Version; it refuses an unknown judgement, a digest not of the sha256 form, a
|
||||
// move judged to recreate or move data against nothing, and a move that cannot be judged without its reason.
|
||||
func EncodeComposedDigest(c ComposedDigest) ([]byte, error) {
|
||||
if err := c.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.V = Version
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodeComposedDigest reads the value beside a composition; it refuses an unknown version (ErrUnknownVersion) and
|
||||
// what EncodeComposedDigest refuses.
|
||||
func DecodeComposedDigest(data []byte) (ComposedDigest, error) {
|
||||
var c ComposedDigest
|
||||
if err := decode(data, &c); err != nil {
|
||||
return ComposedDigest{}, err
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return ComposedDigest{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SameComposedDigest says whether two values are the same, so the controller writes only when they differ.
|
||||
func SameComposedDigest(a, b ComposedDigest) bool { return a == b }
|
||||
@@ -0,0 +1,125 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1943/AC1: a node's composition bucket is named for the node, and a node with no name, or one a bucket refuses,
|
||||
// is refused, naming why.
|
||||
func TestComposedBucketFor(t *testing.T) {
|
||||
b, err := ComposedBucketFor("ace")
|
||||
if err != nil || b != "composed-ace" {
|
||||
t.Fatalf("ComposedBucketFor(ace) = %q, %v", b, err)
|
||||
}
|
||||
for _, bad := range []string{"", "a.b", "a*", "a b", "a>"} {
|
||||
if _, err := ComposedBucketFor(bad); err == nil || !strings.Contains(err.Error(), "node") {
|
||||
t.Errorf("ComposedBucketFor(%q) = %v, want a refusal naming the node", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: a module's composition key and the node's own key round-trip through one parser; an empty node, a
|
||||
// module named node, a bad fingerprint and a key of another shape are refused, naming why.
|
||||
func TestComposedKeys(t *testing.T) {
|
||||
fp := fp2of('c')
|
||||
key, err := ComposedKey("ace", "mesh-mail", fp)
|
||||
if err != nil || key != "ace.mesh-mail."+strings.Repeat("c", 64) {
|
||||
t.Fatalf("ComposedKey = %q, %v", key, err)
|
||||
}
|
||||
node, module, back, isNode, err := ParseComposedKey(key)
|
||||
if err != nil || node != "ace" || module != "mesh-mail" || back != fp || isNode {
|
||||
t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v, %v", key, node, module, back, isNode, err)
|
||||
}
|
||||
own, err := ComposedNodeKey("ace")
|
||||
if err != nil || own != "ace.node" {
|
||||
t.Fatalf("ComposedNodeKey = %q, %v", own, err)
|
||||
}
|
||||
node, module, back, isNode, err = ParseComposedKey(own)
|
||||
if err != nil || node != "ace" || module != "" || back != "" || !isNode {
|
||||
t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v, %v", own, node, module, back, isNode, err)
|
||||
}
|
||||
|
||||
refusals := []struct {
|
||||
node, module, fp, why string
|
||||
}{
|
||||
{"", "mesh-mail", fp, "node name is empty"},
|
||||
{"ace", "node", fp, `"node" is refused`},
|
||||
{"ace", "", fp, "module name is empty"},
|
||||
{"ace", "mesh-mail", "src2:abc", "not a fingerprint of the new form"},
|
||||
{"ace", "mesh-mail", "src1:" + strings.Repeat("c", 64), "not a fingerprint of the new form"},
|
||||
{"a.b", "mesh-mail", fp, "node name"},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if _, err := ComposedKey(r.node, r.module, r.fp); err == nil || !strings.Contains(err.Error(), r.why) {
|
||||
t.Errorf("ComposedKey(%q, %q, %q) = %v, want a refusal saying %q", r.node, r.module, r.fp, err, r.why)
|
||||
}
|
||||
}
|
||||
if _, err := ComposedNodeKey(""); err == nil {
|
||||
t.Errorf("ComposedNodeKey(\"\") accepted")
|
||||
}
|
||||
for _, bad := range []string{"", "ace", "ace.mesh-mail", "ace.node.x", "ace.mesh-mail.abc", ".mesh-mail." + strings.Repeat("c", 64),
|
||||
"ace.node." + strings.Repeat("c", 64), "ace.mesh-mail." + strings.Repeat("c", 64) + ".x", "want.ace.mesh-mail"} {
|
||||
if _, _, _, _, err := ParseComposedKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
|
||||
t.Errorf("ParseComposedKey(%q) = %v, want a refusal naming it", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: the controller's value beside a composition, its digest and the judgement of the move, round-trips; an
|
||||
// unknown judgement, a digest not of the sha256 form, a move judged against nothing, a cannot-judge without its
|
||||
// reason, and an upgrade policy or source outside the catalogue's words are refused, naming why; an unknown version is
|
||||
// refused as one. The module's upgrade policy and its source ride beside the judgement, for the assigner's hold.
|
||||
func TestComposedDigestValue(t *testing.T) {
|
||||
digest := "sha256:" + strings.Repeat("d", 64)
|
||||
against := "sha256:" + strings.Repeat("e", 64)
|
||||
for _, v := range []ComposedDigest{
|
||||
{Digest: digest, Judgement: JudgedNeither},
|
||||
{Digest: digest, Judgement: JudgedNeither, Against: against},
|
||||
{Digest: digest, Judgement: JudgedRecreates, Against: against, Words: "the container's image changes"},
|
||||
{Digest: digest, Judgement: JudgedMovesData, Against: against},
|
||||
{Digest: digest, Judgement: JudgedCannot, Words: "the composition the node runs is gone"},
|
||||
{Digest: digest, Judgement: JudgedUnchanged, Words: "equal to the declaration the node last applied"},
|
||||
{Digest: digest, Judgement: JudgedNeither, Policy: "record", PolicyFrom: "person", PolicyWhy: "the operator: its data"},
|
||||
{Digest: digest, Judgement: JudgedNeither, Policy: "roll", PolicyFrom: "default"},
|
||||
{Digest: digest, Judgement: JudgedNeither, Against: against, UserListOnly: true, Words: "only the bus's user list changes"},
|
||||
} {
|
||||
raw, err := EncodeComposedDigest(v)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeComposedDigest(%+v): %v", v, err)
|
||||
}
|
||||
back, err := DecodeComposedDigest(raw)
|
||||
v.V = Version
|
||||
if err != nil || !SameComposedDigest(back, v) {
|
||||
t.Errorf("round trip of %+v = %+v, %v", v, back, err)
|
||||
}
|
||||
}
|
||||
refusals := []struct {
|
||||
v ComposedDigest
|
||||
why string
|
||||
}{
|
||||
{ComposedDigest{Digest: digest, Judgement: "maybe"}, `judgement "maybe"`},
|
||||
{ComposedDigest{Digest: digest}, `judgement ""`},
|
||||
{ComposedDigest{Digest: "sha256:abc", Judgement: JudgedNeither}, "not sha256"},
|
||||
{ComposedDigest{Judgement: JudgedNeither}, "not sha256"},
|
||||
{ComposedDigest{Digest: digest, Judgement: JudgedRecreates}, "against"},
|
||||
{ComposedDigest{Digest: digest, Judgement: JudgedMovesData}, "against"},
|
||||
{ComposedDigest{Digest: digest, Judgement: JudgedCannot}, "why"},
|
||||
{ComposedDigest{Digest: digest, Judgement: JudgedNeither, Against: "sha256:zz"}, "not sha256"},
|
||||
{ComposedDigest{Digest: digest, Judgement: JudgedNeither, Policy: "sometimes", PolicyFrom: "person"}, `policy "sometimes"`},
|
||||
{ComposedDigest{Digest: digest, Judgement: JudgedNeither, Policy: "record"}, `source ""`},
|
||||
{ComposedDigest{Digest: digest, Judgement: JudgedNeither, PolicyFrom: "person"}, `policy ""`},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if _, err := EncodeComposedDigest(r.v); err == nil || !strings.Contains(err.Error(), r.why) {
|
||||
t.Errorf("EncodeComposedDigest(%+v) = %v, want a refusal saying %q", r.v, err, r.why)
|
||||
}
|
||||
}
|
||||
if _, err := DecodeComposedDigest([]byte(`{"v":2,"digest":"` + digest + `","judgement":"neither"}`)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("version 2 = %v, want ErrUnknownVersion", err)
|
||||
}
|
||||
if _, err := DecodeComposedDigest([]byte(`{"v":1,"digest":"` + digest + `","judgement":"perhaps"}`)); err == nil {
|
||||
t.Errorf("an unknown judgement was read")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// A composition's signature (novox/hq ADR 0539 clause 3): the controller signs each composition it writes with its
|
||||
// key, and the operator signs one made by hand with mesh-hand-compose with theirs. The node-engine trusts exactly these two
|
||||
// signers and applies nothing of a composition whose signature fails or whose signer is neither. The signature is an
|
||||
// ed25519 signature, as a declaration's is, of the composition's digest (ComposedDigestOf) after a context line, so a
|
||||
// signature made for anything else never verifies here.
|
||||
const composedSigningContext = "novox composition v1\n"
|
||||
|
||||
// Signature is who signed a composition and the signature: Signer the signer's ed25519 public key and Sig the
|
||||
// signature, both in standard base64.
|
||||
type Signature struct {
|
||||
Signer string `json:"signer"`
|
||||
Sig string `json:"sig"`
|
||||
}
|
||||
|
||||
func (s Signature) check() error {
|
||||
if key, err := base64.StdEncoding.DecodeString(s.Signer); err != nil || len(key) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("delivery: a composition's signer %q is not an ed25519 public key in base64", s.Signer)
|
||||
}
|
||||
if sig, err := base64.StdEncoding.DecodeString(s.Sig); err != nil || len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("delivery: a composition's signature is not an ed25519 signature in base64")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignComposed is c signed with key: its digest signed, and the key's public half named as the signer. A signature c
|
||||
// already carries is replaced.
|
||||
func SignComposed(c Composed, key ed25519.PrivateKey) (Composed, error) {
|
||||
if len(key) != ed25519.PrivateKeySize {
|
||||
return Composed{}, fmt.Errorf("delivery: the key to sign a composition with is not an ed25519 private key")
|
||||
}
|
||||
return SignComposedBy(c, key.Public().(ed25519.PublicKey), func(message []byte) ([]byte, error) {
|
||||
return ed25519.Sign(key, message), nil
|
||||
})
|
||||
}
|
||||
|
||||
// SignComposedBy is SignComposed for a signer that keeps its private half to itself (the controller's identity store
|
||||
// signs a message and hands back the signature): sign signs the message it is given with the key whose public half is
|
||||
// signer. A signature that does not verify against signer is refused, so a composition is never written signed by a
|
||||
// key other than the one it names.
|
||||
func SignComposedBy(c Composed, signer ed25519.PublicKey, sign func(message []byte) ([]byte, error)) (Composed, error) {
|
||||
if len(signer) != ed25519.PublicKeySize {
|
||||
return Composed{}, fmt.Errorf("delivery: the signer of a composition is not an ed25519 public key")
|
||||
}
|
||||
c.Signature = nil
|
||||
digest, err := ComposedDigestOf(c)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
message := []byte(composedSigningContext + digest)
|
||||
sig, err := sign(message)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
if !ed25519.Verify(signer, message, sig) {
|
||||
return Composed{}, fmt.Errorf("delivery: the signature made for the composition of %q on %s does not verify against its signer", c.Module, c.Node)
|
||||
}
|
||||
c.Signature = &Signature{Signer: base64.StdEncoding.EncodeToString(signer), Sig: base64.StdEncoding.EncodeToString(sig)}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// VerifyComposed is c's digest when c is signed by one of the trusted keys and its signature verifies against its
|
||||
// digest; otherwise it refuses, saying why, and nothing of c is to be applied: an unsigned composition, one whose
|
||||
// signer is not a trusted key, and one changed after it was signed.
|
||||
func VerifyComposed(c Composed, trusted []ed25519.PublicKey) (string, error) {
|
||||
if c.Signature == nil {
|
||||
return "", fmt.Errorf("delivery: the composition of %q on %s is unsigned, and only a signed one is applied", c.Module, c.Node)
|
||||
}
|
||||
if err := c.Signature.check(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
signer, _ := base64.StdEncoding.DecodeString(c.Signature.Signer)
|
||||
var key ed25519.PublicKey
|
||||
for _, t := range trusted {
|
||||
if t.Equal(ed25519.PublicKey(signer)) {
|
||||
key = t
|
||||
}
|
||||
}
|
||||
if key == nil {
|
||||
return "", fmt.Errorf("delivery: the composition of %q on %s is signed by %s, which is not a trusted signer (the controller's key or the operator's)",
|
||||
c.Module, c.Node, c.Signature.Signer)
|
||||
}
|
||||
digest, err := ComposedDigestOf(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig, _ := base64.StdEncoding.DecodeString(c.Signature.Sig)
|
||||
if !ed25519.Verify(key, []byte(composedSigningContext+digest), sig) {
|
||||
return "", fmt.Errorf("delivery: the composition of %q on %s does not match its signature: it was changed after it was signed, or signed for another",
|
||||
c.Module, c.Node)
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aSigningKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func aComposition() Composed {
|
||||
return Composed{Node: "ace", Module: "m", FP: fp2of('a'), Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x"}`)}}
|
||||
}
|
||||
|
||||
// 1943/AC5: a composition signed by the controller's key or by the operator's verifies against the two trusted keys,
|
||||
// keeps its digest, and survives its encoding; one signed by another key, unsigned, or changed by one byte after it was
|
||||
// signed does not verify, and nothing of it is to be applied.
|
||||
func TestACompositionIsSignedByATrustedKey(t *testing.T) {
|
||||
controllerPub, controller := aSigningKey(t)
|
||||
operatorPub, operator := aSigningKey(t)
|
||||
_, stranger := aSigningKey(t)
|
||||
trusted := []ed25519.PublicKey{controllerPub, operatorPub}
|
||||
c := aComposition()
|
||||
want, _ := ComposedDigestOf(c)
|
||||
|
||||
for name, key := range map[string]ed25519.PrivateKey{"controller": controller, "operator": operator} {
|
||||
signed, err := SignComposed(c, key)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
raw, err := EncodeComposed(signed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeComposed(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest, err := VerifyComposed(back, trusted)
|
||||
if err != nil || digest != want {
|
||||
t.Errorf("%s's signature: %q, %v; want the digest %s", name, digest, err, want)
|
||||
}
|
||||
if d, _ := ComposedDigestOf(back); d != want {
|
||||
t.Errorf("%s's signature changed the digest: %s", name, d)
|
||||
}
|
||||
|
||||
changed := strings.Replace(string(raw), `"content":"x"`, `"content":"y"`, 1)
|
||||
if changed == string(raw) {
|
||||
t.Fatal("the test changed nothing")
|
||||
}
|
||||
if tampered, err := DecodeComposed([]byte(changed)); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if _, err := VerifyComposed(tampered, trusted); err == nil || !strings.Contains(err.Error(), "signature") {
|
||||
t.Errorf("%s: a composition changed by one byte verified: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
byStranger, _ := SignComposed(c, stranger)
|
||||
if _, err := VerifyComposed(byStranger, trusted); err == nil || !strings.Contains(err.Error(), "not a trusted") {
|
||||
t.Errorf("a stranger's signature = %v, want refused as no trusted signer", err)
|
||||
}
|
||||
if _, err := VerifyComposed(c, trusted); err == nil || !strings.Contains(err.Error(), "unsigned") {
|
||||
t.Errorf("an unsigned composition = %v, want refused", err)
|
||||
}
|
||||
signed, _ := SignComposed(c, controller)
|
||||
forged := signed
|
||||
forged.Signature = &Signature{Signer: signed.Signature.Signer, Sig: byStranger.Signature.Sig}
|
||||
if _, err := VerifyComposed(forged, trusted); err == nil {
|
||||
t.Errorf("a stranger's signature under the controller's name verified")
|
||||
}
|
||||
if _, err := EncodeComposed(Composed{Node: "ace", Module: "m", FP: fp2of('a'), Resources: c.Resources,
|
||||
Signature: &Signature{Signer: "not base64!", Sig: "x"}}); err == nil {
|
||||
t.Errorf("a signature naming no key was encoded")
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC5: a signer that keeps its private half signs through a function, and the composition verifies as one signed
|
||||
// with the key; a function that signs with another key than the one named is refused.
|
||||
func TestACompositionIsSignedByAKeptKey(t *testing.T) {
|
||||
pub, priv := aSigningKey(t)
|
||||
_, other := aSigningKey(t)
|
||||
c := aComposition()
|
||||
signed, err := SignComposedBy(c, pub, func(m []byte) ([]byte, error) { return ed25519.Sign(priv, m), nil })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := VerifyComposed(signed, []ed25519.PublicKey{pub}); err != nil {
|
||||
t.Errorf("a composition signed through the function does not verify: %v", err)
|
||||
}
|
||||
if _, err := SignComposedBy(c, pub, func(m []byte) ([]byte, error) { return ed25519.Sign(other, m), nil }); err == nil {
|
||||
t.Errorf("a signature by another key than the one named was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// NodeOwn is what a node's own composition (`<node>.node`) carries beside its resources: what the declaration says of
|
||||
// the node as a whole (novox/hq ADR 0539 clause 2: adoption, accounts, identities and groups). A module's composition
|
||||
// carries none of it.
|
||||
type NodeOwn struct {
|
||||
// Adoption says the node is adopted and which of its modules are taken (ADR 0100, ADR 0103); nil for a converged node.
|
||||
Adoption *declaration.Adoption `json:"adoption,omitempty"`
|
||||
// Identities is the identity of every module assigned to the node (ADR 0328 §4), Groups and Libraries every library
|
||||
// it is reached by (ADR 0328 §3).
|
||||
Identities []declaration.Identity `json:"identities,omitempty"`
|
||||
Groups []declaration.Group `json:"groups,omitempty"`
|
||||
Libraries []declaration.Library `json:"libraries,omitempty"`
|
||||
// Provisioner is the account the provisioner's client runs as on the node (ADR 0349 §4).
|
||||
Provisioner string `json:"provisioner_account,omitempty"`
|
||||
}
|
||||
|
||||
func (o NodeOwn) empty() bool {
|
||||
return o.Adoption == nil && len(o.Identities) == 0 && len(o.Groups) == 0 && len(o.Libraries) == 0 && o.Provisioner == ""
|
||||
}
|
||||
|
||||
// Composed is a composition (ADR 0539 clause 1): one module's resources composed for one node at one fingerprint, or,
|
||||
// with Module and FP empty, the node's own resources and parts (Own). Each resource is a declaration resource as the
|
||||
// node-engine reads it, saying its module; it is kept as JSON and written canonically (its keys sorted, its spacing
|
||||
// dropped), so its digest (ComposedDigestOf) is a function of its content alone. Data says the module declares data,
|
||||
// so the node agent backs it up before it moves (ADR 0429 §14). Filled names the files of the module the provisioner
|
||||
// fills (ADR 0349 §5). A file over 64 KiB travels by digest and size (ByDigest), its content in the node's object
|
||||
// bucket (ADR 0539 clause 4).
|
||||
type Composed struct {
|
||||
V int
|
||||
Node string
|
||||
Module string
|
||||
FP string
|
||||
Data bool
|
||||
Resources []json.RawMessage
|
||||
Filled []string
|
||||
Own NodeOwn
|
||||
// Signature is the controller's or, made by hand, the operator's (ADR 0539 clause 3, SignComposed); it signs the
|
||||
// digest and is never part of it.
|
||||
Signature *Signature
|
||||
}
|
||||
|
||||
// composedWire is a composition as it is written.
|
||||
type composedWire struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module,omitempty"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
Data bool `json:"data,omitempty"`
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
Filled []string `json:"filled_files,omitempty"`
|
||||
Own *NodeOwn `json:"own,omitempty"`
|
||||
Signature *Signature `json:"signature,omitempty"`
|
||||
}
|
||||
|
||||
// canonicalJSON is raw with its object keys sorted and its spacing dropped, its numbers kept as written.
|
||||
func canonicalJSON(raw json.RawMessage) (json.RawMessage, error) {
|
||||
var v any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := d.Token(); err != io.EOF {
|
||||
return nil, fmt.Errorf("more after the value")
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (c Composed) check() error {
|
||||
if _, err := HaveNodeKey(c.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Module == "" {
|
||||
if c.FP != "" {
|
||||
return fmt.Errorf("delivery: the node's own composition names no fingerprint, and this one names %s", c.FP)
|
||||
}
|
||||
// It carries the filled paths of its own files, those of a module with no fingerprint of the new form moved
|
||||
// into it (issue 1945), and never a module's data.
|
||||
if c.Data {
|
||||
return fmt.Errorf("delivery: the node's own composition holds no module's data")
|
||||
}
|
||||
} else {
|
||||
if _, err := ComposedKey(c.Node, c.Module, c.FP); err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.Own.empty() {
|
||||
return fmt.Errorf("delivery: the composition of %s carries the node's own parts, which only <node>.node carries", c.Module)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
files := map[string][]*declaration.File{}
|
||||
for _, raw := range c.Resources {
|
||||
r, err := declaration.ParseResource(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delivery: the composition of %q on %s: %w", c.Module, c.Node, err)
|
||||
}
|
||||
if seen[r.Identity()] {
|
||||
return fmt.Errorf("delivery: the composition of %q on %s holds the resource %q twice", c.Module, c.Node, r.Identity())
|
||||
}
|
||||
seen[r.Identity()] = true
|
||||
if r.DeclaredModule() != c.Module {
|
||||
return fmt.Errorf("delivery: the resource %q is of %q, and this is the composition of %q on %s",
|
||||
r.Identity(), r.DeclaredModule(), c.Module, c.Node)
|
||||
}
|
||||
if f, ok := r.(*declaration.File); ok {
|
||||
if f.TravelsByDigest() {
|
||||
return fmt.Errorf("delivery: the file %q is over %d bytes and travels by digest (ByDigest), not whole",
|
||||
f.ID, declaration.FileReferenceThreshold)
|
||||
}
|
||||
files[f.Path] = append(files[f.Path], f)
|
||||
}
|
||||
}
|
||||
// A file the provisioner fills says no content, so a composition that does not say it is filled would have the
|
||||
// node-engine write it empty (issue 1945, live on 2026-10-25): refused, naming it.
|
||||
filledHere := make(map[string]bool, len(c.Filled))
|
||||
for _, path := range c.Filled {
|
||||
filledHere[path] = true
|
||||
}
|
||||
for path, fs := range files {
|
||||
for _, f := range fs {
|
||||
if f.OwnerAtRuntime == declaration.RuntimeOwned && !filledHere[path] && !saysContent(f) {
|
||||
return fmt.Errorf("delivery: the file %q says no content and the composition of %q on %s does not say "+
|
||||
"the provisioner fills %s: the node-engine would write it empty", f.ID, c.Module, c.Node, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, path := range c.Filled {
|
||||
if len(files[path]) == 0 {
|
||||
return fmt.Errorf("delivery: the composition of %q fills %q, and declares no file there", c.Module, path)
|
||||
}
|
||||
for _, f := range files[path] {
|
||||
if f.OwnerAtRuntime == "" {
|
||||
return fmt.Errorf("delivery: the file %q is filled by the provisioner and says no owner-at-runtime", f.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Signature != nil {
|
||||
if err := c.Signature.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saysContent says a file names what to write: content, bytes, a digest or a sealed value. A file the provisioner fills
|
||||
// names none of them.
|
||||
func saysContent(f *declaration.File) bool {
|
||||
return f.Content != "" || f.Bytes != "" || f.Digest != "" || f.Sealed != "" || f.VaultSealed != nil
|
||||
}
|
||||
|
||||
// wire is c checked and made canonical, ready to marshal.
|
||||
func (c Composed) wire() (composedWire, error) {
|
||||
if err := c.check(); err != nil {
|
||||
return composedWire{}, err
|
||||
}
|
||||
w := composedWire{V: Version, Node: c.Node, Module: c.Module, FP: c.FP, Data: c.Data, Filled: c.Filled,
|
||||
Signature: c.Signature, Resources: make([]json.RawMessage, len(c.Resources))}
|
||||
for i, r := range c.Resources {
|
||||
canon, err := canonicalJSON(r)
|
||||
if err != nil {
|
||||
return composedWire{}, fmt.Errorf("delivery: a resource of %q is not JSON: %w", c.Module, err)
|
||||
}
|
||||
w.Resources[i] = canon
|
||||
}
|
||||
if !c.Own.empty() {
|
||||
own := c.Own
|
||||
w.Own = &own
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// EncodeComposed is c as JSON at Version, each resource canonical. It refuses a composition whose node or fingerprint
|
||||
// a key refuses, a resource its kind refuses (an action among them), a resource of another module, a module's
|
||||
// composition carrying the node's own parts, a file over 64 KiB carried whole, and a filled path no file declares.
|
||||
func EncodeComposed(c Composed) ([]byte, error) {
|
||||
w, err := c.wire()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// DecodeComposed reads a composition; it refuses an unknown version (ErrUnknownVersion), a field it does not know, and
|
||||
// what EncodeComposed refuses.
|
||||
func DecodeComposed(data []byte) (Composed, error) {
|
||||
if err := checkVersion(data); err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
var w composedWire
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&w); err != nil {
|
||||
return Composed{}, fmt.Errorf("delivery: a composition does not fit its shape: %w", err)
|
||||
}
|
||||
c := Composed{V: w.V, Node: w.Node, Module: w.Module, FP: w.FP, Data: w.Data, Resources: w.Resources,
|
||||
Filled: w.Filled, Signature: w.Signature}
|
||||
if w.Own != nil {
|
||||
c.Own = *w.Own
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ComposedDigestOf is a composition's digest, "sha256:<hex>", of its encoding without its signature. A want names it
|
||||
// (ADR 0539 clause 1), the controller writes it beside the composition, and the signature signs it.
|
||||
func ComposedDigestOf(c Composed) (string, error) {
|
||||
c.Signature = nil
|
||||
raw, err := EncodeComposed(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// SameComposed says whether two compositions are the same value, their signatures included, so the controller writes
|
||||
// only when they differ.
|
||||
func SameComposed(a, b Composed) bool {
|
||||
ra, errA := EncodeComposed(a)
|
||||
rb, errB := EncodeComposed(b)
|
||||
return errA == nil && errB == nil && bytes.Equal(ra, rb)
|
||||
}
|
||||
|
||||
// ByDigest is c with every file over 64 KiB given by its digest and size, and the content of each by digest, for the
|
||||
// writer to put in the node's object bucket (declaration.FileBucketFor) under declaration.ObjectName before it writes
|
||||
// the composition (ADR 0539 clause 4, ADR 0488 §3). A file that is sealed, filled with secrets or written into another
|
||||
// stays as it is (declaration.File.TravelsByDigest).
|
||||
func (c Composed) ByDigest() (Composed, map[string][]byte, error) {
|
||||
out := c
|
||||
out.Resources = make([]json.RawMessage, len(c.Resources))
|
||||
objects := map[string][]byte{}
|
||||
for i, raw := range c.Resources {
|
||||
out.Resources[i] = raw
|
||||
r, err := declaration.ParseResource(raw)
|
||||
if err != nil {
|
||||
return Composed{}, nil, err
|
||||
}
|
||||
f, ok := r.(*declaration.File)
|
||||
if !ok || !f.TravelsByDigest() {
|
||||
continue
|
||||
}
|
||||
ref, body, err := f.AsReference()
|
||||
if err != nil {
|
||||
return Composed{}, nil, err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return Composed{}, nil, err
|
||||
}
|
||||
delete(fields, "content")
|
||||
delete(fields, "bytes")
|
||||
fields["digest"], _ = json.Marshal(ref.Digest)
|
||||
fields["size"], _ = json.Marshal(ref.Size)
|
||||
rewritten, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return Composed{}, nil, err
|
||||
}
|
||||
out.Resources[i] = rewritten
|
||||
objects[ref.Digest] = body
|
||||
}
|
||||
return out, objects, nil
|
||||
}
|
||||
|
||||
// SplitComposed splits a node's composed resources into its compositions, by key (ComposedKey, ComposedNodeKey): each
|
||||
// resource into the composition of the module it says, under that module's fingerprint in fps, and a resource that
|
||||
// says no module into the node's own, with own. Each filled path goes with the module whose file declares it. The order
|
||||
// of the resources is kept within each composition. A resource of a module fps names no fingerprint for is refused,
|
||||
// naming it. Data is the caller's to set, from the module's manifest.
|
||||
func SplitComposed(node string, resources []json.RawMessage, fps map[string]string, filled []string, own NodeOwn) (map[string]Composed, error) {
|
||||
nodeKey, err := ComposedNodeKey(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]Composed{nodeKey: {Node: node, Own: own, Resources: []json.RawMessage{}}}
|
||||
pathOf := map[string]string{}
|
||||
for _, raw := range resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Module string `json:"module"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, fmt.Errorf("delivery: a resource of %s is not JSON: %w", node, err)
|
||||
}
|
||||
key := nodeKey
|
||||
if head.Module != "" {
|
||||
fp, ok := fps[head.Module]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("delivery: the resource %q is of %q, whose fingerprint on %s is not given", head.ID, head.Module, node)
|
||||
}
|
||||
if key, err = ComposedKey(node, head.Module, fp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := out[key]; !ok {
|
||||
out[key] = Composed{Node: node, Module: head.Module, FP: fp}
|
||||
}
|
||||
}
|
||||
if head.Type == string(declaration.TypeFile) && head.Module != "" {
|
||||
pathOf[head.Path] = key
|
||||
}
|
||||
c := out[key]
|
||||
c.Resources = append(c.Resources, raw)
|
||||
out[key] = c
|
||||
}
|
||||
for _, path := range filled {
|
||||
key, ok := pathOf[path]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("delivery: the provisioner fills %q on %s, and no module's file declares it", path, node)
|
||||
}
|
||||
c := out[key]
|
||||
c.Filled = append(c.Filled, path)
|
||||
out[key] = c
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// capturedDeclaration is a node's declaration as the controller's `plan` verb answered it on 2026-10-24 at 16:19
|
||||
// (testdata/declaration-<node>.json.gz; the resources carrying a sealed value or a secret taken out, and the filled
|
||||
// files that named them), never typed by hand.
|
||||
type capturedDeclaration struct {
|
||||
Builds map[string]string `json:"builds"`
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
Filled []string `json:"filled_files"`
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
}
|
||||
|
||||
func readCaptured(t *testing.T, node string) capturedDeclaration {
|
||||
t.Helper()
|
||||
f, err := os.Open("testdata/declaration-" + node + ".json.gz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
z, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := io.ReadAll(z)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var c capturedDeclaration
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// fpOfBuild stands for each module's fingerprint in a captured declaration, which names builds, not fingerprints.
|
||||
func fpOfBuild(build string) string {
|
||||
var b strings.Builder
|
||||
for b.Len() < 64 {
|
||||
b.WriteString(fmt.Sprintf("%x", []byte(build)))
|
||||
}
|
||||
return SourcePrefix2 + b.String()[:64]
|
||||
}
|
||||
|
||||
func splitCaptured(t *testing.T, node string, c capturedDeclaration) map[string]Composed {
|
||||
t.Helper()
|
||||
fps := map[string]string{}
|
||||
for m, b := range c.Builds {
|
||||
fps[m] = fpOfBuild(b)
|
||||
}
|
||||
// mesh-wireguard's resources are composed with no build of it named; its fingerprint is the indexer's, as every
|
||||
// module's is, and stood in for here by its name.
|
||||
for _, r := range c.Resources {
|
||||
var head struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
_ = json.Unmarshal(r, &head)
|
||||
if _, ok := fps[head.Module]; head.Module != "" && !ok {
|
||||
fps[head.Module] = fpOfBuild(head.Module)
|
||||
}
|
||||
}
|
||||
split, err := SplitComposed(node, c.Resources, fps, c.Filled, NodeOwn{Provisioner: c.Provisioner})
|
||||
if err != nil {
|
||||
t.Fatalf("SplitComposed(%s): %v", node, err)
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
func canonical(t *testing.T, raw json.RawMessage) string {
|
||||
t.Helper()
|
||||
var v any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := json.Marshal(v)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// 1943/AC2: each module's slice of a captured declaration of each node, and the node's own resources, round-trip
|
||||
// through the composition's encoding with the same digest; together they are the declaration's resources, each once,
|
||||
// each in the composition of the module it says.
|
||||
func TestCompositionsOfACapturedDeclaration(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, node := range []string{"ace", "g14"} {
|
||||
t.Run(node, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
oneCapturedDeclaration(t, node)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func oneCapturedDeclaration(t *testing.T, node string) {
|
||||
{
|
||||
c := readCaptured(t, node)
|
||||
split := splitCaptured(t, node, c)
|
||||
modules := map[string]bool{}
|
||||
for m := range c.Builds {
|
||||
modules[m] = true
|
||||
}
|
||||
var union []string
|
||||
for key, comp := range split {
|
||||
n, module, fp, isNode, err := ParseComposedKey(key)
|
||||
if err != nil || n != node || comp.Node != node || comp.Module != module || comp.FP != fp {
|
||||
t.Fatalf("%s: key %q holds %s/%s/%s (%v)", node, key, comp.Node, comp.Module, comp.FP, err)
|
||||
}
|
||||
if !isNode && !modules[module] && module != "mesh-wireguard" {
|
||||
t.Errorf("%s: a composition of %s, which the declaration names no build of", node, module)
|
||||
}
|
||||
raw, err := EncodeComposed(comp)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: EncodeComposed(%s): %v", node, key, err)
|
||||
}
|
||||
back, err := DecodeComposed(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: DecodeComposed(%s): %v", node, key, err)
|
||||
}
|
||||
if !SameComposed(back, comp) {
|
||||
t.Errorf("%s: %s did not round-trip", node, key)
|
||||
}
|
||||
// SameComposed encodes both, so the digest of what was read back, a hash of its encoding, is the one written.
|
||||
if d, err := ComposedDigestOf(back); err != nil || !digestForm.MatchString(d) {
|
||||
t.Errorf("%s: %s digests %q (%v)", node, key, d, err)
|
||||
}
|
||||
for _, r := range back.Resources {
|
||||
var head struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
_ = json.Unmarshal(r, &head)
|
||||
if head.Module != module {
|
||||
t.Errorf("%s: %s holds a resource of %q", node, key, head.Module)
|
||||
}
|
||||
union = append(union, canonical(t, r))
|
||||
}
|
||||
}
|
||||
var want []string
|
||||
for _, r := range c.Resources {
|
||||
want = append(want, canonical(t, r))
|
||||
}
|
||||
sort.Strings(union)
|
||||
sort.Strings(want)
|
||||
if strings.Join(union, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("%s: the compositions hold %d resources, the declaration %d, or they differ", node, len(union), len(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC2: one changed byte of a resource is another digest; the same composition written with its keys in another
|
||||
// order is the same digest.
|
||||
func TestComposedDigestFollowsTheContent(t *testing.T) {
|
||||
c := readCaptured(t, "g14")
|
||||
split := splitCaptured(t, "g14", c)
|
||||
var comp Composed
|
||||
for _, v := range split {
|
||||
if v.Module != "" && len(v.Resources) > 1 {
|
||||
comp = v
|
||||
break
|
||||
}
|
||||
}
|
||||
d1, _ := ComposedDigestOf(comp)
|
||||
var m map[string]json.RawMessage
|
||||
_ = json.Unmarshal(comp.Resources[0], &m)
|
||||
var keys []string
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(keys)))
|
||||
var parts []string
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%q : %s", k, m[k]))
|
||||
}
|
||||
reordered := json.RawMessage("{ " + strings.Join(parts, ",\n ") + " }")
|
||||
shuffled := comp
|
||||
shuffled.Resources = append([]json.RawMessage{reordered}, comp.Resources[1:]...)
|
||||
if d2, _ := ComposedDigestOf(shuffled); d2 != d1 {
|
||||
t.Errorf("the same resources in another order and spacing digest %s and %s", d1, d2)
|
||||
}
|
||||
changed := comp
|
||||
changed.Resources = append([]json.RawMessage{json.RawMessage(strings.Replace(canonical(t, comp.Resources[0]), `"id":"`, `"id":"x`, 1))}, comp.Resources[1:]...)
|
||||
if d3, _ := ComposedDigestOf(changed); d3 == d1 {
|
||||
t.Errorf("a changed resource kept its digest")
|
||||
}
|
||||
revised := comp
|
||||
revised.Data = !comp.Data
|
||||
if d4, _ := ComposedDigestOf(revised); d4 == d1 {
|
||||
t.Errorf("a changed data flag kept its digest")
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC2: a file over 64 KiB is carried by digest and size, its content handed back to be put in the node's object
|
||||
// bucket; a composition that carries such a file whole is refused.
|
||||
func TestAComposedFileOver64KiBIsCarriedByDigest(t *testing.T) {
|
||||
c := readCaptured(t, "g14")
|
||||
split := splitCaptured(t, "g14", c)
|
||||
var comp Composed
|
||||
var at int
|
||||
for _, v := range split {
|
||||
for i, r := range v.Resources {
|
||||
var f map[string]any
|
||||
_ = json.Unmarshal(r, &f)
|
||||
if f["type"] == "file" && v.Module != "" && f["bytes"] == nil && f["into"] == nil {
|
||||
comp, at = v, i
|
||||
break
|
||||
}
|
||||
}
|
||||
if comp.Module != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
var f map[string]any
|
||||
_ = json.Unmarshal(comp.Resources[at], &f)
|
||||
// Grown past the threshold from a captured file: no captured file of this node is over 64 KiB.
|
||||
body := strings.Repeat("x", declaration.FileReferenceThreshold+1)
|
||||
f["content"] = body
|
||||
big, _ := json.Marshal(f)
|
||||
comp.Resources = append([]json.RawMessage{}, comp.Resources...)
|
||||
comp.Resources[at] = big
|
||||
if _, err := EncodeComposed(comp); err == nil || !strings.Contains(err.Error(), "by digest") {
|
||||
t.Fatalf("a file over 64 KiB carried whole = %v, want refused", err)
|
||||
}
|
||||
byDigest, objects, err := comp.ByDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ref map[string]any
|
||||
_ = json.Unmarshal(byDigest.Resources[at], &ref)
|
||||
digest := declaration.DigestOf([]byte(body))
|
||||
if ref["digest"] != digest || ref["size"] != float64(len(body)) || ref["content"] != nil {
|
||||
t.Errorf("the file became %v, want digest %s and size %d", ref, digest, len(body))
|
||||
}
|
||||
if string(objects[digest]) != body || len(objects) != 1 {
|
||||
t.Errorf("the objects handed back are %d, want the file's content under its digest", len(objects))
|
||||
}
|
||||
if _, err := EncodeComposed(byDigest); err != nil {
|
||||
t.Errorf("the composition by digest is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC2: an unknown version is refused as one; a module's composition carrying the node's own parts, a resource of
|
||||
// another module, a resource its kind refuses, or no fingerprint, is refused, naming why.
|
||||
func TestComposedRefusals(t *testing.T) {
|
||||
fp := fp2of('a')
|
||||
file := json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x"}`)
|
||||
ok := Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{file}}
|
||||
raw, err := EncodeComposed(ok)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DecodeComposed(bytes.Replace(raw, []byte(`"v":1`), []byte(`"v":2`), 1)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("version 2 = %v, want ErrUnknownVersion", err)
|
||||
}
|
||||
refusals := []struct {
|
||||
c Composed
|
||||
why string
|
||||
}{
|
||||
{Composed{Node: "ace", Module: "m", Resources: []json.RawMessage{file}}, "fingerprint"},
|
||||
{Composed{Node: "", Module: "m", FP: fp, Resources: []json.RawMessage{file}}, "node name is empty"},
|
||||
{Composed{Node: "ace", Module: "other", FP: fp, Resources: []json.RawMessage{file}}, `of "m"`},
|
||||
{Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{file}, Own: NodeOwn{Provisioner: "p"}}, "node's own"},
|
||||
{Composed{Node: "ace", FP: fp, Resources: []json.RawMessage{}}, "names no fingerprint"},
|
||||
{Composed{Node: "ace", Resources: []json.RawMessage{file}}, `of "m"`},
|
||||
{Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"x","image":"y"}`)}}, `"image"`},
|
||||
{Composed{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage(`{"id":"m.a","type":"action","module":"m"}`)}}, "action"},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if _, err := EncodeComposed(r.c); err == nil || !strings.Contains(err.Error(), r.why) {
|
||||
t.Errorf("EncodeComposed(%s/%s) = %v, want a refusal saying %q", r.c.Node, r.c.Module, err, r.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC2 (issue 1150): a module whose name holds a dot keys its have, its want and its composition escaped, and the
|
||||
// parsers give its name back; a name with a wildcard, whitespace or an escape of its own is still refused.
|
||||
func TestADottedModuleKeysEscaped(t *testing.T) {
|
||||
fp := fp2of('f')
|
||||
have, err := HaveKey("ace", "novox.be")
|
||||
if err != nil || have != "ace.novox=2ebe" {
|
||||
t.Fatalf("HaveKey = %q, %v", have, err)
|
||||
}
|
||||
if n, m, isNode, err := ParseHaveKey(have); err != nil || n != "ace" || m != "novox.be" || isNode {
|
||||
t.Errorf("ParseHaveKey(%q) = %q, %q, %v, %v", have, n, m, isNode, err)
|
||||
}
|
||||
want, err := WantKey("ace", "novox.be")
|
||||
if err != nil || want != "want.ace.novox=2ebe" {
|
||||
t.Fatalf("WantKey = %q, %v", want, err)
|
||||
}
|
||||
if n, m, err := ParseWantKey(want); err != nil || n != "ace" || m != "novox.be" {
|
||||
t.Errorf("ParseWantKey(%q) = %q, %q, %v", want, n, m, err)
|
||||
}
|
||||
comp, err := ComposedKey("ace", "novox.be", fp)
|
||||
if err != nil || comp != "ace.novox=2ebe."+strings.Repeat("f", 64) {
|
||||
t.Fatalf("ComposedKey = %q, %v", comp, err)
|
||||
}
|
||||
if n, m, back, _, err := ParseComposedKey(comp); err != nil || n != "ace" || m != "novox.be" || back != fp {
|
||||
t.Errorf("ParseComposedKey(%q) = %q, %q, %q, %v", comp, n, m, back, err)
|
||||
}
|
||||
for _, bad := range []string{"m*", "m x", "m=2e", "m>", ".m", "m.", "a..b"} {
|
||||
if _, err := HaveKey("ace", bad); err == nil {
|
||||
t.Errorf("HaveKey(ace, %q) accepted", bad)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"ace.m=2", "ace.m=2E", "ace.m=41", "ace.=6eode"} {
|
||||
if _, _, _, err := ParseHaveKey(bad); err == nil {
|
||||
t.Errorf("ParseHaveKey(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The node's own composition carries the filled paths of the files moved into it, the files of a module with no
|
||||
// fingerprint of the new form: the provisioner fills them all the same (issue 1945, live on 2026-10-25: the paths were
|
||||
// dropped and the node-engine wrote those files empty). It still holds no module's data.
|
||||
func TestTheNodesOwnCompositionCarriesTheFilledPathsOfItsFiles(t *testing.T) {
|
||||
filled := json.RawMessage(`{"id":"power.unit","type":"file","path":"/etc/systemd/system/p.service","mode":"0644","owner-at-runtime":"owned"}`)
|
||||
own := Composed{Node: "ace", Resources: []json.RawMessage{filled}, Filled: []string{"/etc/systemd/system/p.service"},
|
||||
Own: NodeOwn{Provisioner: "mesh-provisioner-client"}}
|
||||
raw, err := EncodeComposed(own)
|
||||
if err != nil {
|
||||
t.Fatalf("the node's own composition with a filled file: %v", err)
|
||||
}
|
||||
back, err := DecodeComposed(raw)
|
||||
if err != nil || len(back.Filled) != 1 {
|
||||
t.Fatalf("read back: %v %v", back.Filled, err)
|
||||
}
|
||||
if _, err := EncodeComposed(Composed{Node: "ace", Data: true, Resources: []json.RawMessage{filled},
|
||||
Filled: own.Filled}); err == nil {
|
||||
t.Error("the node's own composition was let hold a module's data")
|
||||
}
|
||||
}
|
||||
|
||||
// A file the provisioner fills says no content: in a composition that does not say it is filled, the node-engine would
|
||||
// write it empty. Such a composition is refused, in the node's own as in a module's (issue 1945).
|
||||
func TestAFileWithNoContentThatIsNotFilledIsRefused(t *testing.T) {
|
||||
fp := fp2of('a')
|
||||
for _, c := range []Composed{
|
||||
{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"power.unit","type":"file","path":"/etc/p","mode":"0644","owner-at-runtime":"owned"}`)}},
|
||||
{Node: "ace", Module: "m", FP: fp, Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"m.f","type":"file","module":"m","path":"/etc/m","mode":"0644","owner-at-runtime":"owned"}`)}},
|
||||
} {
|
||||
if _, err := EncodeComposed(c); err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("%s/%s: %v, want a refusal saying it would be written empty", c.Node, c.Module, err)
|
||||
}
|
||||
}
|
||||
// A file that says its content, even an empty one, is written as it says.
|
||||
if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":""}`)}}); err != nil {
|
||||
t.Errorf("a file that says it is empty: %v", err)
|
||||
}
|
||||
}
|
||||
+72
-22
@@ -11,7 +11,8 @@
|
||||
// `node`. A presence key is `<node>` for a node and `part.<part>.<instance>` for a part's instance, so a node may
|
||||
// not be named `part`. A pulse key is `<bucket>.<key>`: the full name of the bucket watched, then that bucket's key,
|
||||
// which may hold dots, so it splits at the first dot. A name is refused when it is empty or holds a dot, a wildcard,
|
||||
// whitespace or a character a bus key refuses, and the error names it.
|
||||
// whitespace or a character a bus key refuses, and the error names it; but a module's name may hold dots between its
|
||||
// parts (`novox.be`), and keys with each dot escaped as EscapeName writes it (`novox=2ebe`, issue 1150).
|
||||
//
|
||||
// **Values** are JSON of under about 500 bytes, each carrying its version in `v`. A reader refuses a value whose
|
||||
// version is missing or one it does not know, with an error that is ErrUnknownVersion (research 063 section 05
|
||||
@@ -35,7 +36,7 @@
|
||||
// builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the
|
||||
// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte.
|
||||
//
|
||||
// Standard library only, and this module's own asks package.
|
||||
// Standard library only, and this module's own asks and declaration packages (a composition holds declaration resources).
|
||||
package delivery
|
||||
|
||||
import (
|
||||
@@ -89,6 +90,10 @@ const (
|
||||
// for which it had written a have key; the key is never deleted (issue 1369, ADR 0408 §4), so a reader can tell
|
||||
// that the module stopped.
|
||||
PhaseRemoved Phase = "removed"
|
||||
// PhaseHeld is a module whose backup before an apply failed: it keeps running what it has, nothing of the new
|
||||
// composition is applied, and the node waits for the answer in its want (novox/hq ADR 0539 clause 16). Detail
|
||||
// carries the backup's own words.
|
||||
PhaseHeld Phase = "held"
|
||||
)
|
||||
|
||||
// Health is what the module's own check says.
|
||||
@@ -164,13 +169,45 @@ func HaveKey(node, module string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkName("module", module); err != nil {
|
||||
part, err := moduleKeyPart(module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if module == "node" {
|
||||
return "", fmt.Errorf(`delivery: the module name "node" is refused: %s.node is the node's own key`, node)
|
||||
}
|
||||
return node + "." + module, nil
|
||||
return node + "." + part, nil
|
||||
}
|
||||
|
||||
var moduleNameOK = regexp.MustCompile(`^[-/_a-zA-Z0-9]+(\.[-/_a-zA-Z0-9]+)*$`)
|
||||
|
||||
// moduleKeyPart is a module's name as a key part (issue 1150): a name may hold a dot (`novox.be`), which a key part
|
||||
// may not, so a dot is escaped as EscapeName writes it (`novox=2ebe`); a name of letters, digits, `-`, `/` and `_` keys
|
||||
// as it is. A name that is empty, or holds a wildcard, whitespace, `=`, or an empty part between dots, is refused.
|
||||
func moduleKeyPart(module string) (string, error) {
|
||||
if module == "" {
|
||||
return "", fmt.Errorf("delivery: the module name is empty")
|
||||
}
|
||||
if !moduleNameOK.MatchString(module) {
|
||||
return "", fmt.Errorf("delivery: the module name %q holds a wildcard, whitespace, '=', an empty part between dots or a character a key refuses", module)
|
||||
}
|
||||
return EscapeName(module), nil
|
||||
}
|
||||
|
||||
// parseModuleKeyPart is the inverse of moduleKeyPart; it refuses a part that is not the one moduleKeyPart writes for
|
||||
// its name (an escape of a character that keys as it is, an upper-case escape, an escape of a refused character).
|
||||
func parseModuleKeyPart(part string) (string, error) {
|
||||
if err := checkName("module", part); err != nil {
|
||||
return "", err
|
||||
}
|
||||
module, err := UnescapeName(part)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if back, err := moduleKeyPart(module); err != nil || back != part {
|
||||
return "", fmt.Errorf("delivery: the module part %q is not a module name as a key writes it", part)
|
||||
}
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// HaveNodeKey is the key, in HaveBucket, of a node's own facts: `<node>.node`.
|
||||
@@ -190,12 +227,13 @@ func ParseHaveKey(key string) (node, module string, isNode bool, err error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
|
||||
}
|
||||
if err := checkName("module", module); err != nil {
|
||||
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
|
||||
}
|
||||
if module == "node" {
|
||||
return node, "", true, nil
|
||||
}
|
||||
module, err = parseModuleKeyPart(module)
|
||||
if err != nil {
|
||||
return "", "", false, fmt.Errorf("delivery: %q is not a have key: %w", key, err)
|
||||
}
|
||||
return node, module, false, nil
|
||||
}
|
||||
|
||||
@@ -282,24 +320,31 @@ func PulseTTL(moveAge time.Duration) (ttl time.Duration, stalled bool) {
|
||||
// Have is a module's state on a node, written by that node's node-engine under HaveKey. FP is the module's
|
||||
// fingerprint once the indexer exists (design 47 release 3); until then Build is the build the node was sent, and
|
||||
// the view compares by build.
|
||||
//
|
||||
// Composed is the digest of the composition the node runs for the module (novox/hq ADR 0539 clause 9): the
|
||||
// controller judges a move against it (clause 14), and the assigner reads it to know a want was applied.
|
||||
type Have struct {
|
||||
V int `json:"v"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
Phase Phase `json:"phase"`
|
||||
Health Health `json:"health"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
V int `json:"v"`
|
||||
FP string `json:"fp,omitempty"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Build string `json:"build,omitempty"`
|
||||
Phase Phase `json:"phase"`
|
||||
Health Health `json:"health"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole:
|
||||
// this package names the facts and does not type them.
|
||||
//
|
||||
// Composed is the digest of the node's own composition it runs (ADR 0539 clause 9).
|
||||
type HaveNode struct {
|
||||
V int `json:"v"`
|
||||
Windows json.RawMessage `json:"windows,omitempty"`
|
||||
Busy json.RawMessage `json:"busy,omitempty"`
|
||||
Strays json.RawMessage `json:"strays,omitempty"`
|
||||
Filters json.RawMessage `json:"filters,omitempty"`
|
||||
Profile json.RawMessage `json:"profile,omitempty"`
|
||||
V int `json:"v"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Windows json.RawMessage `json:"windows,omitempty"`
|
||||
Busy json.RawMessage `json:"busy,omitempty"`
|
||||
Strays json.RawMessage `json:"strays,omitempty"`
|
||||
Filters json.RawMessage `json:"filters,omitempty"`
|
||||
Profile json.RawMessage `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
|
||||
@@ -347,9 +392,14 @@ func decode(data []byte, into any) error {
|
||||
|
||||
func (h Have) check() error {
|
||||
switch h.Phase {
|
||||
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved:
|
||||
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld:
|
||||
default:
|
||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed or removed", h.Phase)
|
||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed or held", h.Phase)
|
||||
}
|
||||
if h.Composed != "" {
|
||||
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch h.Health {
|
||||
case HealthOK, HealthFailed, HealthWaits, HealthUnknown:
|
||||
@@ -424,7 +474,7 @@ func SameHave(a, b Have) bool { return a == b }
|
||||
|
||||
// SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing.
|
||||
func SameHaveNode(a, b HaveNode) bool {
|
||||
return a.V == b.V && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
|
||||
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile)
|
||||
}
|
||||
|
||||
|
||||
@@ -73,3 +73,20 @@ func DecodeControllerGrant(data []byte) (ControllerGrant, error) {
|
||||
|
||||
// SameControllerGrant says whether two grants are the same value.
|
||||
func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// NamesNode says whether the grant names module's accounts on node: its own `<node>.<module>` or a process's
|
||||
// `<node>.<module>.<process>`. A grant that names no account (a module that does not speak on the bus) names every
|
||||
// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR
|
||||
// 0546 clause 4).
|
||||
func (g ControllerGrant) NamesNode(node, module string) bool {
|
||||
if len(g.Accounts) == 0 {
|
||||
return true
|
||||
}
|
||||
own := node + "." + module
|
||||
for _, a := range g.Accounts {
|
||||
if a == own || strings.HasPrefix(a, own+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Whether the artifact store still holds what a done key names (novox/hq issue 1937). A build module pushes a bundle or
|
||||
// an archive as a bare blob (ADR 0498 §1), and the store's nightly collector deletes every blob no manifest names; only
|
||||
// the controller's take-in and sweep hold one by its manifest. A done key the controller has not taken in can therefore
|
||||
// name a blob that is gone, and reusing it sends a definition every machine fails on. So the builder asks before it
|
||||
// reuses a done key, and the controller says when a take-in found a gone blob.
|
||||
const (
|
||||
// HeldVerb is the controller seat's verb the builder asks, as a module's invokes names it: references in, each
|
||||
// answered held or gone.
|
||||
HeldVerb = "seat:mesh-controller.held"
|
||||
// ArtifactsGoneEvent is the controller seat's event said when a take-in is refused because the store does not hold
|
||||
// an artifact the key names: as a consumer names it, `mesh-controller.artifacts-gone`.
|
||||
ArtifactsGoneEvent = "artifacts-gone"
|
||||
// ArtifactsGoneSubject is where it is published (the seat's event subject).
|
||||
ArtifactsGoneSubject = "mesh.seat.mesh-controller.event." + ArtifactsGoneEvent
|
||||
)
|
||||
|
||||
// HeldAnswer is the answer of HeldVerb: the references the store holds, and those it answered 404 for. A reference the
|
||||
// store could not be asked about is in neither, and named in Unknown with why.
|
||||
type HeldAnswer struct {
|
||||
Store string `json:"store,omitempty"`
|
||||
Held []string `json:"held"`
|
||||
Gone []string `json:"gone"`
|
||||
Unknown map[string]string `json:"unknown,omitempty"`
|
||||
}
|
||||
|
||||
// DecodeHeldAnswer reads HeldVerb's answer: the verb answers {output, ok, answer}, the answer being HeldAnswer. A
|
||||
// reference asked that the answer names in none of held, gone and unknown is an error: an answer that leaves one out
|
||||
// would let a key with a gone blob be reused.
|
||||
func DecodeHeldAnswer(raw []byte, asked []string) (HeldAnswer, error) {
|
||||
var wrapped struct {
|
||||
Output string `json:"output"`
|
||||
OK bool `json:"ok"`
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrapped); err != nil {
|
||||
return HeldAnswer{}, fmt.Errorf("the answer of %s is not {output, ok, answer}: %w", HeldVerb, err)
|
||||
}
|
||||
if !wrapped.OK || len(wrapped.Answer) == 0 {
|
||||
return HeldAnswer{}, fmt.Errorf("%s did not answer: %s", HeldVerb, wrapped.Output)
|
||||
}
|
||||
var a HeldAnswer
|
||||
if err := json.Unmarshal(wrapped.Answer, &a); err != nil {
|
||||
return HeldAnswer{}, fmt.Errorf("the answer of %s is not {held, gone}: %w", HeldVerb, err)
|
||||
}
|
||||
named := map[string]bool{}
|
||||
for _, r := range a.Held {
|
||||
named[r] = true
|
||||
}
|
||||
for _, r := range a.Gone {
|
||||
named[r] = true
|
||||
}
|
||||
for r := range a.Unknown {
|
||||
named[r] = true
|
||||
}
|
||||
for _, r := range asked {
|
||||
if !named[r] {
|
||||
return HeldAnswer{}, fmt.Errorf("the answer of %s names nothing of %s", HeldVerb, r)
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ArtifactsGone is the body of ArtifactsGoneEvent: the module whose builder's key the controller would not take in,
|
||||
// the key and its fingerprint, and every reference of it the store does not hold.
|
||||
type ArtifactsGone struct {
|
||||
Event string `json:"event"`
|
||||
Module string `json:"module"`
|
||||
Key string `json:"key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Gone []string `json:"gone"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// EncodeArtifactsGone is the event's body, the references sorted.
|
||||
func EncodeArtifactsGone(e ArtifactsGone) ([]byte, error) {
|
||||
e.Event = ArtifactsGoneEvent
|
||||
e.Gone = append([]string(nil), e.Gone...)
|
||||
sort.Strings(e.Gone)
|
||||
if e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 {
|
||||
return nil, fmt.Errorf("delivery: %s names a module, a fingerprint and at least one gone reference", ArtifactsGoneEvent)
|
||||
}
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// DecodeArtifactsGone reads the event's body, refusing one that names no module, fingerprint or reference.
|
||||
func DecodeArtifactsGone(raw []byte) (ArtifactsGone, error) {
|
||||
var e ArtifactsGone
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
return ArtifactsGone{}, fmt.Errorf("delivery: %s is not one: %w", ArtifactsGoneEvent, err)
|
||||
}
|
||||
if e.Event != ArtifactsGoneEvent || e.Module == "" || e.Fingerprint == "" || len(e.Gone) == 0 {
|
||||
return ArtifactsGone{}, fmt.Errorf("delivery: %s names no module, fingerprint or gone reference: %s", ArtifactsGoneEvent, raw)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
const (
|
||||
refA = "artifact-store://node-tools/runtime/blobs/sha256:a46b5d51a30e8fe71107d75ceb46e3187022fe51f551d0498b8be817f22435cb"
|
||||
refB = "artifact-store://mesh-host/host-arch/blobs/sha256:8a752f914f3523904a5442cae37ce3cb1ad297d6789b0f871585bce4d0c2ea00"
|
||||
)
|
||||
|
||||
// 1937/AC1 (the contract): the controller's answer reads into held and gone, and an answer leaving a reference out is refused.
|
||||
func TestDecodeHeldAnswer(t *testing.T) {
|
||||
raw := []byte(`{"output":"…","ok":true,"answer":{"store":"s:5100","held":["` + refA + `"],"gone":["` + refB + `"]}}`)
|
||||
a, err := DecodeHeldAnswer(raw, []string{refA, refB})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Held) != 1 || a.Held[0] != refA || len(a.Gone) != 1 || a.Gone[0] != refB {
|
||||
t.Fatalf("read %+v", a)
|
||||
}
|
||||
if _, err := DecodeHeldAnswer(raw, []string{refA, refB, "artifact-store://x/y/blobs/sha256:00"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "names nothing of") {
|
||||
t.Fatalf("an answer leaving a reference out was taken: %v", err)
|
||||
}
|
||||
if _, err := DecodeHeldAnswer([]byte(`{"output":"the store is down","ok":false}`), nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "the store is down") {
|
||||
t.Fatalf("a failed verb was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1937/AC2 (the contract): the event's body names the module, the fingerprint and the gone references, sorted.
|
||||
func TestArtifactsGoneRoundTrip(t *testing.T) {
|
||||
raw, err := EncodeArtifactsGone(ArtifactsGone{Module: "node-tools", Key: "node-tools.75", Fingerprint: "src2:75",
|
||||
Gone: []string{refB, refA}, At: time.Unix(0, 0).UTC()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := DecodeArtifactsGone(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Event != ArtifactsGoneEvent || e.Module != "node-tools" || len(e.Gone) != 2 || e.Gone[0] != refB {
|
||||
t.Fatalf("read %+v", e)
|
||||
}
|
||||
if _, err := EncodeArtifactsGone(ArtifactsGone{Module: "m", Fingerprint: "src2:1"}); err == nil {
|
||||
t.Fatal("an event naming no gone reference was made")
|
||||
}
|
||||
if _, err := DecodeArtifactsGone([]byte(`{"event":"artifacts-gone","module":"m"}`)); err == nil {
|
||||
t.Fatal("an event naming no fingerprint was read")
|
||||
}
|
||||
}
|
||||
|
||||
// The verb and the event are on the controller seat's contract under the names the builder uses.
|
||||
func TestHeldOnTheControllerSeat(t *testing.T) {
|
||||
if HeldVerb != "seat:"+catalogue.ControllerSeatName+".held" {
|
||||
t.Fatalf("HeldVerb %q", HeldVerb)
|
||||
}
|
||||
found := false
|
||||
for _, v := range catalogue.ControllerVerbs {
|
||||
if v.Name == "held" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the controller seat declares no verb held")
|
||||
}
|
||||
for _, s := range catalogue.DefaultSeats() {
|
||||
if s.Name != catalogue.ControllerSeatName {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
if e == ArtifactsGoneEvent {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("the controller seat emits %v, not %s", s.Emits, ArtifactsGoneEvent)
|
||||
}
|
||||
t.Fatal("no controller seat")
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A put (novox/hq ADR 0546): one version of a module put on one node by the assigner's tool `assigner_put`. Each put is the
|
||||
// key `<node>.<module>` of the assigner's state puts, which holds the version asked, the hold, why, and each step the
|
||||
// assigner took or waits on, so a reader watches the key to follow it. The want the put writes carries Put (and Hold),
|
||||
// so the assigner's rules pass over that key while the put holds it.
|
||||
|
||||
// AssignerPutsBucket is the assigner's bucket of puts, `<owner module>_puts`.
|
||||
const AssignerPutsBucket = AssignerModule + "_puts"
|
||||
|
||||
// AssignerPutsHistory is the revisions the puts bucket keeps per key: each step of a put is one.
|
||||
const AssignerPutsHistory = 20
|
||||
|
||||
// A put's steps, in the order they are taken (ADR 0546 clauses 3 to 9). A put stands at the last step written.
|
||||
const (
|
||||
// PutAsked is a put written by the tool, not taken up by a pass yet.
|
||||
PutAsked = "asked"
|
||||
// PutBuild waits for the builder's build at the fingerprint.
|
||||
PutBuild = "build"
|
||||
// PutCompose waits for the controller's composition (and grant) at the fingerprint.
|
||||
PutCompose = "compose"
|
||||
// PutPerson waits for the operator's answer to the ask about the move.
|
||||
PutPerson = "person"
|
||||
// PutSnapshot waits for the bus's streams to be snapshotted after the operator's yes.
|
||||
PutSnapshot = "snapshot"
|
||||
// PutWanted is the want written: the node's have has not reached it yet.
|
||||
PutWanted = "wanted"
|
||||
// PutHolding is a held put whose have runs it healthy: the want stays until released.
|
||||
PutHolding = "holding"
|
||||
// PutDone is a put without hold whose have ran it healthy: the rules write the want again.
|
||||
PutDone = "done"
|
||||
// PutReleased is a put released by the tool `assigner_release`.
|
||||
PutReleased = "released"
|
||||
// PutRefused is a put that cannot go on: a build that failed, an answer no, a snapshot not taken. Its words say why.
|
||||
PutRefused = "refused"
|
||||
)
|
||||
|
||||
// PutEnded says a put at this step is over: nothing more is done for it and the rules own the want.
|
||||
func PutEnded(step string) bool {
|
||||
return step == PutDone || step == PutReleased || step == PutRefused
|
||||
}
|
||||
|
||||
// PutStep is one step a put took: when, which, and what was done or waits, in a sentence.
|
||||
type PutStep struct {
|
||||
At time.Time `json:"at"`
|
||||
Step string `json:"step"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
// PutStepsKept is how many steps a put keeps, newest last.
|
||||
const PutStepsKept = 16
|
||||
|
||||
// Put is the value of a key of AssignerPutsBucket.
|
||||
type Put struct {
|
||||
V int `json:"v"`
|
||||
// FP is the fingerprint put, of the new form.
|
||||
FP string `json:"fp"`
|
||||
// Hold keeps the want at FP until released.
|
||||
Hold bool `json:"hold,omitempty"`
|
||||
Why string `json:"why"`
|
||||
// By is who asked, as the bus named the caller; "unproven" when it named none.
|
||||
By string `json:"by,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
// Step is where the put stands now, one of the Put* steps; Words say it in a sentence.
|
||||
Step string `json:"step"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// Composed is the digest of the composition the want names, once known.
|
||||
Composed string `json:"composed,omitempty"`
|
||||
// Ask is the key, in AssignerAsksBucket, of the ask about the move, once asked.
|
||||
Ask string `json:"ask,omitempty"`
|
||||
// Snapshot says where the bus's streams were snapshotted, once they were.
|
||||
Snapshot string `json:"snapshot,omitempty"`
|
||||
// Steps are the steps taken, oldest first, at most PutStepsKept.
|
||||
Steps []PutStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// Said is p at step with words, the step added to its steps when it differs from the last one.
|
||||
func (p Put) Said(at time.Time, step, words string) Put {
|
||||
if p.Step == step && p.Words == words {
|
||||
return p
|
||||
}
|
||||
p.Step, p.Words = step, words
|
||||
p.Steps = append(append([]PutStep(nil), p.Steps...), PutStep{At: at.UTC(), Step: step, Words: words})
|
||||
if len(p.Steps) > PutStepsKept {
|
||||
p.Steps = p.Steps[len(p.Steps)-PutStepsKept:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p Put) check() error {
|
||||
if err := checkFingerprint(p.FP, "a put's fingerprint"); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Why == "" {
|
||||
return fmt.Errorf("delivery: a put says why")
|
||||
}
|
||||
switch p.Step {
|
||||
case PutAsked, PutBuild, PutCompose, PutPerson, PutSnapshot, PutWanted, PutHolding, PutDone, PutReleased, PutRefused:
|
||||
default:
|
||||
return fmt.Errorf("delivery: %q is not a step of a put", p.Step)
|
||||
}
|
||||
if p.Composed != "" {
|
||||
return checkDigest(p.Composed, "a put's composition digest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PutKey is the key, in AssignerPutsBucket, of the put of module on node: `<node>.<module>`.
|
||||
func PutKey(node, module string) (string, error) {
|
||||
return HaveKey(node, module)
|
||||
}
|
||||
|
||||
// ParsePutKey reads a PutKey.
|
||||
func ParsePutKey(key string) (node, module string, err error) {
|
||||
node, module, isNode, err := ParseHaveKey(key)
|
||||
if err == nil && isNode {
|
||||
err = fmt.Errorf("delivery: %q is a node's own key, and no put", key)
|
||||
}
|
||||
return node, module, err
|
||||
}
|
||||
|
||||
// EncodePut is p as JSON at Version; it refuses a fingerprint not of the new form, a put that says no why and an
|
||||
// unknown step.
|
||||
func EncodePut(p Put) ([]byte, error) {
|
||||
if err := p.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.V = Version
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// DecodePut reads a put; it refuses an unknown version (ErrUnknownVersion) and what EncodePut refuses.
|
||||
func DecodePut(data []byte) (Put, error) {
|
||||
var p Put
|
||||
if err := decode(data, &p); err != nil {
|
||||
return Put{}, err
|
||||
}
|
||||
if err := p.check(); err != nil {
|
||||
return Put{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
+82
-10
@@ -1,8 +1,11 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -154,8 +157,8 @@ func checkAskID(id string) error {
|
||||
}
|
||||
|
||||
// StepAsk is the message on a build seat's stream: who asked what, of which kind and language, and the one whole job.
|
||||
// Exactly one of Build and Check is set: a delivery ask is a build; a check ask a build (a candidate's fingerprint) or a
|
||||
// check; a recheck ask a check; an image ask is always a build.
|
||||
// Exactly one of Build, Check and Compose is set: a delivery ask is a build; a check ask a build (a candidate's
|
||||
// fingerprint), a check or, on the Go seat, a compose; a recheck ask a check; an image ask is always a build.
|
||||
type StepAsk struct {
|
||||
V int `json:"v"`
|
||||
ID string `json:"id"`
|
||||
@@ -163,10 +166,48 @@ type StepAsk struct {
|
||||
Language string `json:"language"`
|
||||
// Asker is the module that put the ask (mesh-delivery-builder or mesh-delivery-checker); the stream's grant is what
|
||||
// makes it true, this field only names it to a reader.
|
||||
Asker string `json:"asker"`
|
||||
At time.Time `json:"at"`
|
||||
Build *BuildJob `json:"build,omitempty"`
|
||||
Check *CheckJob `json:"check,omitempty"`
|
||||
Asker string `json:"asker"`
|
||||
At time.Time `json:"at"`
|
||||
Build *BuildJob `json:"build,omitempty"`
|
||||
Check *CheckJob `json:"check,omitempty"`
|
||||
Compose *ComposeJob `json:"compose,omitempty"`
|
||||
}
|
||||
|
||||
// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer
|
||||
// command (Package) in the Go module at Dir of the fetched tree, once per ComposerKey, run it in a run step shut off
|
||||
// with Request on its standard input, and answer its standard output whole (ComposeAnswer). The assigner asks it, to
|
||||
// compose a group with its mesh-sdk member's head, and to read the group's manifests with the mesh-sdk the running
|
||||
// controller and node-engine pin (ADR 0482 clause 5, issue 1623).
|
||||
type ComposeJob struct {
|
||||
// Fetch is the fetch tool's arguments: the repository and the full commit whose mesh-sdk composes (mesh-sdk itself
|
||||
// at a head, or a module that requires mesh-sdk, at the version it pins).
|
||||
Fetch map[string]any `json:"fetch"`
|
||||
// Dir is the directory of the Go module the composer is built in, relative to the repository's root ("go" for
|
||||
// mesh-sdk, RootPath for a module at the root); Package is the composer's import path (mesh-sdk composer.Package).
|
||||
Dir string `json:"dir"`
|
||||
Package string `json:"package"`
|
||||
// Request is the composer's request (mesh-sdk composer.Request), its manifests named by their digests in the
|
||||
// indexer's ManifestsBucket: the build module fills them before the run (composer.Fill).
|
||||
Request json.RawMessage `json:"request"`
|
||||
// Reader names, for a reader of the ask, whose composition this is: the mesh-sdk head of a group, or a module whose
|
||||
// mesh-sdk reads the group's manifests (mesh-controller, mesh-host).
|
||||
Reader string `json:"reader,omitempty"`
|
||||
}
|
||||
|
||||
// ComposeAnswer is a compose ask's step result: whether this run built the composer or took the one kept under its
|
||||
// key, the key, and the composer's verdict (mesh-sdk composer.Verdict) whole.
|
||||
type ComposeAnswer struct {
|
||||
Built bool `json:"built"`
|
||||
Key string `json:"key"`
|
||||
Verdict json.RawMessage `json:"verdict"`
|
||||
}
|
||||
|
||||
// ComposerKey is the key a build module keeps a built composer under: the repository, commit, directory and package it
|
||||
// was built from and the toolchain it was built with, hashed (`composer-` and 16 hex digits), so each head is built once
|
||||
// on a node (ADR 0482 clause 4).
|
||||
func ComposerKey(repository, commit, dir, pkg string, toolchain ToolchainUsed) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{repository, commit, dir, pkg, toolchain.Version, toolchain.Recipe}, "\n")))
|
||||
return "composer-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// BuildJob is a build ask's job: fetch the commit (unless every artifact is a copy), then build or copy each artifact,
|
||||
@@ -232,9 +273,17 @@ func (a StepAsk) check() error {
|
||||
if a.At.IsZero() {
|
||||
return fmt.Errorf("delivery: the ask %s names no time", a.ID)
|
||||
}
|
||||
jobs := 0
|
||||
for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil} {
|
||||
if set {
|
||||
jobs++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case (a.Build == nil) == (a.Check == nil):
|
||||
return fmt.Errorf("delivery: the ask %s holds a build or a check, exactly one", a.ID)
|
||||
case jobs != 1:
|
||||
return fmt.Errorf("delivery: the ask %s holds a build, a check or a compose, exactly one", a.ID)
|
||||
case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo):
|
||||
return fmt.Errorf("delivery: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID)
|
||||
case a.Build != nil && a.Kind == AskRecheck:
|
||||
return fmt.Errorf("delivery: the ask %s is a build on the recheck stream, which carries checks alone", a.ID)
|
||||
case a.Check != nil && a.Kind == AskDelivery:
|
||||
@@ -245,9 +294,28 @@ func (a StepAsk) check() error {
|
||||
if a.Build != nil {
|
||||
return a.Build.check(a.ID)
|
||||
}
|
||||
if a.Compose != nil {
|
||||
return a.Compose.check(a.ID)
|
||||
}
|
||||
return a.Check.check(a.ID)
|
||||
}
|
||||
|
||||
func (c ComposeJob) check(id string) error {
|
||||
if err := checkFetchArgs(id, c.Fetch); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Dir == "" || c.Dir != path.Clean(c.Dir) || strings.HasPrefix(c.Dir, "/") || c.Dir == ".." || strings.HasPrefix(c.Dir, "../") {
|
||||
return fmt.Errorf("delivery: the compose ask %s names no clean directory inside the repository (%q)", id, c.Dir)
|
||||
}
|
||||
if c.Package == "" {
|
||||
return fmt.Errorf("delivery: the compose ask %s names no package to build", id)
|
||||
}
|
||||
if !json.Valid(c.Request) || len(c.Request) == 0 || c.Request[0] != '{' {
|
||||
return fmt.Errorf("delivery: the compose ask %s holds no request object", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b BuildJob) check(id string) error {
|
||||
if err := checkName("module", b.Module); err != nil {
|
||||
return err
|
||||
@@ -467,9 +535,13 @@ func ParseAskStepKey(key string) (node, askID string, err error) {
|
||||
return node, askID, nil
|
||||
}
|
||||
|
||||
// PauseKey is the key, in a build module's StepsBucket, that keeps whether the module on node takes asks:
|
||||
// PausesState is the state a build module keeps its nodes' pause keys in. It has no age, unlike StepsState, whose keys
|
||||
// are per step and dropped after their age: a pause kept among the steps would end by itself.
|
||||
const PausesState = "pauses"
|
||||
|
||||
// PauseKey is the key, in a build module's PausesState, that keeps whether the module on node takes asks:
|
||||
// `paused.<node>` (issue 1887's design limit 2: every holder pulls from one shared worker, so a node's pause is its holder
|
||||
// not fetching, kept here so a restart keeps it). A pause key is never an ask's step key: its second part is a node.
|
||||
// not fetching, kept so a restart keeps it). A pause key is never an ask's step key: its second part is a node.
|
||||
func PauseKey(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func composeAsk() StepAsk {
|
||||
return StepAsk{ID: "mh3k2x9qa1b2c5", Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-assigner",
|
||||
At: time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC),
|
||||
Compose: &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit},
|
||||
Dir: "go", Package: "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose",
|
||||
Request: json.RawMessage(`{"v":1,"cases":[{"name":"change","manifests":{"demo":{"digest":"d1"}}}]}`), Reader: "mesh-sdk head"}}
|
||||
}
|
||||
|
||||
// 1622: a compose ask encodes and decodes with every field.
|
||||
func TestAComposeAskRoundTrips(t *testing.T) {
|
||||
a := composeAsk()
|
||||
raw, err := EncodeStepAsk(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeStepAsk(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.V = StepAskVersion
|
||||
if !reflect.DeepEqual(got, a) {
|
||||
t.Fatalf("round trip:\n got %+v\nwant %+v", got, a)
|
||||
}
|
||||
}
|
||||
|
||||
// 1622: a compose ask goes only on the Go seat's check stream, holds no other job beside it, fetches a full commit,
|
||||
// builds a package in a clean directory of the repository and carries a request object.
|
||||
func TestAComposeAskIsRefusedWhereItDoesNotBelong(t *testing.T) {
|
||||
for name, change := range map[string]func(*StepAsk){
|
||||
"on the delivery stream": func(a *StepAsk) { a.Kind = AskDelivery },
|
||||
"on the recheck stream": func(a *StepAsk) { a.Kind = AskRecheck },
|
||||
"on the Node seat": func(a *StepAsk) { a.Language = AskNode },
|
||||
"beside a check": func(a *StepAsk) { a.Check = checkAsk().Check },
|
||||
"with a short commit": func(a *StepAsk) { a.Compose.Fetch["commit"] = "0123456" },
|
||||
"in no directory": func(a *StepAsk) { a.Compose.Dir = "" },
|
||||
"outside the repository": func(a *StepAsk) { a.Compose.Dir = "../x" },
|
||||
"with no package": func(a *StepAsk) { a.Compose.Package = "" },
|
||||
"with no request": func(a *StepAsk) { a.Compose.Request = nil },
|
||||
"with a request not JSON": func(a *StepAsk) { a.Compose.Request = json.RawMessage(`[1]`) },
|
||||
} {
|
||||
a := composeAsk()
|
||||
change(&a)
|
||||
if _, err := EncodeStepAsk(a); err == nil {
|
||||
t.Errorf("a compose ask %s is encoded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1622/AC3: a composer is kept under one key per repository, commit, directory, package and toolchain, so two asks of
|
||||
// one head build it once on a node, and another head or toolchain builds its own.
|
||||
func TestAComposerIsKeptByWhatItWasBuiltFrom(t *testing.T) {
|
||||
tc := ToolchainUsed{Version: "go1.26.0", Recipe: "3"}
|
||||
k := ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc)
|
||||
if k != ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 {
|
||||
t.Fatalf("key = %q", k)
|
||||
}
|
||||
for _, other := range []string{
|
||||
ComposerKey("novox/mesh-sdk", strings.Repeat("1", 40), "go", "p", tc),
|
||||
ComposerKey("novox/mesh-controller", askCommit, ".", "p", tc),
|
||||
ComposerKey("novox/mesh-sdk", askCommit, "go", "p", ToolchainUsed{Version: "go1.26.1", Recipe: "3"}),
|
||||
} {
|
||||
if other == k {
|
||||
t.Errorf("two composers built from different inputs share the key %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+71
-11
@@ -32,19 +32,27 @@ const (
|
||||
AskKindHold = "hold"
|
||||
// AskKindRestore is the kind of the assigner's ask about a data module set back.
|
||||
AskKindRestore = "restore"
|
||||
// AskKindBackup is the kind of the assigner's ask about a backup before an apply that failed: back up again, or go
|
||||
// on without one (novox/hq ADR 0539 clause 16).
|
||||
AskKindBackup = "backup"
|
||||
// AskKindRecord is the kind of the assigner's ask about a move a person must allow, for a module of policy record:
|
||||
// a composition judged to recreate a container or move data, or one that cannot be judged (ADR 0539 clause 14).
|
||||
AskKindRecord = "record"
|
||||
|
||||
viewWord = "view"
|
||||
)
|
||||
|
||||
// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's
|
||||
// Module and fingerprint FP, a restore's Node, Module and FP.
|
||||
// Module and fingerprint FP, a restore's and a backup's Node, Module and FP, a record's Node, Module and the digest
|
||||
// Composed of the composition it would allow.
|
||||
type AskKey struct {
|
||||
Kind string
|
||||
Group string
|
||||
Hash string
|
||||
Node string
|
||||
Module string
|
||||
FP string
|
||||
Kind string
|
||||
Group string
|
||||
Hash string
|
||||
Node string
|
||||
Module string
|
||||
FP string
|
||||
Composed string
|
||||
}
|
||||
|
||||
// AskNeedsProof says whether an ask of this kind must prove who answered it, so the view may not answer it: the
|
||||
@@ -53,10 +61,11 @@ func AskNeedsProof(kind string) (bool, error) {
|
||||
switch kind {
|
||||
case AskKindMerge:
|
||||
return true, nil
|
||||
case AskKindHold, AskKindRestore:
|
||||
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord:
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s or %s", kind, AskKindMerge, AskKindHold, AskKindRestore)
|
||||
return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s, %s, %s or %s", kind, AskKindMerge, AskKindHold,
|
||||
AskKindRestore, AskKindBackup, AskKindRecord)
|
||||
}
|
||||
|
||||
// HoldAskKey is the key, in AssignerAsksBucket, of the ask about a module's held fingerprint: `hold.<module>.<hex>`.
|
||||
@@ -81,7 +90,32 @@ func RestoreAskKey(node, module, fingerprint string) (string, error) {
|
||||
return AskKindRestore + "." + node + "." + key, nil
|
||||
}
|
||||
|
||||
// ParseAnyAskKey reads an ask key of any kind (MergeAskKey, HoldAskKey, RestoreAskKey); it refuses an unknown kind
|
||||
// BackupAskKey is the key, in AssignerAsksBucket, of the ask about a module's failed backup on a node before it applies
|
||||
// a fingerprint: `backup.<node>.<module>.<hex>`.
|
||||
func BackupAskKey(node, module, fingerprint string) (string, error) {
|
||||
key, err := ComposedKey(node, module, fingerprint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return AskKindBackup + "." + key, nil
|
||||
}
|
||||
|
||||
// RecordAskKey is the key, in AssignerAsksBucket, of the ask about a move of a module on a node to the composition
|
||||
// whose digest it names: `record.<node>.<module>.<digest hex>`, so a new revision at the same fingerprint is asked
|
||||
// again (ADR 0539 clause 14).
|
||||
func RecordAskKey(node, module, composed string) (string, error) {
|
||||
have, err := HaveKey(node, module)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkDigest(composed, "a record ask's composition digest"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return AskKindRecord + "." + have + "." + strings.TrimPrefix(composed, "sha256:"), nil
|
||||
}
|
||||
|
||||
// ParseAnyAskKey reads an ask key of any kind (MergeAskKey, HoldAskKey, RestoreAskKey, BackupAskKey, RecordAskKey); it
|
||||
// refuses an unknown kind
|
||||
// and a key whose parts do not fit its kind, naming the key.
|
||||
func ParseAnyAskKey(key string) (AskKey, error) {
|
||||
kind, rest, _ := strings.Cut(key, ".")
|
||||
@@ -114,6 +148,26 @@ func ParseAnyAskKey(key string) (AskKey, error) {
|
||||
return refused(err)
|
||||
}
|
||||
return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil
|
||||
case AskKindBackup:
|
||||
node, module, fp, isNode, err := ParseComposedKey(rest)
|
||||
if err != nil || isNode {
|
||||
return refused(fmt.Errorf("a backup ask is backup.<node>.<module>.<fingerprint hex>"))
|
||||
}
|
||||
return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil
|
||||
case AskKindRecord:
|
||||
parts := strings.Split(rest, ".")
|
||||
if len(parts) != 3 {
|
||||
return refused(fmt.Errorf("a record ask is record.<node>.<module>.<digest hex>"))
|
||||
}
|
||||
node, module, isNode, err := ParseHaveKey(parts[0] + "." + parts[1])
|
||||
if err != nil || isNode {
|
||||
return refused(fmt.Errorf("a record ask is record.<node>.<module>.<digest hex>"))
|
||||
}
|
||||
composed := "sha256:" + parts[2]
|
||||
if err := checkDigest(composed, "a record ask's composition digest"); err != nil {
|
||||
return refused(err)
|
||||
}
|
||||
return AskKey{Kind: kind, Node: node, Module: module, Composed: composed}, nil
|
||||
}
|
||||
_, err := AskNeedsProof(kind)
|
||||
return refused(err)
|
||||
@@ -229,9 +283,15 @@ func ViewAnswerFits(a ViewAnswer, ask AssignerAsk) error {
|
||||
if a.Asking != ask.Ask.ID {
|
||||
return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, ask.Ask.ID)
|
||||
}
|
||||
if _, ok := ask.Ask.Option(a.Option); !ok {
|
||||
o, ok := ask.Ask.Option(a.Option)
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID)
|
||||
}
|
||||
if o.Level != "" && o.Level != asks.Acknowledge {
|
||||
// An option that authorises is answered on a channel that proves who answered (ADR 0259 §5, ADR 0546 clause 8),
|
||||
// never in the view, which proves nobody.
|
||||
return fmt.Errorf("delivery: the view's answer chose %q, an option of level %s, which only a channel that proves who answered takes", a.Option, o.Level)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -158,4 +158,11 @@ func TestViewAnswerFits(t *testing.T) {
|
||||
if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil {
|
||||
t.Errorf("an option the ask does not offer fits")
|
||||
}
|
||||
// An option that authorises is never answered in the view (ADR 0546 clause 8).
|
||||
approving := first
|
||||
approving.Ask.Options = append([]asks.Option(nil), first.Ask.Options...)
|
||||
approving.Ask.Options[0].Level = asks.Approve
|
||||
if err := ViewAnswerFits(answer, approving); err == nil || !strings.Contains(err.Error(), "approve") {
|
||||
t.Errorf("the view answered an option of level approve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func hashOf(c byte) string { return "sha256:" + digestOf(c) }
|
||||
|
||||
// 1943/AC3: a want naming its fingerprint and the digest of the composition it allows round-trips, and so does one
|
||||
// carrying the answer to a failed backup; a want naming a fingerprint without a digest, a digest not of the sha256
|
||||
// form, a removed want naming a digest, an unknown backup answer and a rule past the seventh are refused.
|
||||
func TestAWantNamesItsComposition(t *testing.T) {
|
||||
for _, w := range []Want{
|
||||
{FP: fp2of('a'), Composed: hashOf('1')},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupAgain},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupSkip, Words: "the operator: go on without a backup"},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleComposed, Words: "no composition of b for ace yet"},
|
||||
{Removed: true},
|
||||
} {
|
||||
raw, err := EncodeWant(w)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeWant(%+v): %v", w, err)
|
||||
}
|
||||
back, err := DecodeWant(raw)
|
||||
w.V = Version
|
||||
if err != nil || !SameWant(back, w) {
|
||||
t.Errorf("want %+v came back %+v, %v", w, back, err)
|
||||
}
|
||||
}
|
||||
refusals := []struct {
|
||||
w Want
|
||||
why string
|
||||
}{
|
||||
{Want{FP: fp2of('a')}, "names no composition"},
|
||||
{Want{FP: fp2of('a'), Composed: "sha256:12"}, "not sha256"},
|
||||
{Want{Removed: true, Composed: hashOf('1')}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`},
|
||||
{Want{Removed: true, Backup: BackupAgain}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8}, "rule 8"},
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if _, err := EncodeWant(r.w); err == nil || !strings.Contains(err.Error(), r.why) {
|
||||
t.Errorf("EncodeWant(%+v) = %v, want a refusal saying %q", r.w, err, r.why)
|
||||
}
|
||||
}
|
||||
if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + fp2of('a') + `"}`)); err == nil || !strings.Contains(err.Error(), "names no composition") {
|
||||
t.Errorf("a want of release 5, naming no composition, was read: %v", err)
|
||||
}
|
||||
if RuleComposed != 7 {
|
||||
t.Errorf("RuleComposed = %d, want the seventh rule", RuleComposed)
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC3: the node's own want is `want.<node>.node` and round-trips through its parser, while a module named node is
|
||||
// still refused a want key; its value names the digest of the node's own composition and no fingerprint.
|
||||
func TestTheNodesOwnWant(t *testing.T) {
|
||||
key, err := WantNodeKey("ace")
|
||||
if err != nil || key != "want.ace.node" {
|
||||
t.Fatalf("WantNodeKey = %q, %v", key, err)
|
||||
}
|
||||
if node, err := ParseWantNodeKey(key); err != nil || node != "ace" {
|
||||
t.Errorf("ParseWantNodeKey(%q) = %q, %v", key, node, err)
|
||||
}
|
||||
if _, err := WantKey("ace", "node"); err == nil {
|
||||
t.Errorf(`WantKey(ace, "node") accepted`)
|
||||
}
|
||||
if _, _, err := ParseWantKey(key); err == nil {
|
||||
t.Errorf("ParseWantKey read the node's own want as a module's")
|
||||
}
|
||||
for _, bad := range []string{"want.ace.mesh-mail", "want.node", "ace.node", "want.a.b.node", ""} {
|
||||
if _, err := ParseWantNodeKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
|
||||
t.Errorf("ParseWantNodeKey(%q) = %v, want a refusal naming it", bad, err)
|
||||
}
|
||||
}
|
||||
if _, err := WantNodeKey(""); err == nil {
|
||||
t.Errorf("WantNodeKey(\"\") accepted")
|
||||
}
|
||||
w := NodeWant{Composed: hashOf('2'), Words: "the node's own composition"}
|
||||
raw, err := EncodeNodeWant(w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeNodeWant(raw)
|
||||
w.V = Version
|
||||
if err != nil || back != w {
|
||||
t.Errorf("node want came back %+v, %v", back, err)
|
||||
}
|
||||
for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}} {
|
||||
if _, err := EncodeNodeWant(bad); err == nil {
|
||||
t.Errorf("EncodeNodeWant(%+v) accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, err := DecodeNodeWant([]byte(`{"v":1,"composed":"` + hashOf('2') + `","fp":"` + fp2of('a') + `"}`)); err == nil {
|
||||
t.Errorf("a node want naming a fingerprint was read")
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC3: a have held after a failed backup, naming the composition it runs, round-trips; so does the node's own have
|
||||
// naming its composition; a have's digest not of the sha256 form is refused.
|
||||
func TestAHaveNamesItsComposition(t *testing.T) {
|
||||
for _, h := range []Have{
|
||||
{FP: fp2of('a'), Composed: hashOf('3'), Phase: PhaseHeld, Health: HealthOK, Detail: "the backup failed: no space left"},
|
||||
{FP: fp2of('a'), Composed: hashOf('3'), Phase: PhaseRunning, Health: HealthOK},
|
||||
} {
|
||||
raw, err := EncodeHave(h)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeHave(%+v): %v", h, err)
|
||||
}
|
||||
back, err := DecodeHave(raw)
|
||||
h.V = Version
|
||||
if err != nil || !SameHave(back, h) {
|
||||
t.Errorf("have %+v came back %+v, %v", h, back, err)
|
||||
}
|
||||
}
|
||||
if _, err := EncodeHave(Have{Composed: "sha256:x", Phase: PhaseRunning, Health: HealthOK}); err == nil || !strings.Contains(err.Error(), "not sha256") {
|
||||
t.Errorf("a have naming a bad digest = %v", err)
|
||||
}
|
||||
n := HaveNode{Composed: hashOf('4')}
|
||||
raw, err := EncodeHaveNode(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeHaveNode(raw)
|
||||
n.V = Version
|
||||
if err != nil || !SameHaveNode(back, n) {
|
||||
t.Errorf("node have came back %+v, %v", back, err)
|
||||
}
|
||||
if SameHaveNode(n, HaveNode{V: Version, Composed: hashOf('5')}) {
|
||||
t.Errorf("node haves naming two compositions are the same")
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC3: the assigner's backup ask (back up again, or go on without one, ADR 0539 clause 16) and its record ask (a
|
||||
// move a person must allow, clause 14) key by node, module and fingerprint or composition, round-trip through
|
||||
// ParseAnyAskKey, and the view may answer both.
|
||||
func TestTheBackupAndRecordAsks(t *testing.T) {
|
||||
fp, digest := fp2of('a'), hashOf('6')
|
||||
backup, err := BackupAskKey("ace", "mesh-mail", fp)
|
||||
if err != nil || backup != "backup.ace.mesh-mail."+strings.Repeat("a", 64) {
|
||||
t.Fatalf("BackupAskKey = %q, %v", backup, err)
|
||||
}
|
||||
record, err := RecordAskKey("ace", "mesh-mail", digest)
|
||||
if err != nil || record != "record.ace.mesh-mail."+strings.Repeat("6", 64) {
|
||||
t.Fatalf("RecordAskKey = %q, %v", record, err)
|
||||
}
|
||||
if got, err := ParseAnyAskKey(backup); err != nil || got != (AskKey{Kind: AskKindBackup, Node: "ace", Module: "mesh-mail", FP: fp}) {
|
||||
t.Errorf("ParseAnyAskKey(%q) = %+v, %v", backup, got, err)
|
||||
}
|
||||
if got, err := ParseAnyAskKey(record); err != nil || got != (AskKey{Kind: AskKindRecord, Node: "ace", Module: "mesh-mail", Composed: digest}) {
|
||||
t.Errorf("ParseAnyAskKey(%q) = %+v, %v", record, got, err)
|
||||
}
|
||||
for _, kind := range []string{AskKindBackup, AskKindRecord} {
|
||||
if proof, err := AskNeedsProof(kind); err != nil || proof {
|
||||
t.Errorf("AskNeedsProof(%s) = %v, %v; the view may answer it", kind, proof, err)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{backup, record} {
|
||||
if _, err := ViewAnswerKey(k); err != nil {
|
||||
t.Errorf("the view may not answer %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range [][3]string{{"", "mesh-mail", fp}, {"ace", "node", fp}, {"ace", "mesh-mail", "src2:1"}} {
|
||||
if _, err := BackupAskKey(bad[0], bad[1], bad[2]); err == nil {
|
||||
t.Errorf("BackupAskKey(%v) accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, err := RecordAskKey("ace", "mesh-mail", "sha256:1"); err == nil {
|
||||
t.Errorf("a record ask naming a bad digest was keyed")
|
||||
}
|
||||
for _, bad := range []string{"backup.ace.mesh-mail", "record.ace.mesh-mail.12", "record.ace." + strings.Repeat("6", 64)} {
|
||||
if _, err := ParseAnyAskKey(bad); err == nil {
|
||||
t.Errorf("ParseAnyAskKey(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,12 @@
|
||||
// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a
|
||||
// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host
|
||||
// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's
|
||||
// go.mod pins. In a mesh where either module has no source repository nothing is
|
||||
// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these
|
||||
// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog,
|
||||
// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine
|
||||
// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and
|
||||
// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and
|
||||
// mesh-catalog. In a mesh where either module has no source repository nothing is
|
||||
// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the
|
||||
// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the
|
||||
// catalogue's main, not the group's head.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -1,11 +1,16 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// 1171/AC2: given an account file broker-<process id> in the controller's form {url, fingerprint, node,
|
||||
@@ -46,6 +51,28 @@ func TestReadAccountToleratesNoClaimsAtAll(t *testing.T) {
|
||||
// 1171/AC2: a file missing url, user or password is refused, naming the field; the password never appears
|
||||
// in the error even when it is the field present and another is missing.
|
||||
func TestReadAccountRefusesAFileMissingAField(t *testing.T) {
|
||||
t.Run("1945: a refused bucket stops a start only when the part cannot work without it", func(t *testing.T) {
|
||||
refusal := errors.New("nats: permissions violation for publish to $JS.API.STREAM.INFO.KV_mesh-delivery-gate_source")
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
err error
|
||||
}{{"refused", refusal}, {"not answered", context.DeadlineExceeded}} {
|
||||
var said []string
|
||||
logf := func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
|
||||
if kv := OptionalBucket(context.Background(), refusingBus{c.err}, "b", "b is not heard", logf); kv != nil {
|
||||
t.Errorf("%s: an optional bucket answered %v", c.name, kv)
|
||||
}
|
||||
NotWatched(logf, "b", c.err, "b is not heard")
|
||||
if len(said) != 2 || !strings.Contains(said[0], c.err.Error()) || !strings.Contains(said[1], "cannot be watched") {
|
||||
t.Errorf("%s: said %q", c.name, said)
|
||||
}
|
||||
if _, err := EssentialBucket(context.Background(), refusingBus{c.err}, "builder", "b", "build"); err == nil ||
|
||||
!strings.Contains(err.Error(), "grant of b") || !strings.Contains(err.Error(), "the builder cannot build") {
|
||||
t.Errorf("%s: an essential bucket: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
values map[string]any
|
||||
@@ -82,3 +109,8 @@ func writeAccount(t *testing.T, fields map[string]any) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// refusingBus refuses every bucket with err.
|
||||
type refusingBus struct{ err error }
|
||||
|
||||
func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) { return nil, r.err }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// **A part does not stop for a bucket it can work without** (novox/hq issue 1945, met twice on 2026-10-25: the delivery
|
||||
// builder exited on a refused read of the gate's source and crash-looped). A grant reaches the bus with a delivery, so a
|
||||
// bucket, a watch or a consumer the bus refuses, or times out on, at start is often only not granted yet: exiting for it
|
||||
// stops the part until a person's hand. A part opens what it cannot do its job at all without with EssentialBucket, which
|
||||
// stops its start with an error naming the bucket and its grant, and everything else with OptionalBucket, which says what
|
||||
// is not heard and goes on; a watch the bus refuses on an optional bucket is said with NotWatched. Here, beside State,
|
||||
// because this is where a process opens what it keeps and reads on the bus; the parts that open buckets with their own
|
||||
// JetStream (the delivery parts) take the same three.
|
||||
|
||||
// BucketOpener is the part of the bus a start opens buckets with: a jetstream.JetStream, or a test's fake.
|
||||
type BucketOpener interface {
|
||||
KeyValue(ctx context.Context, bucket string) (jetstream.KeyValue, error)
|
||||
}
|
||||
|
||||
// EssentialBucket opens a bucket part cannot work without (it cannot `does` without it); its error is EssentialErr's.
|
||||
func EssentialBucket(ctx context.Context, js BucketOpener, part, name, does string) (jetstream.KeyValue, error) {
|
||||
kv, err := js.KeyValue(ctx, name)
|
||||
if err != nil {
|
||||
return nil, EssentialErr(part, name, does, err)
|
||||
}
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// EssentialErr is the error of a bucket, consumer or stream named name that part cannot work without: it says what part
|
||||
// cannot do, that it stops, and the grant to look at.
|
||||
func EssentialErr(part, name, does string, err error) error {
|
||||
return fmt.Errorf("%s cannot be read (%v): the %s cannot %s without it, so it stops; where the bus refused it or did not "+
|
||||
"answer, this module's grant of %s (its manifest's reads, state or consumes, issued with its credential) has not "+
|
||||
"reached the bus yet", name, err, part, does, name)
|
||||
}
|
||||
|
||||
// OptionalBucket opens a bucket the part works without: any error is said with what is not heard (without), and nil
|
||||
// answered.
|
||||
func OptionalBucket(ctx context.Context, js BucketOpener, name, without string, logf func(string, ...any)) jetstream.KeyValue {
|
||||
kv, err := js.KeyValue(ctx, name)
|
||||
if err != nil {
|
||||
logf("the bucket %s cannot be read (%v): %s", name, err, without)
|
||||
return nil
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
// NotWatched says that an optional bucket opened but its watch was refused or not answered: what is not heard.
|
||||
func NotWatched(logf func(string, ...any), name string, err error, without string) {
|
||||
logf("the bucket %s cannot be watched (%v): %s", name, err, without)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -24,7 +24,11 @@ func Version() string {
|
||||
|
||||
// FromBuildInfo reads the SDK's version out of a build info. A dependency replaced by another module version is that
|
||||
// version; replaced by a local path it is the path, never empty. When the SDK is the main module it is "(devel)",
|
||||
// followed by a space and the vcs.revision when the build stamped one. Without the SDK in the build it is "unknown".
|
||||
// followed by a space and the vcs.revision when the build stamped one. An SDK command built from another module that
|
||||
// requires the SDK (`go build git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose` in a reader's tree, as mesh-build-go's
|
||||
// compose job builds it: novox/hq ADR 0482 clause 5) has the SDK as its main module at the version that module pins,
|
||||
// read from the module cache with no vcs stamp: it is that version, never a bare "(devel)" that names no SDK. Without
|
||||
// the SDK in the build it is "unknown".
|
||||
func FromBuildInfo(bi *debug.BuildInfo) string {
|
||||
if bi == nil {
|
||||
return "unknown"
|
||||
@@ -35,6 +39,9 @@ func FromBuildInfo(bi *debug.BuildInfo) string {
|
||||
return "(devel) " + s.Value
|
||||
}
|
||||
}
|
||||
if v := bi.Main.Version; v != "" && v != "(devel)" {
|
||||
return v
|
||||
}
|
||||
return "(devel)"
|
||||
}
|
||||
for _, d := range bi.Deps {
|
||||
|
||||
@@ -42,6 +42,18 @@ func TestTheMainModuleIsDevelWithItsRevision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The shape go gives `go build <the SDK's command>` run in a module that requires the SDK (captured with go1.27 from
|
||||
// mesh-compose built in a reader's tree at bc0c25e51052: "mod git.novox.be/novox/mesh-sdk/go v0.1.14-…-bc0c25e51052",
|
||||
// no vcs settings): the SDK is the main module at the pinned version, which names the SDK the binary carries.
|
||||
func TestAnSDKCommandBuiltFromAReaderIsThePinnedVersion(t *testing.T) {
|
||||
bi := &debug.BuildInfo{Path: sdkPath + "/cmd/mesh-compose",
|
||||
Main: debug.Module{Path: sdkPath, Version: "v0.1.14-0.20261024112544-bc0c25e51052"},
|
||||
Settings: []debug.BuildSetting{{Key: "-trimpath", Value: "true"}, {Key: "CGO_ENABLED", Value: "0"}}}
|
||||
if got := FromBuildInfo(bi); got != "v0.1.14-0.20261024112544-bc0c25e51052" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBuildInfoIsUnknown(t *testing.T) {
|
||||
if got := FromBuildInfo(nil); got != "unknown" {
|
||||
t.Fatalf("got %q", got)
|
||||
|
||||
@@ -154,8 +154,15 @@ const CodeChanged = -32010
|
||||
var (
|
||||
current *channel
|
||||
currentMu sync.Mutex
|
||||
// served is closed once Serve has the runtime's channel (Served).
|
||||
served = make(chan struct{})
|
||||
servedOnce sync.Once
|
||||
)
|
||||
|
||||
// Served is closed once Serve has the runtime's channel, so code started before Serve can wait for it instead of
|
||||
// asking again until the runtime takes it: a watch of a config value, a subscribe.
|
||||
func Served() <-chan struct{} { return served }
|
||||
|
||||
// Serve answers the runtime on stdin and stdout until stdin closes. An empty name serves as the
|
||||
// module the runtime named in MESH_SERVED_MODULE.
|
||||
func Serve(name string, tools []Tool) error {
|
||||
@@ -180,6 +187,7 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error {
|
||||
currentMu.Lock()
|
||||
current = c
|
||||
currentMu.Unlock()
|
||||
servedOnce.Do(func() { close(served) })
|
||||
// What the bundle said it keeps is announced once it serves: the runtime answers the publish on this channel, so
|
||||
// it is asked from beside the loop that reads the answer.
|
||||
go func() { _ = announce1() }()
|
||||
|
||||
@@ -16,6 +16,22 @@ func TestABundleEmitsThroughTheRuntimeAndServesAsItsModule(t *testing.T) {
|
||||
t.Setenv(ServedAs, "chime")
|
||||
t.Setenv("MESH_MODULE", "chime")
|
||||
t.Setenv("MESH_NODE", "anchor")
|
||||
// 1887/AC-wake: Served closes once Serve has the runtime's channel, not before; the globals are put back after.
|
||||
oldServed := served
|
||||
served, servedOnce = make(chan struct{}), sync.Once{}
|
||||
t.Cleanup(func() {
|
||||
served, servedOnce = oldServed, sync.Once{}
|
||||
select {
|
||||
case <-oldServed: // it was closed: its Once is spent too
|
||||
servedOnce.Do(func() {})
|
||||
default:
|
||||
}
|
||||
})
|
||||
select {
|
||||
case <-Served():
|
||||
t.Fatal("Served closed before Serve")
|
||||
default:
|
||||
}
|
||||
inR, inW := io.Pipe()
|
||||
outR, outW := io.Pipe()
|
||||
go func() {
|
||||
@@ -50,6 +66,11 @@ func TestABundleEmitsThroughTheRuntimeAndServesAsItsModule(t *testing.T) {
|
||||
if got := next()["result"].(map[string]any)["serverInfo"].(map[string]any)["name"]; got != "chime" {
|
||||
t.Fatalf("served as %v, want the module the runtime named", got)
|
||||
}
|
||||
select {
|
||||
case <-Served():
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Served did not close once serving")
|
||||
}
|
||||
send(map[string]any{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": map[string]any{"name": "ring", "arguments": map[string]any{}}})
|
||||
asked := next()
|
||||
if asked["method"] != "mesh/publish" {
|
||||
|
||||
Reference in New Issue
Block a user