Merge pull request 'The seat mesh-placement's contract, and node grants for its states (issue 1126, delivery 1 of 2)' (#119) from feat/1126-grants-first into main
This commit was merged in pull request #119.
This commit is contained in:
@@ -200,6 +200,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if runner != "" {
|
||||
args["runner"] = runner
|
||||
}
|
||||
if len(job.NodeNames) > 0 {
|
||||
args[delivery.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12)
|
||||
}
|
||||
return args
|
||||
}
|
||||
test := func(args map[string]any) (bool, error) {
|
||||
|
||||
@@ -143,6 +143,15 @@ test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
|
||||
// ADR 0567 §12: a check's node names reach the digest and test tools, for the names check (a kept verdict misses when
|
||||
// they change).
|
||||
r.calls = nil
|
||||
if _, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, NodeNames: []string{"anchor"},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) ||
|
||||
!strings.Contains(strings.Join(r.calls, "\n"), `test {"level":"unit","node-names":["anchor"]`) {
|
||||
t.Fatalf("the node names are not handed to the digest and test tools: %v, %v", r.calls, err)
|
||||
}
|
||||
|
||||
// Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is
|
||||
// tested on a node that has it.
|
||||
for _, has := range []bool{false, true} {
|
||||
|
||||
+31
-37
@@ -8,14 +8,15 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: "we should only use g14 as a builder
|
||||
// outside office hours", "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting names, per
|
||||
// node, the window in the node's local time in which it takes asks; outside it the capacity judged by is 0: steps running
|
||||
// finish, no new one is taken. A node it does not name, or an empty setting, builds at any hour. The puller judges it at
|
||||
// every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of another part).
|
||||
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: the laptop builds only outside
|
||||
// office hours, "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting is the node's window
|
||||
// in its local time, a config value set on each node's layer of mesh-config (ADR 0567 §12: no repository names a node);
|
||||
// outside it the capacity judged by is 0: steps running finish, no new one is taken. An empty value builds at any hour.
|
||||
// The puller judges it at every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of
|
||||
// another part).
|
||||
|
||||
// BuildHoursSetting is the build modules' setting of the nodes' build windows: `<node>=HH:MM-HH:MM` pairs separated by
|
||||
// commas, in the node's local time; an end before its start crosses midnight (g14=18:00-06:00).
|
||||
// BuildHoursSetting is the build modules' setting of the node's build window: HH:MM-HH:MM in the node's local time; an
|
||||
// end before its start crosses midnight (18:00-06:00).
|
||||
const BuildHoursSetting = "build-hours"
|
||||
|
||||
// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive.
|
||||
@@ -51,35 +52,28 @@ func (w BuildWindow) NextEdge(t time.Time) time.Time {
|
||||
return next
|
||||
}
|
||||
|
||||
// ParseBuildHours is node's window in a build-hours setting: nil when the setting is empty or does not name the node.
|
||||
func ParseBuildHours(setting, node string) (*BuildWindow, error) {
|
||||
var found *BuildWindow
|
||||
for _, pair := range strings.Split(setting, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
name, span, ok := strings.Cut(pair, "=")
|
||||
from, to, ok2 := strings.Cut(span, "-")
|
||||
if !ok || !ok2 || strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("the build hours %q: %q is not <node>=HH:MM-HH:MM", setting, pair)
|
||||
}
|
||||
start, err := minuteOf(from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
end, err := minuteOf(to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
if start == end {
|
||||
return nil, fmt.Errorf("the build hours %q: %s opens and closes at the same minute", setting, pair)
|
||||
}
|
||||
if strings.TrimSpace(name) == node {
|
||||
found = &BuildWindow{Start: start, End: end}
|
||||
}
|
||||
// ParseBuildWindow reads a build-hours value, HH:MM-HH:MM: nil when it is empty.
|
||||
func ParseBuildWindow(setting string) (*BuildWindow, error) {
|
||||
setting = strings.TrimSpace(setting)
|
||||
if setting == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return found, nil
|
||||
from, to, ok := strings.Cut(setting, "-")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the build hours %q are not HH:MM-HH:MM", setting)
|
||||
}
|
||||
start, err := minuteOf(from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
end, err := minuteOf(to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
|
||||
}
|
||||
if start == end {
|
||||
return nil, fmt.Errorf("the build hours %q open and close at the same minute", setting)
|
||||
}
|
||||
return &BuildWindow{Start: start, End: end}, nil
|
||||
}
|
||||
|
||||
// minuteOf reads HH:MM as minutes after midnight.
|
||||
@@ -92,7 +86,7 @@ func minuteOf(s string) (int, error) {
|
||||
return h*60 + m, nil
|
||||
}
|
||||
|
||||
// NodeBuildHours is node's build window, read from the module's settings file at path at every call, and why it is
|
||||
// NodeBuildHours is the node's build window, read from the module's settings file at path at every call, and why it is
|
||||
// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that
|
||||
// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety
|
||||
// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through
|
||||
@@ -125,7 +119,7 @@ func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*Build
|
||||
if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil {
|
||||
return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v))
|
||||
}
|
||||
w, err := ParseBuildHours(setting, node)
|
||||
w, err := ParseBuildWindow(setting)
|
||||
if err != nil {
|
||||
return fault(err.Error())
|
||||
}
|
||||
|
||||
+31
-34
@@ -4,45 +4,38 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
|
||||
)
|
||||
|
||||
// CapacitySetting is the build modules' setting of each node's build capacity (novox/hq ADR 0525 §5): `<node>=<asks at
|
||||
// once>` pairs separated by commas, as the builder's was (ADR 0485 §1). Each build module declares it with
|
||||
// DefaultCapacities as its default, and the controller lays it into the module's settings file.
|
||||
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
|
||||
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
|
||||
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
|
||||
// lays the node's value into the module's settings file.
|
||||
const CapacitySetting = "capacity"
|
||||
|
||||
// DefaultCapacities is the setting's default, the builder's before R4g: the control-node (novox) and the laptop (g14)
|
||||
// build nothing.
|
||||
const DefaultCapacities = "shanks=3,ace=2,g14=0,novox=0"
|
||||
|
||||
// NodeCapacity is node's build capacity, read from the module's settings file at path at every call, so a change takes
|
||||
// effect at the next pull; with no path it reads fallback. A node the setting does not name runs one ask at a time (as
|
||||
// the builder's did). A file or a setting that cannot be read gives 0 and says why: pulling on a setting nobody can read
|
||||
// could build on a node that must not.
|
||||
func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func() int {
|
||||
// NodeCapacity is the node's build capacity, read from the module's settings file at path at every call, so a change
|
||||
// takes effect at the next pull. No path, no file value, or a value that is not a whole number of at least 0 gives 0 and
|
||||
// a value that cannot be read says why: pulling on a setting nobody can read could build on a node that must not.
|
||||
func NodeCapacity(path, node string, logf func(string, ...any)) func() int {
|
||||
say := func(format string, args ...any) {
|
||||
if logf != nil {
|
||||
logf(format, args...)
|
||||
}
|
||||
}
|
||||
return func() int {
|
||||
setting := fallback
|
||||
if path != "" {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
|
||||
return 0
|
||||
}
|
||||
var s map[string]any
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
|
||||
return 0
|
||||
}
|
||||
setting, _ = s[CapacitySetting].(string)
|
||||
if path == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := capacityOf(setting, node)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
|
||||
return 0
|
||||
}
|
||||
var s map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
|
||||
return 0
|
||||
}
|
||||
n, err := capacityOf(s[CapacitySetting])
|
||||
if err != nil {
|
||||
say("%v, so %s pulls nothing", err, node)
|
||||
return 0
|
||||
@@ -51,11 +44,15 @@ func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func()
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// capacityOf is the capacity a config value holds: absent or null is 0; anything but a whole number of at least 0 is
|
||||
// refused, naming it.
|
||||
func capacityOf(value json.RawMessage) (int, error) {
|
||||
if len(value) == 0 || string(value) == "null" {
|
||||
return 0, nil
|
||||
}
|
||||
return (&buildrun.Places{Capacity: caps}).CapacityOf(node), nil
|
||||
var n int
|
||||
if err := json.Unmarshal(value, &n); err != nil || n < 0 {
|
||||
return 0, fmt.Errorf("the build capacity %s is not a whole number of asks at once, 0 or more", value)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26
|
||||
// 19:41: under the 50 % cap shanks' mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
|
||||
// 19:41: under the 50 % cap the workstation's mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
|
||||
// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's
|
||||
// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at
|
||||
// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
const CoresPerStepSetting = "cores-per-step"
|
||||
|
||||
// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog
|
||||
// check on mesh-build-go takes what it is given (three at once took 965 % of shanks' 1200 % slice, about 4 cores each,
|
||||
// check on mesh-build-go takes what it is given (three at once took 965 % of the workstation's 1200 % slice, about 4 cores each,
|
||||
// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a
|
||||
// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2.
|
||||
var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2}
|
||||
|
||||
@@ -14,30 +14,20 @@ import (
|
||||
// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores
|
||||
// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file.
|
||||
func TestNodeCapacityAndLimits(t *testing.T) {
|
||||
if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
|
||||
t.Errorf("novox = %d by default, want 0", got)
|
||||
}
|
||||
if got := NodeCapacity("", DefaultCapacities, "shanks", nil)(); got != 3 {
|
||||
t.Errorf("shanks = %d by default, want 3", got)
|
||||
// ADR 0567 §12: the capacity is the node's integer, its default 0, and no default names a node.
|
||||
if got := NodeCapacity("", "laptop", nil)(); got != 0 {
|
||||
t.Errorf("no settings file = %d, want 0", got)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
os.WriteFile(file, []byte(`{"capacity":"shanks=1,ace=2"}`), 0o600)
|
||||
capacity := NodeCapacity(file, DefaultCapacities, "shanks", nil)
|
||||
if got := capacity(); got != 1 {
|
||||
t.Errorf("shanks = %d from the file, want 1", got)
|
||||
capacity := NodeCapacity(file, "anchor", nil)
|
||||
for value, want := range map[string]int{`{"capacity":1}`: 1, `{"capacity":4}`: 4, `{}`: 0, `{"capacity":null}`: 0,
|
||||
`{"capacity":"anchor=3"}`: 0, `{"capacity":-1}`: 0, `{"capacity":1.5}`: 0} {
|
||||
os.WriteFile(file, []byte(value), 0o600)
|
||||
if got := capacity(); got != want {
|
||||
t.Errorf("%s = %d, want %d", value, got, want)
|
||||
}
|
||||
}
|
||||
os.WriteFile(file, []byte(`{"capacity":"shanks=4"}`), 0o600)
|
||||
if got := capacity(); got != 4 {
|
||||
t.Errorf("shanks = %d after the change, want 4", got)
|
||||
}
|
||||
if got := NodeCapacity(file, "", "pi", nil)(); got != 1 {
|
||||
t.Errorf("a node not named = %d, want 1", got)
|
||||
}
|
||||
os.WriteFile(file, []byte(`{"capacity":"shanks=lots"}`), 0o600)
|
||||
if got := capacity(); got != 0 {
|
||||
t.Errorf("an unreadable setting = %d, want 0", got)
|
||||
}
|
||||
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), DefaultCapacities, "shanks", nil)(); got != 0 {
|
||||
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), "anchor", nil)(); got != 0 {
|
||||
t.Errorf("a missing file = %d, want 0", got)
|
||||
}
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "")
|
||||
@@ -74,7 +64,7 @@ func TestNodeCapacityAndLimits(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
file string
|
||||
want float64
|
||||
}{{`{"capacity":"shanks=2"}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
|
||||
}{{`{"capacity":2}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
|
||||
os.WriteFile(file, []byte(c.file), 0o600)
|
||||
if got := cores(); got != c.want {
|
||||
t.Errorf("cores-per-step from %s = %g, want %g", c.file, got, c.want)
|
||||
@@ -100,18 +90,19 @@ func TestNodeCapacityAndLimits(t *testing.T) {
|
||||
bad bool
|
||||
ineligible string // what the module's Eligible says; "" for eligible
|
||||
}{
|
||||
{"g14 in the office", "g14=18:00-06:00", "g14", day(10, 0), 0, "18:00", false, ""},
|
||||
{"g14 in the evening", "g14=18:00-06:00", "g14", day(18, 0), 2, "06:00", false, ""},
|
||||
{"g14 past midnight", "g14=18:00-06:00", "g14", day(5, 59), 2, "06:00", false, ""},
|
||||
{"g14 at the close", "g14=18:00-06:00", "g14", day(6, 0), 0, "18:00", false, ""},
|
||||
{"a day window", "shanks=09:00-17:30,g14=18:00-06:00", "shanks", day(17, 29), 2, "17:30", false, ""},
|
||||
{"a node not named", "g14=18:00-06:00", "ace", day(10, 0), 2, "", false, ""},
|
||||
{"a bad time", "g14=18:00-6", "g14", day(10, 0), 2, "", true, ""},
|
||||
{"no span", "g14=18:00-18:00", "g14", day(10, 0), 2, "", true, ""},
|
||||
{"ace without /dev/net/tun", "", "ace", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"},
|
||||
{"not eligible within its hours", "g14=18:00-06:00", "g14", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"},
|
||||
{"the laptop in the office", "18:00-06:00", "laptop", day(10, 0), 0, "18:00", false, ""},
|
||||
{"the laptop in the evening", "18:00-06:00", "laptop", day(18, 0), 2, "06:00", false, ""},
|
||||
{"the laptop past midnight", "18:00-06:00", "laptop", day(5, 59), 2, "06:00", false, ""},
|
||||
{"the laptop at the close", "18:00-06:00", "laptop", day(6, 0), 0, "18:00", false, ""},
|
||||
{"a day window", "09:00-17:30", "anchor", day(17, 29), 2, "17:30", false, ""},
|
||||
{"no window", "", "anchor", day(10, 0), 2, "", false, ""},
|
||||
{"a bad time", "18:00-6", "laptop", day(10, 0), 2, "", true, ""},
|
||||
{"no span", "18:00-18:00", "laptop", day(10, 0), 2, "", true, ""},
|
||||
{"the old form naming a node", "laptop=18:00-06:00", "laptop", day(10, 0), 2, "", true, ""},
|
||||
{"a node without /dev/net/tun", "", "anchor", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"},
|
||||
{"not eligible within its hours", "18:00-06:00", "laptop", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"},
|
||||
} {
|
||||
w, err := ParseBuildHours(c.setting, c.node)
|
||||
w, err := ParseBuildWindow(c.setting)
|
||||
if (err != nil) != c.bad {
|
||||
t.Errorf("%s: parse %v", c.name, err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on shanks ran 30 minutes
|
||||
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on the workstation ran 30 minutes
|
||||
// and its module's journal held no line naming its ask, so nobody could tell whether it fetched, linted or tested). The
|
||||
// puller hands every ask's run a phase reporter in its context; RunAsk and a module's own step bodies call Phase as they
|
||||
// move (fetch and merge, a level's digest, lint, a level's tests), and each phase is logged with the ask's id and its
|
||||
|
||||
@@ -610,7 +610,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
}
|
||||
}
|
||||
|
||||
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: g14's
|
||||
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: the laptop'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
|
||||
@@ -659,7 +659,7 @@ func (p *Puller) watchHours() {
|
||||
why = fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, c.Value)
|
||||
default:
|
||||
var err error
|
||||
if w, err = ParseBuildHours(setting, p.Node); err != nil {
|
||||
if w, err = ParseBuildWindow(setting); err != nil {
|
||||
why = err.Error()
|
||||
}
|
||||
}
|
||||
@@ -689,13 +689,7 @@ 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)
|
||||
n, err := capacityOf(c.Value)
|
||||
if err != nil {
|
||||
return 0, err.Error()
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (w *configWatch) watch(key string, handle func(stdio.ProvisionChange) error
|
||||
w.asked, w.handle = key, handle
|
||||
value := w.value
|
||||
w.mu.Unlock()
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)})
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)})
|
||||
}
|
||||
|
||||
func (w *configWatch) set(t *testing.T, value string) {
|
||||
@@ -33,7 +33,7 @@ func (w *configWatch) set(t *testing.T, value string) {
|
||||
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 {
|
||||
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
||||
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"}
|
||||
w := &configWatch{value: "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,
|
||||
@@ -298,7 +298,7 @@ func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
||||
if w.asked != CapacitySetting {
|
||||
t.Fatalf("the puller watches %q, want %q", w.asked, CapacitySetting)
|
||||
}
|
||||
w.set(t, "shanks=3,g14=2")
|
||||
w.set(t, "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 {
|
||||
|
||||
@@ -149,7 +149,7 @@ func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why
|
||||
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
|
||||
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
|
||||
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
|
||||
// held one of shanks' two slots).
|
||||
// held one of a build node's two slots).
|
||||
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
|
||||
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
|
||||
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
|
||||
@@ -33,7 +35,7 @@ func beyondTheControllers(user string) map[string]bool {
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for _, s := range append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...) {
|
||||
for _, s := range append(append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...), NodeComposeAdvisories()...) {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
@@ -118,6 +120,20 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
}
|
||||
|
||||
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
// ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole
|
||||
// proposal laid over), and none of its open proposals; it writes neither.
|
||||
reads := strings.Join(NodeComposeReads("laptop"), " ")
|
||||
for bucket, granted := range map[string]bool{delivery.PlacementBucket: true, delivery.PlacementCandidatesBucket: true,
|
||||
delivery.PlacementProposalsBucket: false} {
|
||||
if strings.Contains(reads, "$JS.API.DIRECT.GET.KV_"+bucket+".>") != granted || strings.Contains(reads, "$KV."+bucket+".laptop") {
|
||||
t.Errorf("a node is granted %s: %v, want %v", bucket, !granted, granted)
|
||||
}
|
||||
}
|
||||
// It hears the creation of those two buckets, and of no other.
|
||||
if got := NodeComposeAdvisories(); len(got) != 2 || got[0] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement" ||
|
||||
got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" {
|
||||
t.Errorf("a node hears the creation of %v", got)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -898,8 +898,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// its own modules reads the other nodes' public records, which its firewall and resolver need (ADR 0547 §2). Read
|
||||
// only: each node still writes its own keys alone.
|
||||
pub = append(pub, readGrants(delivery.HaveBucket, "*."+delivery.HaveNodeWord)...)
|
||||
// And what it composes its own modules from (ADR 0547 §2, ADR 0549 §3): the indexer's placement, modules and
|
||||
// manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of
|
||||
// And what it composes its own modules from (ADR 0547 §2, ADR 0567 §9): the seat mesh-placement's placement and
|
||||
// candidates, the indexer's modules and manifests, the builder's builds (the manifest of a build its want names), and mesh-config's resolved values of
|
||||
// its own node alone. Read only.
|
||||
pub = append(pub, NodeComposeReads(p.Node)...)
|
||||
// And, of the other nodes' resolved values, the keys their modules serve from (issue 1126), each one key.
|
||||
@@ -931,6 +931,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And the backup holder's word that it backed the module up (ADR 0539 clause 16), whichever module holds
|
||||
// node-backup: one event, by its name.
|
||||
sub = append(sub, NodeAgentHears...)
|
||||
// And the creation of the seat mesh-placement's two states it composes from (ADR 0567 §9), so a node agent started
|
||||
// before they exist binds each when the bus says it was made, with no timer; no other bucket's.
|
||||
sub = append(sub, NodeComposeAdvisories()...)
|
||||
// And, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the
|
||||
// values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call.
|
||||
if p.MovesIntoTheVault {
|
||||
|
||||
+12
-6
@@ -311,12 +311,12 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err
|
||||
// ResolvedBucket is mesh-config's state of resolved values, keyed `<node>.<module>.<key>`.
|
||||
const ResolvedBucket = "mesh-config_resolved"
|
||||
|
||||
// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0549 §3, issue
|
||||
// 1126): every key of the indexer's placement, placement candidates, modules and manifests and of the builder's builds,
|
||||
// and of mesh-config's resolved values only its own node's, `<node>.>`. Every candidate key, not only `*.<node>`: a node
|
||||
// answering its candidate composes with the whole group's placement (the seed places every node at once, and a binding
|
||||
// crosses nodes), and nothing in a placement is secret. Reads only; the node is checked as every subject token is by the
|
||||
// caller's principal.
|
||||
// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0567 §3, §9,
|
||||
// issue 1126): every key of the seat mesh-placement's placement and candidates (both the assigner's states), of the
|
||||
// indexer's modules and manifests and of the builder's builds, and of mesh-config's resolved values only its own node's,
|
||||
// `<node>.>`. Every candidate key, not only `*.<node>`: a node answering its candidate composes with the whole
|
||||
// proposal laid over (the seed places every node at once, and a binding crosses nodes), and nothing in a placement is
|
||||
// secret. Reads only; the node is checked as every subject token is by the caller's principal.
|
||||
func NodeComposeReads(node string) []string {
|
||||
var out []string
|
||||
for _, bucket := range []string{delivery.PlacementBucket, delivery.PlacementCandidatesBucket, delivery.ModulesBucket,
|
||||
@@ -326,6 +326,12 @@ func NodeComposeReads(node string) []string {
|
||||
return append(out, readGrants(ResolvedBucket, node+".>")...)
|
||||
}
|
||||
|
||||
// NodeComposeAdvisories are the stream-created advisories of the seat mesh-placement's placement and candidates
|
||||
// (ADR 0567 §9): what a node agent hears to bind each once it is made. Those two and no wider.
|
||||
func NodeComposeAdvisories() []string {
|
||||
return []string{StreamCreatedAdvisory(delivery.PlacementBucket), StreamCreatedAdvisory(delivery.PlacementCandidatesBucket)}
|
||||
}
|
||||
|
||||
// ServedReadGrants is the read of each key of mesh-config's resolved values named, `<node>.<module>.<key>`
|
||||
// (Principal.ServedReads, issue 1126): a get and a watch of that key alone. Each part is checked as every subject token
|
||||
// is, so a key cannot widen the grant.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package catalogue
|
||||
|
||||
// The seat of placement (novox/hq ADR 0567): which module runs on which node, with its host ports, bindings, the
|
||||
// node's pins and holdings, the config placement and values, and what a person decides of the node, is state on the
|
||||
// bus held by this seat, changed only by a proposal the nodes it touches answer and the operator approves. Held once
|
||||
// for the mesh by the assigner (mesh-delivery-assigner), beside its wants. Its states are named after the holding
|
||||
// module, as every module state is (delivery.PlacementBucket, PlacementProposalsBucket, PlacementCandidatesBucket);
|
||||
// they are the seat's contract, so every reader takes their names from mesh-sdk, and a change of holder is a migration
|
||||
// the new holder's record decides (ADR 0567 §1). Its events are the holder's too, carried on the holder's stream
|
||||
// (delivery.PlacementStream): the names are in delivery (PlacementProposedEvent and the rest).
|
||||
|
||||
// PlacementSeat is the seat the assigner holds for placement.
|
||||
const PlacementSeat = "mesh-placement"
|
||||
|
||||
// The seat's seven verbs (ADR 0567 §10).
|
||||
const (
|
||||
PlacementVerbPlacement = "placement"
|
||||
PlacementVerbProposals = "proposals"
|
||||
PlacementVerbPropose = "propose"
|
||||
PlacementVerbWithdraw = "withdraw"
|
||||
PlacementVerbAgain = "again"
|
||||
PlacementVerbAdopt = "adopt"
|
||||
PlacementVerbHandBack = "hand-back"
|
||||
)
|
||||
|
||||
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
|
||||
// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same
|
||||
// delivery as its holder.
|
||||
func placementVerbs() []Verb {
|
||||
changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " +
|
||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||
"layer, mesh-wide-values, values)",
|
||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||
return []Verb{
|
||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||
"module is placed on, or every key, each with its revision, and how full the state is against its cap. With " +
|
||||
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
|
||||
"proposal's changes from its placement-changed event.",
|
||||
Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on",
|
||||
"history": "\"true\": the changes in order, from the stream and the state's revisions"}, nil, "history")},
|
||||
{Name: PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
|
||||
"the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " +
|
||||
"answered, its verdict and its ask. Narrowed to one by id.",
|
||||
Input: schema(map[string]string{"id": "one proposal's id"}, nil)},
|
||||
{Name: PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " +
|
||||
"the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " +
|
||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||||
"caller, as the runtime proves it.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||||
"properties": map[string]any{
|
||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||||
"changes": changes,
|
||||
}}},
|
||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||||
Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})},
|
||||
{Name: PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " +
|
||||
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
|
||||
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
|
||||
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
|
||||
{Name: PlacementVerbAdopt, Optional: true, Description: "Take placement over from the controller: its " +
|
||||
"placement-export is proposed whole as the seed, every node's key, which every node must answer as the same. " +
|
||||
"Its Approve is the take-over; at the warrant the export is read again and a changed one is proposed anew.",
|
||||
Input: schema(map[string]string{"why": "why, shown to the operator"}, []string{"why"})},
|
||||
{Name: PlacementVerbHandBack, Optional: true, Description: "The operator's alone, recorded as a hand act: " +
|
||||
"delete every placement key, the history kept, so every node returns to the controller's compositions. " +
|
||||
"Refused after the lease write.",
|
||||
Input: schema(map[string]string{"why": "why, recorded with the hand act"}, []string{"why"})},
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,9 @@ var defaultSeats = append([]Seat{
|
||||
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
|
||||
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
|
||||
{Name: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
|
||||
// Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by
|
||||
// a proposal the nodes answer and the operator approves. Its states and events are the holder module's.
|
||||
{Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"},
|
||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
||||
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -43,6 +44,11 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Forty-seven with mesh-placement (novox/hq ADR 0567), serving its seven verbs.
|
||||
if s, ok := SeatNamed(PlacementSeat); !ok || s.Scope != ScopeMesh || !reflect.DeepEqual(VerbNames(s.Serves),
|
||||
[]string{"placement", "proposals", "propose", "withdraw", "again", "adopt", "hand-back"}) {
|
||||
t.Errorf("the placement seat is %v, %v", ok, s)
|
||||
}
|
||||
// Forty-six with the three build seats replacing node-build-agent and the retired mesh-build-machine (novox/hq
|
||||
// ADR 0525 §2, §10); forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes.
|
||||
// Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
@@ -56,8 +62,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 46 {
|
||||
t.Errorf("the mesh defines %d seats rather than 46; the set is closed, so a change here is "+
|
||||
if len(Seats()) != 47 {
|
||||
t.Errorf("the mesh defines %d seats rather than 47; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
// placement-seed writes the catalogue's first placement files (novox/hq ADR 0549 §4, ADR 0555 §6, issue 1126) from the
|
||||
// answer of the controller's one read-only verb `placement-export`: one `placement/<node>.json` per node, each the
|
||||
// node's placement as the controller's own code read it from its own store, checked as the catalogue's check checks it
|
||||
// (delivery.CheckPlacements) before anything is written. The pull request it makes merges only when its composition check
|
||||
// composes every node equal to what it runs.
|
||||
//
|
||||
// placement-seed -catalogue <checkout of mesh-catalog> < export.json
|
||||
// placement-seed -catalogue <dir> -export export.json
|
||||
//
|
||||
// export.json is the verb's answer as it prints it ({"<node>": <placement>, ...}, or the verb's wrapping with it under
|
||||
// "answer"). It opens no network connection, bus or store; it writes only <dir>/placement/<node>.json, and refuses to
|
||||
// write over a placement file already there. Exit codes: 0 written; 1 the export cannot be read, is refused by the
|
||||
// check, or a file is there; 2 the flags are wrong.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
catalogue := flag.String("catalogue", "", "the checkout of the catalogue to write placement/<node>.json into")
|
||||
export := flag.String("export", "", "the verb's answer; standard input when absent")
|
||||
flag.Parse()
|
||||
if *catalogue == "" || flag.NArg() > 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: placement-seed -catalogue <dir> [-export <file>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
in := io.Reader(os.Stdin)
|
||||
if *export != "" {
|
||||
f, err := os.Open(*export)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
written, err := Seed(in, *catalogue)
|
||||
for _, path := range written {
|
||||
fmt.Println(path)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "placement-seed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed reads the export and writes each node's placement file under root, answering the paths written. Nothing is
|
||||
// written when the export does not read, the check refuses it, or a placement file is already there.
|
||||
func Seed(export io.Reader, root string) ([]string, error) {
|
||||
raw, err := io.ReadAll(export)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
placements, err := read(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all := func(string) bool { return true }
|
||||
if refused := delivery.CheckPlacements(placements, all, all); len(refused) > 0 {
|
||||
return nil, fmt.Errorf("the export is refused by the catalogue's check: %v", refused)
|
||||
}
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for node := range placements {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
files := map[string][]byte{}
|
||||
for _, node := range nodes {
|
||||
p := placements[node]
|
||||
p.Revision = "" // only the indexer writes a revision, into the key
|
||||
if _, err := delivery.EncodePlacement(p); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", node, err)
|
||||
}
|
||||
p.V = delivery.Version
|
||||
body, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil, fmt.Errorf("%s is already there; the seed is written once (ADR 0549 §4)", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
files[path] = append(body, '\n')
|
||||
}
|
||||
var written []string
|
||||
for _, node := range nodes {
|
||||
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return written, err
|
||||
}
|
||||
if err := os.WriteFile(path, files[path], 0o644); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written = append(written, path)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// read is the export as the verb prints it, or wrapped as a verb's answer (the controller's verbAnswer: its JSON under
|
||||
// "answer").
|
||||
func read(raw []byte) (map[string]delivery.Placement, error) {
|
||||
var wrapped struct {
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Answer) > 0 {
|
||||
raw = wrapped.Answer
|
||||
}
|
||||
var byNode map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &byNode); err != nil {
|
||||
return nil, fmt.Errorf("the export is not {<node>: <placement>}: %w", err)
|
||||
}
|
||||
if len(byNode) == 0 {
|
||||
return nil, errors.New("the export names no node")
|
||||
}
|
||||
out := map[string]delivery.Placement{}
|
||||
for node, body := range byNode {
|
||||
p, err := delivery.DecodePlacement(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", node, err)
|
||||
}
|
||||
if p.Node != node {
|
||||
return nil, fmt.Errorf("the export's placement of %s names the node %q", node, p.Node)
|
||||
}
|
||||
out[node] = p
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Issue 1126 (ADR 0549 §4, ADR 0555 §6): the seed writes one placement file per node of the export, as the catalogue's
|
||||
// check and the indexer read it, from the verb's answer bare or wrapped; it writes nothing for an export the check
|
||||
// refuses, and never over a file already there.
|
||||
func TestTheSeedWritesEachNodesPlacementFileOnce(t *testing.T) {
|
||||
export := `{"answer":{"ace":{"v":1,"node":"ace","revision":"x","decided":{"address":"10.77.0.2"},"modules":[{"module":"plex","ports":{"32400":32400}}]},` +
|
||||
`"novox":{"v":1,"node":"novox","decided":{"hub":true,"address":"10.77.0.1"},"modules":[]}}}`
|
||||
root := t.TempDir()
|
||||
written, err := Seed(strings.NewReader(export), root)
|
||||
if err != nil || len(written) != 2 {
|
||||
t.Fatalf("written %v, %v", written, err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "placement", "ace.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil || p.Node != "ace" || p.Revision != "" || p.HostPorts()["plex"][32400] != 32400 {
|
||||
t.Errorf("ace's file reads %+v, %v", p, err)
|
||||
}
|
||||
if _, err := Seed(strings.NewReader(export), root); err == nil || !strings.Contains(err.Error(), "already there") {
|
||||
t.Errorf("a second seed over the files: %v", err)
|
||||
}
|
||||
clash := `{"ace":{"v":1,"node":"ace","decided":{"address":"10.77.0.1"},"modules":[]},"novox":{"v":1,"node":"novox","decided":{"address":"10.77.0.1"},"modules":[]}}`
|
||||
other := t.TempDir()
|
||||
if _, err := Seed(strings.NewReader(clash), other); err == nil || !strings.Contains(err.Error(), "both have the address") {
|
||||
t.Errorf("an export the check refuses: %v", err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(filepath.Join(other, "placement")); len(entries) != 0 {
|
||||
t.Errorf("a refused export wrote %v", entries)
|
||||
}
|
||||
// What the controller's placement-export prints, frombus.Export per node, is what the seed takes: a node with no
|
||||
// module placed included.
|
||||
in := compose.Input{Nodes: []compose.Node{{Name: "solo", Place: compose.Place{Hub: true, Address: "10.77.0.9"}}}}
|
||||
exported, err := frombus.Export(in, "solo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, _ := json.Marshal(map[string]any{"answer": map[string]delivery.Placement{"solo": exported}})
|
||||
if written, err := Seed(strings.NewReader(string(answer)), t.TempDir()); err != nil || len(written) != 1 {
|
||||
t.Errorf("Export's answer %s was not seeded: %v, %v", answer, written, err)
|
||||
}
|
||||
for _, bad := range []string{`[]`, `{}`, `{"ace":{"v":1,"node":"novox","modules":[]}}`} {
|
||||
if _, err := Seed(strings.NewReader(bad), t.TempDir()); err == nil {
|
||||
t.Errorf("%s was seeded", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,18 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Answer is a node's answer to its group's placement candidates (novox/hq ADR 0559 §4): s.Node composed in shadow with
|
||||
// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
|
||||
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
|
||||
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
|
||||
// nothing; head and revision are the candidate's, said back in the answer.
|
||||
// nothing; the proposal and the revision are the candidate's, said back in the answer.
|
||||
//
|
||||
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
|
||||
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
|
||||
// composition stands in for is judged: a value of a module the group adds (on this node or another: its credential, own
|
||||
// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
|
||||
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
|
||||
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, head string, revision uint64) delivery.CandidateAnswer {
|
||||
a := delivery.CandidateAnswer{Head: head, Revision: revision}
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
|
||||
a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
|
||||
refuse := func(err error) delivery.CandidateAnswer {
|
||||
a.Refused = err.Error()
|
||||
a.Verdict = delivery.CandidateRefused
|
||||
@@ -138,7 +138,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
|
||||
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
|
||||
|
||||
// What the group adds, by node and module: placed by a candidate and not by the trunk on that node.
|
||||
// What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
|
||||
added := map[string]bool{}
|
||||
for node, p := range s.Placements {
|
||||
if p == nil {
|
||||
|
||||
@@ -70,7 +70,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
|
||||
b := mk("lemurs", "web", "api", "spiegel")
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "h1", 3)
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
var refused []string
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
@@ -141,14 +141,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
if string(gotOwn) != string(wantOwn) {
|
||||
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
|
||||
}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "h1", 3)
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
|
||||
if a.Verdict != delivery.CandidateSame {
|
||||
t.Errorf("the fold's answer: %+v, want the same", a)
|
||||
}
|
||||
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
|
||||
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
|
||||
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
|
||||
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
|
||||
}
|
||||
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
|
||||
@@ -161,7 +161,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
}
|
||||
withMembership = append(withMembership, c)
|
||||
}
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "h1", 3); a.Verdict != delivery.CandidateSame {
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
|
||||
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
|
||||
}
|
||||
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
|
||||
@@ -301,7 +301,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f")}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "h1", 3)
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||
refused = nil
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
@@ -469,7 +469,7 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
|
||||
p := p
|
||||
given[n] = &p
|
||||
}
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "h", 1)
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
|
||||
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
|
||||
t.Errorf("a refusal for want of n2's provider = %+v", a)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Export is one node's placement file as the composition input the controller fills says it (novox/hq ADR 0555 §6,
|
||||
// issue 1126): the inverse of Input for what a person decides, so the controller's one read-only verb
|
||||
// `placement-export` answers, from its own store by its own code, exactly what the seed writes into the catalogue.
|
||||
// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
|
||||
// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
|
||||
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
|
||||
// stable for the same store, since adopt checks its digest again at the warrant.
|
||||
// What the machine observes (its record) is not in it.
|
||||
func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
var n *compose.Node
|
||||
@@ -24,8 +25,8 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
if n == nil {
|
||||
return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
|
||||
}
|
||||
// At the version the placement file is read at, with a list of modules even when none is placed: the export is the
|
||||
// file the seed writes (cmd/placement-seed), and the reader refuses a value of version 0 or without its modules.
|
||||
// At the version a placement key is read at, with a list of modules even when none is placed: the export is the
|
||||
// seed's value, and the reader refuses a value of version 0 or without its modules.
|
||||
p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
|
||||
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
|
||||
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126).
|
||||
//
|
||||
// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its
|
||||
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the assigner's composition check for every
|
||||
// node (ADR 0539 §6's check, ADR 0547 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
|
||||
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a
|
||||
// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
|
||||
// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads.
|
||||
//
|
||||
// **What it reads, and from whom.**
|
||||
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the
|
||||
// indexer's state `placement`, one key per node (ADR 0549 §2);
|
||||
// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1);
|
||||
// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and
|
||||
// accounts): the same placement key, Placement.Decided;
|
||||
// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the
|
||||
@@ -107,9 +107,9 @@ type Mesh struct {
|
||||
// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer
|
||||
// read it: the check of every node judges placement, and reads no archive.
|
||||
//
|
||||
// Placements, when set, stands in for the indexer's placement of the nodes it names: a node's candidate placement
|
||||
// (delivery.PlacementCandidate), so a node composes in shadow with a catalogue pull request's placement and compares
|
||||
// the result with what it runs (issue 1126); a nil value removes the node's placement. The rest is read as always.
|
||||
// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates
|
||||
// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with
|
||||
// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always.
|
||||
//
|
||||
// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again
|
||||
// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126).
|
||||
|
||||
@@ -401,7 +401,9 @@ func TestInput(t *testing.T) {
|
||||
check func(delivery.CandidateAnswer) bool
|
||||
}{
|
||||
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 3 && a.Head == "h1" && a.Revision == 7 }},
|
||||
func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
|
||||
}},
|
||||
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
|
||||
@@ -442,7 +444,7 @@ func TestInput(t *testing.T) {
|
||||
if name == "mesh-wireguard and an old-form module, folded" {
|
||||
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
|
||||
}
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "h1", 7)
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
|
||||
if a.Verdict != c.verdict || !c.check(a) {
|
||||
t.Errorf("%s: %+v", name, a)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4): which nodes must
|
||||
// answer it and what it changes on each (Intended), and the verdict of their answers (JudgeAnswers). Moved here from
|
||||
// the assigner, where it judged a catalogue pull request's placement (ADR 0559 §5), with a proposal in place of a pull
|
||||
// request's change. It holds no bus client and no timer: the holder gives it what it read.
|
||||
package judge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// AnswerBound is how long the holder waits for a node's answer after its candidate was written (ADR 0567 §4): the time to
|
||||
// live of the node's pulse key (delivery.PulseAnswerKey), whose expiry names it silent, never passed. A variable so a test
|
||||
// on the bus can shorten it.
|
||||
var AnswerBound = 10 * time.Minute
|
||||
|
||||
// Intended is, by node, the modules whose composition the proposal changes, and the nodes that must answer it (§3).
|
||||
// current is the composition input with the current placement and laidInput the same with the proposal's placements
|
||||
// (laid, by node; nil removes the node's key) laid over it; nodes is every node to look at (every node with a record).
|
||||
// A node's set holds the modules the proposal adds, removes or whose placed entry differs on that node, and those whose
|
||||
// composition differs between the two inputs (delivery.CompareComposed), the node's own named "node". Must is every node
|
||||
// whose key the proposal changes and every other whose set is not empty, sorted. A node that has no key now gets an
|
||||
// empty set (§7: the seed is answered as the same). A node whose compositions cannot be made on either side keeps the
|
||||
// modules its entries name, said in notes; such a node is in Must only when its key changes.
|
||||
func Intended(current, laidInput *compose.Input, currentPlaced map[string]delivery.Placement, laid map[string]*delivery.Placement,
|
||||
nodes []string, fps map[string]string) (intended map[string]map[string]bool, must []string, notes []string) {
|
||||
intended = map[string]map[string]bool{}
|
||||
inMust := map[string]bool{}
|
||||
all := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
all[n] = true
|
||||
}
|
||||
for n := range laid {
|
||||
all[n] = true
|
||||
inMust[n] = true
|
||||
}
|
||||
names := make([]string, 0, len(all))
|
||||
for n := range all {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, node := range names {
|
||||
before, placed := currentPlaced[node]
|
||||
set := map[string]bool{}
|
||||
after, changesKey := laid[node]
|
||||
if changesKey && !placed {
|
||||
intended[node] = set // a node new to placement: every answer must be the same
|
||||
continue
|
||||
}
|
||||
if changesKey {
|
||||
var afterModules []delivery.PlacedModule
|
||||
if after != nil {
|
||||
afterModules = after.Modules
|
||||
}
|
||||
entries := map[string]delivery.PlacedModule{}
|
||||
for _, m := range before.Modules {
|
||||
entries[m.Module] = m
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range afterModules {
|
||||
seen[m.Module] = true
|
||||
if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) {
|
||||
set[m.Module] = true
|
||||
}
|
||||
}
|
||||
for name := range entries {
|
||||
if !seen[name] {
|
||||
set[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if placed && current != nil && laidInput != nil {
|
||||
was, _, _, errB := compose.CompositionsShadow(*current, node, fps)
|
||||
now, _, _, errA := compose.CompositionsShadow(*laidInput, node, fps)
|
||||
if errA == nil && errB == nil {
|
||||
running := make([]delivery.Composed, 0, len(was))
|
||||
for _, c := range was {
|
||||
running = append(running, c)
|
||||
}
|
||||
cmp := delivery.CompareComposed(now, running)
|
||||
for _, d := range cmp.Differs {
|
||||
set[d.Module] = true
|
||||
}
|
||||
for _, d := range cmp.Added {
|
||||
set[d.Module] = true
|
||||
}
|
||||
for _, m := range cmp.Removed {
|
||||
set[m] = true
|
||||
}
|
||||
} else if changesKey {
|
||||
notes = append(notes, fmt.Sprintf("what the proposal changes on %s is read from its placement alone: its "+
|
||||
"compositions cannot be made (now: %v; proposed: %v)", node, errB, errA))
|
||||
}
|
||||
}
|
||||
if len(set) > 0 {
|
||||
inMust[node] = true
|
||||
}
|
||||
if inMust[node] {
|
||||
intended[node] = set
|
||||
}
|
||||
}
|
||||
for n := range inMust {
|
||||
must = append(must, n)
|
||||
}
|
||||
sort.Strings(must)
|
||||
return intended, must, notes
|
||||
}
|
||||
|
||||
// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the
|
||||
// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it
|
||||
// names the proposal and that revision. It fails on a refusal by a node that must answer, and on every node of Must with
|
||||
// no answer that counts once its pulse key has ended (pulsing false: the bound's expiry, heard on the bus); while it
|
||||
// stands it waits for them.
|
||||
// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both
|
||||
// shown in the ask's brief. The seed alone fails on any difference.
|
||||
func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer,
|
||||
revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict {
|
||||
v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{},
|
||||
Answers: map[string]delivery.CandidateAnswer{}}
|
||||
for node, set := range intended {
|
||||
for m := range set {
|
||||
v.Intended[node] = append(v.Intended[node], m)
|
||||
}
|
||||
sort.Strings(v.Intended[node])
|
||||
}
|
||||
must := p.Must
|
||||
if len(must) == 0 {
|
||||
must = p.Nodes()
|
||||
}
|
||||
for _, node := range must {
|
||||
a := answers[node]
|
||||
if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] {
|
||||
v.Silent = append(v.Silent, node)
|
||||
continue
|
||||
}
|
||||
v.Answers[node] = *a
|
||||
if a.Refused != "" {
|
||||
v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused))
|
||||
continue
|
||||
}
|
||||
for _, r := range a.Refusals {
|
||||
v.Fails = append(v.Fails, fmt.Sprintf("%s refuses the proposal for %s: %s", node, r.Module, r.Why))
|
||||
}
|
||||
want := intended[node]
|
||||
differs := func(module, words string) {
|
||||
if p.Seed {
|
||||
v.Fails = append(v.Fails, fmt.Sprintf("%s %s with the seed, which must be the same as what runs", node, words))
|
||||
return
|
||||
}
|
||||
if !want[module] {
|
||||
v.Unintended[node] = append(v.Unintended[node], module)
|
||||
}
|
||||
}
|
||||
for _, d := range a.Differs {
|
||||
differs(d.Module, fmt.Sprintf("composes %s differently (%s)", d.Module, d.Why))
|
||||
}
|
||||
for _, d := range a.Added {
|
||||
differs(d.Module, "would run "+d.Module)
|
||||
}
|
||||
for _, m := range a.Removed {
|
||||
differs(m, "would stop running "+m)
|
||||
}
|
||||
sort.Strings(v.Unintended[node])
|
||||
}
|
||||
if len(v.Intended) == 0 {
|
||||
v.Intended = nil
|
||||
}
|
||||
if len(v.Unintended) == 0 {
|
||||
v.Unintended = nil
|
||||
}
|
||||
if len(v.Answers) == 0 {
|
||||
v.Answers = nil
|
||||
}
|
||||
var waiting, gone []string
|
||||
for _, node := range v.Silent {
|
||||
named := node
|
||||
if online != nil && !online[node] {
|
||||
named += " (offline)"
|
||||
}
|
||||
if pulsing[node] {
|
||||
waiting = append(waiting, named)
|
||||
} else {
|
||||
gone = append(gone, named)
|
||||
}
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write",
|
||||
strings.Join(waiting, ", "), AnswerBound))
|
||||
}
|
||||
if len(gone) > 0 {
|
||||
v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of its candidate's write; judged again when it "+
|
||||
"answers", strings.Join(gone, ", "), AnswerBound))
|
||||
}
|
||||
switch {
|
||||
case len(v.Fails) > 0:
|
||||
v.State = delivery.VerdictFail
|
||||
case len(waiting) > 0:
|
||||
v.State = delivery.VerdictWaits
|
||||
default:
|
||||
v.State = delivery.VerdictPass
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered
|
||||
// beside it, marked, with the proposal's why.
|
||||
func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By)
|
||||
nodes := map[string]bool{}
|
||||
for n := range v.Intended {
|
||||
nodes[n] = true
|
||||
}
|
||||
for n := range v.Unintended {
|
||||
nodes[n] = true
|
||||
}
|
||||
for _, n := range p.Nodes() {
|
||||
nodes[n] = true
|
||||
}
|
||||
names := make([]string, 0, len(nodes))
|
||||
for n := range nodes {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, "; %s:", n)
|
||||
if m := v.Intended[n]; len(m) > 0 {
|
||||
fmt.Fprintf(&b, " changes %s", strings.Join(m, ", "))
|
||||
} else {
|
||||
b.WriteString(" composes the same")
|
||||
}
|
||||
if m := v.Unintended[n]; len(m) > 0 {
|
||||
fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", "))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package judge
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal
|
||||
// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node
|
||||
// failing, a later answer judging again, the seed failing on any difference.
|
||||
func TestJudge(t *testing.T) {
|
||||
current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}}
|
||||
laid := map[string]*delivery.Placement{
|
||||
"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}},
|
||||
"laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}}
|
||||
got, must, _ := Intended(nil, nil, current, laid, []string{"anchor", "laptop", "server"}, nil)
|
||||
if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || !slices.Equal(must, []string{"anchor", "laptop"}) {
|
||||
t.Errorf("intended = %v, must %v: want anchor's mail and web, nothing on the laptop that has no key, server untouched", got, must)
|
||||
}
|
||||
|
||||
at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC)
|
||||
p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0},
|
||||
Must: []string{"anchor", "laptop"}}
|
||||
revisions := map[string]uint64{"anchor": 7, "laptop": 8}
|
||||
intended := map[string]map[string]bool{"anchor": {"web": true}}
|
||||
same := func(rev uint64) *delivery.CandidateAnswer {
|
||||
return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}}
|
||||
}
|
||||
differs := func(rev uint64, module string) *delivery.CandidateAnswer {
|
||||
return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers,
|
||||
Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
seed bool
|
||||
answers map[string]*delivery.CandidateAnswer
|
||||
pulsing map[string]bool
|
||||
state string
|
||||
words string
|
||||
marked string
|
||||
}{
|
||||
{"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
|
||||
{"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers,
|
||||
Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
|
||||
{"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"},
|
||||
{"the seed with a difference", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": differs(8, "web")}, nil, delivery.VerdictFail, "laptop composes web differently", ""},
|
||||
{"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused,
|
||||
Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""},
|
||||
{"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""},
|
||||
{"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictFail, "anchor did not answer", ""},
|
||||
{"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
|
||||
} {
|
||||
q := p
|
||||
q.Seed = row.seed
|
||||
v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.pulsing, at)
|
||||
all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ")
|
||||
if v.State != row.state || !strings.Contains(all, row.words) {
|
||||
t.Errorf("%s: %s, %q", row.name, v.State, all)
|
||||
}
|
||||
if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) {
|
||||
t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,38 +4,12 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DefaultCapacity is the build capacity of a node that has no setting: one build step at a time.
|
||||
const DefaultCapacity = 1
|
||||
|
||||
// ParseCapacity reads a capacity setting: `node=steps` pairs separated by commas, such as
|
||||
// `shanks=3,ace=2`. A node without a pair has DefaultCapacity. A pair with no node, a count that is not a whole number of
|
||||
// at least 0, and a node named twice are refused. A count of 0 keeps the part off the node: it is never chosen.
|
||||
func ParseCapacity(s string) (map[string]int, error) {
|
||||
out := map[string]int{}
|
||||
for _, pair := range strings.Split(s, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
node, count, ok := strings.Cut(pair, "=")
|
||||
node, count = strings.TrimSpace(node), strings.TrimSpace(count)
|
||||
n, err := strconv.Atoi(count)
|
||||
if !ok || node == "" || err != nil || n < 0 {
|
||||
return nil, fmt.Errorf("the capacity %q is not <node>=<steps at once, 0 or more>", pair)
|
||||
}
|
||||
if _, dup := out[node]; dup {
|
||||
return nil, fmt.Errorf("the capacity names %s twice", node)
|
||||
}
|
||||
out[node] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Places counts the build steps a part runs on each node and holds a lane back while its node is at its
|
||||
// capacity (the operator's decision of 2026-10-21; ADR 0478 clause 3). A lane is the fetch and the builds of one build
|
||||
// module on one node, run one step at a time, so a lane holds one place while it runs. A node is chosen for a lane by
|
||||
|
||||
@@ -7,21 +7,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseCapacityReadsNodeEqualsCountPairs(t *testing.T) {
|
||||
c, err := ParseCapacity(" shanks=3, ace=2 ")
|
||||
if err != nil || c["shanks"] != 3 || c["ace"] != 2 || len(c) != 2 {
|
||||
t.Errorf("capacity = %v, %v", c, err)
|
||||
}
|
||||
if c, err := ParseCapacity(""); err != nil || len(c) != 0 {
|
||||
t.Errorf("empty: %v, %v", c, err)
|
||||
}
|
||||
for _, bad := range []string{"shanks", "shanks=x", "=2", "a=1,a=2", "shanks=-1"} {
|
||||
if _, err := ParseCapacity(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func acquireAsync(p *Places, node string) chan func() {
|
||||
got := make(chan func(), 1)
|
||||
go func() {
|
||||
@@ -95,17 +80,10 @@ func TestAWaitingStepStopsWhenItsContextEnds(t *testing.T) { // round 3
|
||||
|
||||
// The operator's decision of 2026-10-21 13:40: the builder builds on shanks and ace only; a capacity of 0 keeps it off a node.
|
||||
func TestACapacityOfZeroKeepsThePartOffTheNodeAndANodeNotNamedKeepsOne(t *testing.T) { // issue 1661
|
||||
c, err := ParseCapacity("shanks=3,ace=2,g14=0,novox=0")
|
||||
if err != nil || c["g14"] != 0 || c["novox"] != 0 || c["shanks"] != 3 || len(c) != 4 {
|
||||
t.Fatalf("capacity = %v, %v", c, err)
|
||||
}
|
||||
p := &Places{Capacity: c}
|
||||
p := &Places{Capacity: map[string]int{"shanks": 3, "ace": 2, "g14": 0, "novox": 0}}
|
||||
if p.CapacityOf("g14") != 0 || p.CapacityOf("novox") != 0 || p.CapacityOf("ace") != 2 || p.CapacityOf("other") != DefaultCapacity {
|
||||
t.Errorf("capacities = %d %d %d %d, want 0 0 2 and the default for a node not named", p.CapacityOf("g14"), p.CapacityOf("novox"), p.CapacityOf("ace"), p.CapacityOf("other"))
|
||||
}
|
||||
if _, err := ParseCapacity("shanks=-1"); err == nil {
|
||||
t.Error("a negative capacity accepted")
|
||||
}
|
||||
entries := tcs("go", "go1.27.1", "ace", "g14", "novox", "shanks", "other")
|
||||
for i := 0; i < 6; i++ {
|
||||
nodes, _, err := ChooseNodes("go", entries, p, nil, 2)
|
||||
@@ -119,7 +97,7 @@ func TestACapacityOfZeroKeepsThePartOffTheNodeAndANodeNotNamedKeepsOne(t *testin
|
||||
}
|
||||
}
|
||||
// Only nodes of capacity 0 report: nothing qualifies, and the build says why.
|
||||
_, _, err = ChooseNodes("go", tcs("go", "go1.27.1", "g14", "novox"), p, nil, 2)
|
||||
_, _, err := ChooseNodes("go", tcs("go", "go1.27.1", "g14", "novox"), p, nil, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "capacity") {
|
||||
t.Errorf("only capacity-0 nodes: error = %v, want it to name the capacity", err)
|
||||
}
|
||||
|
||||
@@ -413,11 +413,11 @@ type HaveNode struct {
|
||||
Sealed []string `json:"sealed,omitempty"`
|
||||
// SealedAt is Sealed by slot (Have.SealedAt).
|
||||
SealedAt Seals `json:"sealed-at,omitempty"`
|
||||
// Candidates is the node's answer to each placement candidate naming it, by group (PlacementCandidate): written by
|
||||
// Candidates is the node's answer to each placement candidate naming it, by proposal id (PlacementCandidate): written by
|
||||
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
|
||||
// candidate.
|
||||
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
|
||||
// Composes is whose compositions the node applies: its own (ComposesOwn, the catalogue places it) or the
|
||||
// Composes is whose compositions the node applies: its own (ComposesOwn, placement places it) or the
|
||||
// controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a
|
||||
// node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty
|
||||
// from an engine older than the field.
|
||||
|
||||
@@ -9,10 +9,9 @@ const oddManifest = "{\n\t\"module\": \"a\",\n \"z\": 1, \"a\": [ \"x\",\"y\"
|
||||
|
||||
func mergedWithInputs() Forge {
|
||||
return Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", Root: "t0",
|
||||
Trees: map[string]string{"a": "t1", "b": "t2"},
|
||||
Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}},
|
||||
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
|
||||
Trees: map[string]string{"a": "t1", "b": "t2"},
|
||||
Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}}
|
||||
}
|
||||
|
||||
// 1442/AC1
|
||||
@@ -22,7 +21,7 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`, `"placements":{"ace":`, `"placements-read":true`} {
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`} {
|
||||
if !strings.Contains(string(data), frag) {
|
||||
t.Errorf("the encoding lacks %s: %s", frag, data)
|
||||
}
|
||||
@@ -48,9 +47,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
|
||||
"locks outside trees": {func(f *Forge) { f.Locks["yy"] = map[string]string{"go.sum": "h"} }, `"yy"`},
|
||||
"empty manifest": {func(f *Forge) { f.Manifests["b"] = "" }, `"b"`},
|
||||
"empty lock hash": {func(f *Forge) { f.Locks["a"]["go.sum"] = "" }, `"a"`},
|
||||
"placements unsaid": {func(f *Forge) { f.PlacementsRead = false }, "placements-read"},
|
||||
"placement of no node": {func(f *Forge) { f.Placements["a.b"] = "{}" }, "names no node"},
|
||||
"placements on a pull": {func(f *Forge) { f.Kind, f.Action, f.Merge, f.Root, f.Trees = ForgePull, PullOpened, "", "", nil }, "only a merged message"},
|
||||
// No repository holds placement (ADR 0567 §11): a message carrying placement files is refused, on a merge or a pull.
|
||||
"placement files": {func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) }, "mesh-placement"},
|
||||
"placements read": {func(f *Forge) { f.RetiredRead = true }, "mesh-placement"},
|
||||
} {
|
||||
f := mergedWithInputs()
|
||||
tc.mut(&f)
|
||||
@@ -86,14 +85,6 @@ func TestForgePullCarriesManifestsAndLocks(t *testing.T) {
|
||||
if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"c"`) {
|
||||
t.Errorf("locks outside Dirs: %v", err)
|
||||
}
|
||||
// A pull message of the catalogue carries its head's placement files whole (placement candidates, issue 1126).
|
||||
f = Forge{Kind: ForgePull, Repository: CatalogueRepository, Number: 2, Head: "h", Action: PullUpdated,
|
||||
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
|
||||
if data, err := EncodeForge(f); err != nil {
|
||||
t.Errorf("a catalogue pull with placement files: %v", err)
|
||||
} else if got, err := DecodeForge(data); err != nil || !SameForge(got, withV(f)) {
|
||||
t.Errorf("a catalogue pull's placement files changed on the way: %+v, %v", got, err)
|
||||
}
|
||||
for name, f := range map[string]Forge{
|
||||
"refused manifests": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}},
|
||||
"refused locks": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Locks: map[string]map[string]string{"a": {"go.sum": "h"}}},
|
||||
|
||||
+49
-53
@@ -3,7 +3,6 @@ package delivery
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -11,29 +10,38 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Placement of modules on nodes lives in the catalogue (novox/hq ADR 0549): one file per node, `placement/<node>.json`
|
||||
// in the catalogue repository, changed only by a pull request. At each trunk merge of the catalogue the indexer reads
|
||||
// the files and writes each node's key `placement.<node>` in its state `placement`, only when it differs, and says
|
||||
// `placement-changed` naming the node, the revision and the modules added, removed or changed (ADR 0549 §2). The node
|
||||
// agents, the assigner, the grants part and mesh-config read it (§3); nothing in it is secret.
|
||||
// Placement of modules on nodes is the state of the seat mesh-placement (novox/hq ADR 0567), held once for the mesh by
|
||||
// the assigner: one key `placement.<node>` per node in its state `placement`, written only on the operator's Approve of a
|
||||
// proposal the nodes it touches answered (§2-§5), by compare-and-set at the revision the proposal was laid over. No
|
||||
// repository holds it. The node agents, the grants part, mesh-config and the status view read it (§9); nothing in it is
|
||||
// secret. Its states are named after the holding module, as every module state is, and these constants are the seat's
|
||||
// contract: every reader takes the names from here (§1).
|
||||
const (
|
||||
// PlacementState is the indexer's state of placements, and PlacementBucket its bucket.
|
||||
// PlacementState is the seat's state of placements, and PlacementBucket its bucket. PlacementRead is the name a
|
||||
// reader's manifest declares in its reads.
|
||||
PlacementState = "placement"
|
||||
PlacementBucket = IndexerModule + "_" + PlacementState
|
||||
// PlacementHistory is the revisions the placement bucket keeps per key.
|
||||
PlacementHistory = 10
|
||||
// PlacementDir is the catalogue's directory of placement files, one `<node>.json` per node.
|
||||
PlacementDir = "placement"
|
||||
// PlacementChangedEvent is what the indexer says when a node's placement key changed.
|
||||
PlacementChangedEvent = "placement-changed"
|
||||
PlacementBucket = AssignerModule + "_" + PlacementState
|
||||
PlacementRead = AssignerModule + "." + PlacementState
|
||||
// PlacementHistory is the revisions the placement bucket keeps per key: the bus's most (§6).
|
||||
PlacementHistory = 64
|
||||
|
||||
// PlacementStream is the holder's stream of the seat's events, kept PlacementStreamDays (§6, within the mesh's bound
|
||||
// on a module's stream, catalogue.StreamMostDays), and declared as the holder's data so the mesh's backups keep it.
|
||||
PlacementStream = "placement"
|
||||
PlacementStreamDays = 30
|
||||
|
||||
// The seat's events, said by its holder as its own (§2, §4, §5, §6), each carrying a PlacementEvent.
|
||||
PlacementProposedEvent = "placement-proposed"
|
||||
PlacementWithdrawnEvent = "placement-withdrawn"
|
||||
PlacementJudgedEvent = "placement-judged"
|
||||
PlacementChangedEvent = "placement-changed"
|
||||
|
||||
placementWord = "placement"
|
||||
|
||||
// CatalogueRepository is the repository whose placement files are the mesh's placement (ADR 0549 §1): the forge bridge
|
||||
// reads them at each of its merges, and no other repository's.
|
||||
CatalogueRepository = "novox/mesh-catalog"
|
||||
)
|
||||
|
||||
// PlacementEvents are the seat's four events, in the order a proposal says them.
|
||||
var PlacementEvents = []string{PlacementProposedEvent, PlacementJudgedEvent, PlacementChangedEvent, PlacementWithdrawnEvent}
|
||||
|
||||
// PlacementKey is the key, in PlacementBucket, of one node's placement: `placement.<node>`.
|
||||
func PlacementKey(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
@@ -51,20 +59,6 @@ func ParsePlacementKey(key string) (node string, err error) {
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// PlacementFile is the path, in the catalogue repository, of one node's placement file.
|
||||
func PlacementFile(node string) string { return path.Join(PlacementDir, node+".json") }
|
||||
|
||||
// PlacementFileNode is the node a catalogue path is the placement file of, and whether it is one: `placement/<node>.json`
|
||||
// directly under PlacementDir.
|
||||
func PlacementFileNode(p string) (string, bool) {
|
||||
dir, file := path.Split(p)
|
||||
node, ok := strings.CutSuffix(file, ".json")
|
||||
if path.Clean(dir) != PlacementDir || !ok || checkName("node", node) != nil {
|
||||
return "", false
|
||||
}
|
||||
return node, true
|
||||
}
|
||||
|
||||
// Provider is the module on a node that answers a requirement or holds a seat: a pin's or a binding's provider.
|
||||
type Provider struct {
|
||||
Node string `json:"node"`
|
||||
@@ -109,8 +103,8 @@ type Holding struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// Placement is one node's placement: the file the catalogue keeps and, with Revision, the value of its key. Modules are
|
||||
// in the order the file names them, which is the order the composition reads the node's modules in. Pins is, by
|
||||
// Placement is one node's placement, the value of its key. Modules are in the order the composition reads the node's
|
||||
// modules in. Revision is unused since placement left the catalogue (ADR 0567): the key's own revision is the bus's. Pins is, by
|
||||
// provision, the provider chosen for the node where more than one could answer.
|
||||
type Placement struct {
|
||||
V int `json:"v"`
|
||||
@@ -122,13 +116,13 @@ type Placement struct {
|
||||
Holds []Holding `json:"holds,omitempty"`
|
||||
}
|
||||
|
||||
// NodeDecided is what a person decides of the node, kept in its placement file (issue 1126's K2: what a person decides
|
||||
// lives in the catalogue, what the machine observes lives in its record, NodeRecord): where it is dialled (endpoint) and
|
||||
// NodeDecided is what a person decides of the node, kept in its placement key (issue 1126's K2: what a person decides
|
||||
// lives in placement, what the machine observes lives in its record, NodeRecord): where it is dialled (endpoint) and
|
||||
// its site, whether it is the private network's hub, its address on the private network, the domain its routed names
|
||||
// are composed under, its operator and agent logins by name and their homes, whether it is adopted rather than
|
||||
// converged, the modules taken on an adopted node, the tunnel found on it at adoption and, on the hub, the names given
|
||||
// to that tunnel's peers. The address is a decision written once, at enrolment, by a
|
||||
// catalogue pull request: nothing allocates it at run time, and CheckPlacements refuses two nodes with one address.
|
||||
// to that tunnel's peers. The address is a decision written once, at enrolment, by an
|
||||
// approved proposal (ADR 0567 §2): nothing allocates it at run time, and CheckPlacements refuses two nodes with one address.
|
||||
// No secret and no key material: accounts are names, never credentials.
|
||||
type NodeDecided struct {
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
@@ -211,8 +205,8 @@ func DecodePlacement(data []byte) (Placement, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SamePlacement says whether two placements are the same value, so the indexer writes only when they differ. The
|
||||
// revision is not compared: a merge that changes no placement writes nothing (ADR 0549 §2).
|
||||
// SamePlacement says whether two placements are the same value, so the holder writes only when they differ. The
|
||||
// revision is not compared.
|
||||
func SamePlacement(a, b Placement) bool {
|
||||
a.Revision, b.Revision = "", ""
|
||||
ja, errA := EncodePlacement(a)
|
||||
@@ -220,21 +214,23 @@ func SamePlacement(a, b Placement) bool {
|
||||
return errA == nil && errB == nil && string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// PlacementChange is what `placement-changed` says: the node, the catalogue revision, and its modules added, removed
|
||||
// or changed against the placement before.
|
||||
// PlacementChange is what `placement-changed` says of one node (ADR 0567 §6): the node, the revision of its key before
|
||||
// (0: it had none) and after (0: the key was deleted), and its modules added, removed or changed against the placement
|
||||
// before.
|
||||
type PlacementChange struct {
|
||||
Node string `json:"node"`
|
||||
Revision string `json:"revision"`
|
||||
Added []string `json:"added,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Before uint64 `json:"before"`
|
||||
After uint64 `json:"after"`
|
||||
Added []string `json:"added,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
}
|
||||
|
||||
// ChangeOf is what changed from before to after on one node; before is the zero Placement for a node new to placement.
|
||||
// A change of the node's pins or holdings counts as a change of every module placed on it both times, since the
|
||||
// composition of each may read them.
|
||||
func ChangeOf(before, after Placement) PlacementChange {
|
||||
c := PlacementChange{Node: after.Node, Revision: after.Revision}
|
||||
c := PlacementChange{Node: after.Node}
|
||||
if c.Node == "" {
|
||||
c.Node = before.Node
|
||||
}
|
||||
@@ -368,8 +364,8 @@ func (p Placement) Held() []catalogue.Held {
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the
|
||||
// modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a
|
||||
// CheckPlacements is the holder's check of a proposal laid over every placement (ADR 0567 §2), over the placements by
|
||||
// node, the modules the indexer holds and the nodes with a record. It returns every refusal, in node order, never only the first: a
|
||||
// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host
|
||||
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which
|
||||
// is held once per node;
|
||||
@@ -401,7 +397,7 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
|
||||
for _, node := range nodes {
|
||||
p := placements[node]
|
||||
if p.Node != node {
|
||||
refusals = append(refusals, fmt.Sprintf("the placement file of %s names the node %q", node, p.Node))
|
||||
refusals = append(refusals, fmt.Sprintf("the placement of %s names the node %q", node, p.Node))
|
||||
}
|
||||
if a := strings.TrimSpace(p.Decided.Address); a != "" {
|
||||
if other, twice := addresses[a]; twice {
|
||||
@@ -413,12 +409,12 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
|
||||
hubs = append(hubs, node)
|
||||
}
|
||||
if !enrolled(node) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s is not an enrolled node", node))
|
||||
refusals = append(refusals, fmt.Sprintf("%s is a node with no record", node))
|
||||
}
|
||||
hostPorts := map[int]string{}
|
||||
for _, m := range p.Modules {
|
||||
if !held(m.Module) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: the catalogue holds no module %s", node, m.Module))
|
||||
refusals = append(refusals, fmt.Sprintf("%s: the indexer holds no module %s", node, m.Module))
|
||||
}
|
||||
for _, port := range sortedKeys(m.Ports) {
|
||||
at := m.Ports[port]
|
||||
|
||||
@@ -6,70 +6,62 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees
|
||||
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull
|
||||
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the
|
||||
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is
|
||||
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`.
|
||||
// Placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): for each node a proposal must hear from, the seat's holder
|
||||
// writes one key `<proposal>.<node>` in its state `candidates`: the node's placement with the proposal laid over it for a
|
||||
// node whose key it changes, or the node's current key for one whose composition it changes only through another node
|
||||
// (Reached: a binding, a firewall or a resolver entry reaching it). It deletes them when the proposal ends. A state of its
|
||||
// own, since the readers of `placement` refuse any key that is not `placement.<node>`.
|
||||
//
|
||||
// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and
|
||||
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node`
|
||||
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed
|
||||
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's
|
||||
// write is the node's event, the `<node>.node` change the check's.
|
||||
// The node watches `*.<node>`, composes in shadow with every candidate of its proposal laid over the current placement
|
||||
// (its own sealed values, the boxes it already runs) and compares the result with what it runs; it applies nothing. Its
|
||||
// answer rides in its own have key, `<node>.node` (HaveNode.Candidates, by proposal), which that node alone writes; the
|
||||
// holder reads it and judges (ADR 0567 §4). Event-driven both ways: the candidate key's write is the node's event, the
|
||||
// `<node>.node` change the holder's.
|
||||
const (
|
||||
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket.
|
||||
PlacementCandidatesState = "placement-candidates"
|
||||
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState
|
||||
// PlacementCandidatesState is the seat's state of placement candidates, PlacementCandidatesBucket its bucket and
|
||||
// PlacementCandidatesRead the name a reader's manifest declares.
|
||||
PlacementCandidatesState = "candidates"
|
||||
PlacementCandidatesBucket = AssignerModule + "_" + PlacementCandidatesState
|
||||
PlacementCandidatesRead = AssignerModule + "." + PlacementCandidatesState
|
||||
// PlacementCandidatesHistory is the revisions the bucket keeps per key.
|
||||
PlacementCandidatesHistory = 5
|
||||
)
|
||||
|
||||
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group:
|
||||
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are
|
||||
// `<group>.*`.
|
||||
func PlacementCandidateKey(group, node string) (string, error) {
|
||||
g, err := groupKey(group)
|
||||
if err != nil {
|
||||
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate in one proposal:
|
||||
// `<proposal>.<node>`, so a node watches `*.<node>` and a proposal's keys are `<proposal>.*`.
|
||||
func PlacementCandidateKey(proposal, node string) (string, error) {
|
||||
if err := CheckProposalID(proposal); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return g + "." + node, nil
|
||||
return proposal + "." + node, nil
|
||||
}
|
||||
|
||||
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
|
||||
func ParsePlacementCandidateKey(key string) (group, node string, err error) {
|
||||
g, node, ok := strings.Cut(key, ".")
|
||||
if !ok || checkName("node", node) != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key)
|
||||
func ParsePlacementCandidateKey(key string) (proposal, node string, err error) {
|
||||
proposal, node, ok := strings.Cut(key, ".")
|
||||
if !ok || checkName("node", node) != nil || CheckProposalID(proposal) != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <proposal>.<node>", key)
|
||||
}
|
||||
if group, err = parseGroupKey(g); err != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
|
||||
}
|
||||
return group, node, nil
|
||||
return proposal, node, nil
|
||||
}
|
||||
|
||||
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement
|
||||
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file.
|
||||
// PlacementCandidate is the value of a candidate key: the proposal it is of, and the node's placement with the proposal
|
||||
// laid over it; nil Placement says the proposal takes the node's key away. Reached says the proposal changes not this
|
||||
// node's key but its composition, through another node's: Placement is then its current key (nil when it has none).
|
||||
type PlacementCandidate struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Pull Head `json:"pull"`
|
||||
Proposal string `json:"proposal"`
|
||||
Reached bool `json:"reached,omitempty"`
|
||||
Placement *Placement `json:"placement,omitempty"`
|
||||
}
|
||||
|
||||
func (c PlacementCandidate) check() error {
|
||||
if c.Group == "" {
|
||||
return fmt.Errorf("delivery: a placement candidate names no group")
|
||||
}
|
||||
if err := checkRepository(c.Pull.Repository); err != nil {
|
||||
if err := CheckProposalID(c.Proposal); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Pull.Number <= 0 || c.Pull.Head == "" {
|
||||
return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head")
|
||||
}
|
||||
if c.Placement != nil {
|
||||
return c.Placement.check()
|
||||
}
|
||||
@@ -90,8 +82,8 @@ func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or
|
||||
// head, and a placement DecodePlacement refuses.
|
||||
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, a proposal id that is not one, and a
|
||||
// placement DecodePlacement refuses.
|
||||
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
|
||||
var c PlacementCandidate
|
||||
if err := decode(data, &c); err != nil {
|
||||
@@ -115,14 +107,13 @@ const (
|
||||
CandidateRefused = "refused"
|
||||
)
|
||||
|
||||
// CandidateAnswer is a node's answer to its group's candidate keys, in HaveNode.Candidates (ADR 0559 §4): the head
|
||||
// and the revision of the node's own key in the group it answered (0 when the group has no key of its own for it: its
|
||||
// file is unchanged and it answers as placed at the head or on the trunk, judged by Head alone), its verdict, the modules composed the same, those that differ with both digests, those
|
||||
// CandidateAnswer is a node's answer to its proposal's candidate keys, in HaveNode.Candidates (ADR 0567 §3): the
|
||||
// proposal and the revision of the node's own candidate key it answered, its verdict, the modules composed the same, those that differ with both digests, those
|
||||
// the candidate adds (naming the values not yet issued for each, which is no refusal: a module's credential and boxes
|
||||
// are made only once it is placed, ADR 0549 §5) or removes, and each refusal. The node's own composition is named
|
||||
// "node" (HaveNodeWord).
|
||||
type CandidateAnswer struct {
|
||||
Head string `json:"head"`
|
||||
Proposal string `json:"proposal"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Verdict string `json:"verdict"`
|
||||
Same []string `json:"same,omitempty"`
|
||||
@@ -132,9 +123,8 @@ type CandidateAnswer struct {
|
||||
Refusals []ModuleRefused `json:"refusals,omitempty"`
|
||||
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
// Returns is, for a head that deletes the node's placement file, what the node does then: it returns to the
|
||||
// controller's compositions (ReturnsToController), as a node the catalogue does not place composes none of its own
|
||||
// (issue 1126: the seed's revert). Same, Differs, Added and Removed then compare the controller's compositions at
|
||||
// Returns is, for a proposal that takes the node's placement key away, what the node does then: it returns to the
|
||||
// controller's compositions (ReturnsToController), as a node with no placement key composes none of its own. Same, Differs, Added and Removed then compare the controller's compositions at
|
||||
// the node's wants with what it runs, and the verdict is theirs; a want the controller holds no composition for is
|
||||
// refused (Refused, without Returns).
|
||||
Returns string `json:"returns,omitempty"`
|
||||
@@ -147,8 +137,8 @@ type CandidateAnswer struct {
|
||||
ValuesDiffer int `json:"values_differ,omitempty"`
|
||||
}
|
||||
|
||||
// ReturnsToController is what a node answers a head that deletes its placement file.
|
||||
const ReturnsToController = "the head deletes this node's placement: it returns to the controller's compositions"
|
||||
// ReturnsToController is what a node answers a proposal that takes its placement key away.
|
||||
const ReturnsToController = "the proposal takes this node's placement away: it returns to the controller's compositions"
|
||||
|
||||
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
|
||||
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
|
||||
@@ -179,7 +169,7 @@ type ResourceBodies struct {
|
||||
|
||||
// The bounds of a module's shown bodies, and of one answer as the node's own key holds it: an answer over
|
||||
// MaxAnswerBytes is fitted (FitAnswer). The node's key (HaveBucket) takes a value of about 1 MiB
|
||||
// (buscompose.ChainValueBytes) and holds every group's answer beside the node's facts, so one answer keeps to a sixth.
|
||||
// (buscompose.ChainValueBytes) and holds every proposal's answer beside the node's facts, so one answer keeps to a sixth.
|
||||
const (
|
||||
MaxBodies = 20
|
||||
MaxBody = 8192
|
||||
@@ -215,8 +205,8 @@ func VerdictOf(a CandidateAnswer) string {
|
||||
}
|
||||
|
||||
func (a CandidateAnswer) check() error {
|
||||
if a.Head == "" {
|
||||
return fmt.Errorf("delivery: a candidate answer names no head")
|
||||
if CheckProposalID(a.Proposal) != nil || a.Revision == 0 {
|
||||
return fmt.Errorf("delivery: a candidate answer names no proposal or no revision of its candidate")
|
||||
}
|
||||
switch a.Verdict {
|
||||
case CandidateSame, CandidateDiffers, CandidateRefused:
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A placement changes only by a proposal (novox/hq ADR 0567 §2): `mesh-placement.propose` takes why and a list of
|
||||
// changes; the holder lays them over the current keys, names the revision of each key it laid them over, and either
|
||||
// refuses at once with every finding or writes the proposal under its id in its state `proposals` and a candidate for
|
||||
// each node it must hear from (PlacementCandidate). The proposal's key leaves the state when it is applied, withdrawn,
|
||||
// declined or expired; its whole content stays in the event that ends it (PlacementEvent), so no proposal is kept in
|
||||
// the state once it has ended.
|
||||
const (
|
||||
// PlacementProposalsState is the seat's state of open proposals, PlacementProposalsBucket its bucket and
|
||||
// PlacementProposalsRead the name a reader's manifest declares; a key is the proposal's id.
|
||||
PlacementProposalsState = "proposals"
|
||||
PlacementProposalsBucket = AssignerModule + "_" + PlacementProposalsState
|
||||
PlacementProposalsRead = AssignerModule + "." + PlacementProposalsState
|
||||
// PlacementProposalsHistory is the revisions the bucket keeps per key.
|
||||
PlacementProposalsHistory = 5
|
||||
|
||||
// PlacementPulsesState is the holder's state of bounds (§4): a key per node a proposal waits on, written with a time
|
||||
// to live of the answer bound, and a key per raised ask, with its ask's; the server's delete marker when one ends is
|
||||
// the event that fails a silent node or ends an unanswered ask, so the holder keeps no timer. Its bucket allows a
|
||||
// time to live per key (catalogue.StateDeclaration.KeyTTL).
|
||||
PlacementPulsesState = "pulses"
|
||||
PlacementPulsesBucket = AssignerModule + "_" + PlacementPulsesState
|
||||
)
|
||||
|
||||
// PulseAnswerKey is the key of the bound on one node's answer to a proposal: `answer.<proposal>.<node>`.
|
||||
func PulseAnswerKey(proposal, node string) (string, error) {
|
||||
if err := CheckProposalID(proposal); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "answer." + proposal + "." + node, nil
|
||||
}
|
||||
|
||||
// PulseAskKey is the key of the bound on a proposal's raised ask: `ask.<proposal>`.
|
||||
func PulseAskKey(proposal string) (string, error) {
|
||||
if err := CheckProposalID(proposal); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "ask." + proposal, nil
|
||||
}
|
||||
|
||||
// The operations a proposal's change names (ADR 0567 §2).
|
||||
const (
|
||||
OpPlace = "place" // place a module on a node, with its host ports and bindings
|
||||
OpTakeOff = "take-off" // take a module off a node
|
||||
OpPin = "pin" // pin the provider of a provision on a node
|
||||
OpUnpin = "unpin" // remove a pin
|
||||
OpHold = "hold" // a module placed on the node holds a seat
|
||||
OpUnhold = "unhold" // it holds it no more
|
||||
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
|
||||
OpConfig = "config" // set a module's config placement or values on the node
|
||||
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
|
||||
)
|
||||
|
||||
// The states of an open proposal.
|
||||
const (
|
||||
// ProposalAnswering: its candidates are written and the nodes it must hear from have not all answered.
|
||||
ProposalAnswering = "answering"
|
||||
// ProposalFailed: its verdict failed; it stays open, for its proposer to read and withdraw, or for a node's later
|
||||
// answer to judge it again.
|
||||
ProposalFailed = "failed"
|
||||
// ProposalAsked: its verdict passed and the operator is asked to approve it.
|
||||
ProposalAsked = "asked"
|
||||
)
|
||||
|
||||
// VerdictWaits is a proposal's verdict while a node it must hear from has not answered within the bound; it passes
|
||||
// (VerdictPass) or fails (VerdictFail) as a check's verdict does (ADR 0567 §4).
|
||||
const VerdictWaits = "waits"
|
||||
|
||||
// PlacementProposalChange is one change of a proposal. Node is always named; the rest is the operation's.
|
||||
type PlacementProposalChange struct {
|
||||
Op string `json:"op"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Ports map[string]int `json:"ports,omitempty"`
|
||||
Bindings map[string]Provider `json:"bindings,omitempty"`
|
||||
Provision string `json:"provision,omitempty"`
|
||||
Provider *Provider `json:"provider,omitempty"`
|
||||
// Claim, Scope, Kind and Site are a holding's (OpHold, OpUnhold).
|
||||
Claim string `json:"claim,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Site string `json:"site,omitempty"`
|
||||
// Field and Value are a decided field's (OpDecide): its JSON name in NodeDecided and its value.
|
||||
Field string `json:"field,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
// MeshWide, Layer, MeshValues and Values are a module's config placement and values (OpConfig), as PlacedModule
|
||||
// carries them; each given replaces what the module has.
|
||||
MeshWide map[string]any `json:"mesh-wide,omitempty"`
|
||||
Layer map[string]any `json:"layer,omitempty"`
|
||||
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
// Whole is the node's whole placement (OpSeed).
|
||||
Whole *Placement `json:"whole,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeLease is why the change cannot be proposed before the lease write, or "" (ADR 0567 §8): until then the holder
|
||||
// keeps the controller's assignments in step through its verbs assign, unassign, pin and unpin, so only those are
|
||||
// proposable — a module placed or taken off with no host port or binding, a pin set or removed, a module's config values
|
||||
// and the seed. A host port, a binding, a holding, a decided field and a config layer's places or accesses wait for the
|
||||
// lease write, which the refusal names.
|
||||
func (c PlacementProposalChange) BeforeLease() string {
|
||||
const lease = "which the controller's verbs cannot take from a caller, so it waits for the lease write (ADR 0567 §8)"
|
||||
switch c.Op {
|
||||
case OpPlace:
|
||||
if len(c.Ports)+len(c.Bindings) > 0 {
|
||||
return fmt.Sprintf("the place of %s on %s names host ports or bindings, %s", c.Module, c.Node, lease)
|
||||
}
|
||||
case OpTakeOff, OpPin, OpUnpin, OpSeed:
|
||||
case OpConfig:
|
||||
// A module's config values are the state's alone (§13); its layers' places and accesses are the controller's
|
||||
// until the lease write.
|
||||
if c.MeshWide != nil || c.Layer != nil {
|
||||
return fmt.Sprintf("the config of %s on %s sets a layer's places or accesses, %s", c.Module, c.Node, lease)
|
||||
}
|
||||
default:
|
||||
return fmt.Sprintf("a change %s of %s is no assignment or pin, %s", c.Op, c.Node, lease)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c PlacementProposalChange) check() error {
|
||||
if err := checkName("node", c.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
needs := func(what string, ok bool) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("delivery: a %s change of %s names no %s", c.Op, c.Node, what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch c.Op {
|
||||
case OpPlace, OpTakeOff, OpConfig:
|
||||
return needs("module", checkName("module", c.Module) == nil)
|
||||
case OpPin:
|
||||
return needs("provision or provider", c.Provision != "" && c.Provider != nil && c.Provider.Node != "" && c.Provider.Module != "")
|
||||
case OpUnpin:
|
||||
return needs("provision", c.Provision != "")
|
||||
case OpHold, OpUnhold:
|
||||
return needs("module or claim", checkName("module", c.Module) == nil && c.Claim != "")
|
||||
case OpDecide:
|
||||
return needs("field or value", c.Field != "" && len(c.Value) > 0)
|
||||
case OpSeed:
|
||||
if c.Whole == nil || c.Whole.Node != c.Node {
|
||||
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
|
||||
}
|
||||
return c.Whole.check()
|
||||
}
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
|
||||
}
|
||||
|
||||
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
||||
// current is every node's placement now (absent: the node has no key). It returns, by node, the laid placement, and
|
||||
// every finding: a change that does not fit its node (a module taken off that is not placed, configured or holding
|
||||
// there; a field NodeDecided does not have) or a placement DecodePlacement refuses. What CheckPlacements refuses
|
||||
// across nodes is the caller's to ask of the result laid over current.
|
||||
func LayChanges(current map[string]Placement, changes []PlacementProposalChange) (map[string]Placement, []string) {
|
||||
laid := map[string]Placement{}
|
||||
var findings []string
|
||||
of := func(node string) Placement {
|
||||
if p, ok := laid[node]; ok {
|
||||
return p
|
||||
}
|
||||
p, ok := current[node]
|
||||
if !ok {
|
||||
return Placement{V: Version, Node: node, Modules: []PlacedModule{}}
|
||||
}
|
||||
return clonePlacement(p)
|
||||
}
|
||||
for i, c := range changes {
|
||||
if err := c.check(); err != nil {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
p := of(c.Node)
|
||||
at := -1
|
||||
for j, m := range p.Modules {
|
||||
if m.Module == c.Module {
|
||||
at = j
|
||||
}
|
||||
}
|
||||
notPlaced := func() {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is not placed on %s", i+1, c.Module, c.Node))
|
||||
}
|
||||
switch c.Op {
|
||||
case OpSeed:
|
||||
p = clonePlacement(*c.Whole)
|
||||
case OpPlace:
|
||||
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
||||
if at >= 0 {
|
||||
m.MeshWide, m.HasMesh, m.Layer, m.HasLayer = p.Modules[at].MeshWide, p.Modules[at].HasMesh, p.Modules[at].Layer, p.Modules[at].HasLayer
|
||||
m.MeshValues, m.Values = p.Modules[at].MeshValues, p.Modules[at].Values
|
||||
p.Modules[at] = m
|
||||
} else {
|
||||
p.Modules = append(p.Modules, m)
|
||||
}
|
||||
case OpTakeOff:
|
||||
if at < 0 {
|
||||
notPlaced()
|
||||
continue
|
||||
}
|
||||
p.Modules = append(p.Modules[:at:at], p.Modules[at+1:]...)
|
||||
var holds []Holding
|
||||
for _, h := range p.Holds {
|
||||
if h.Module != c.Module {
|
||||
holds = append(holds, h)
|
||||
}
|
||||
}
|
||||
p.Holds = holds
|
||||
case OpConfig:
|
||||
if at < 0 {
|
||||
notPlaced()
|
||||
continue
|
||||
}
|
||||
m := &p.Modules[at]
|
||||
if c.MeshWide != nil {
|
||||
m.MeshWide, m.HasMesh = c.MeshWide, true
|
||||
}
|
||||
if c.Layer != nil {
|
||||
m.Layer, m.HasLayer = c.Layer, true
|
||||
}
|
||||
if c.MeshValues != nil {
|
||||
m.MeshValues = c.MeshValues
|
||||
}
|
||||
if c.Values != nil {
|
||||
m.Values = c.Values
|
||||
}
|
||||
case OpPin:
|
||||
if p.Pins == nil {
|
||||
p.Pins = map[string]Provider{}
|
||||
}
|
||||
p.Pins[c.Provision] = *c.Provider
|
||||
case OpUnpin:
|
||||
delete(p.Pins, c.Provision)
|
||||
if len(p.Pins) == 0 {
|
||||
p.Pins = nil
|
||||
}
|
||||
case OpHold:
|
||||
if at < 0 {
|
||||
notPlaced()
|
||||
continue
|
||||
}
|
||||
p.Holds = append(p.Holds, Holding{Claim: c.Claim, Scope: c.Scope, Module: c.Module, Site: c.Site, Kind: c.Kind})
|
||||
case OpUnhold:
|
||||
kept := p.Holds[:0:0]
|
||||
for _, h := range p.Holds {
|
||||
if h.Module != c.Module || h.Claim != c.Claim {
|
||||
kept = append(kept, h)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(p.Holds) {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s holds no %s on %s", i+1, c.Module, c.Claim, c.Node))
|
||||
continue
|
||||
}
|
||||
p.Holds = kept
|
||||
case OpDecide:
|
||||
d, err := decide(p.Decided, c.Field, c.Value)
|
||||
if err != nil {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %v", i+1, err))
|
||||
continue
|
||||
}
|
||||
p.Decided = d
|
||||
}
|
||||
laid[c.Node] = p
|
||||
}
|
||||
for _, node := range sortedKeys(laid) {
|
||||
if err := laid[node].check(); err != nil {
|
||||
findings = append(findings, err.Error())
|
||||
}
|
||||
}
|
||||
return laid, findings
|
||||
}
|
||||
|
||||
// decide is d with the field named by its JSON name set to value, refused when NodeDecided has no such field or the
|
||||
// value does not fit it.
|
||||
func decide(d NodeDecided, field string, value json.RawMessage) (NodeDecided, error) {
|
||||
raw, _ := json.Marshal(d)
|
||||
var fields map[string]json.RawMessage
|
||||
_ = json.Unmarshal(raw, &fields)
|
||||
if fields == nil {
|
||||
fields = map[string]json.RawMessage{}
|
||||
}
|
||||
if !decidedFields[field] {
|
||||
return d, fmt.Errorf("a node has no decided field %q", field)
|
||||
}
|
||||
fields[field] = value
|
||||
raw, _ = json.Marshal(fields)
|
||||
var out NodeDecided
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&out); err != nil {
|
||||
return d, fmt.Errorf("the decided field %q does not take %s: %v", field, value, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decidedFields are NodeDecided's fields by their JSON names.
|
||||
var decidedFields = func() map[string]bool {
|
||||
raw, _ := json.Marshal(NodeDecided{Endpoint: "x", Site: "x", Hub: true, Address: "x", PublicDomain: "x", Account: "x",
|
||||
AccountHome: "x", AgentAccount: "x", AgentHome: "x", Adopted: true, Taken: []string{"x"}, Tunnel: &RecordTunnel{},
|
||||
PeerNames: map[string]string{"x": "x"}})
|
||||
var m map[string]any
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
out := map[string]bool{}
|
||||
for k := range m {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
func clonePlacement(p Placement) Placement {
|
||||
raw, _ := json.Marshal(p)
|
||||
var out Placement
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
if out.Modules == nil {
|
||||
out.Modules = []PlacedModule{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var proposalIDOK = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,62}$`)
|
||||
|
||||
// CheckProposalID refuses an id that is no proposal's: lower-case letters, digits and dashes, 3 to 63 of them, so it is
|
||||
// one key token.
|
||||
func CheckProposalID(id string) error {
|
||||
if !proposalIDOK.MatchString(id) {
|
||||
return fmt.Errorf("delivery: %q is no proposal id (lower-case letters, digits and dashes, one key token)", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewProposalID is a fresh proposal id: its time in UTC and six random hex digits, `p-20261028t183000-1a2b3c`.
|
||||
func NewProposalID(at time.Time) string {
|
||||
var b [3]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "p-" + strings.ToLower(at.UTC().Format("20060102T150405")) + "-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// PlacementVerdict is the holder's judgement of a proposal's answers (ADR 0567 §4). It fails only on a refusal by a
|
||||
// node that must answer or on its silence past the bound (and, for the seed alone, on any difference); every difference
|
||||
// is shown by node: Intended is what the holder computed the proposal changes, Unintended what a node answered beside
|
||||
// it, marked as such in the brief.
|
||||
type PlacementVerdict struct {
|
||||
State string `json:"state"`
|
||||
At time.Time `json:"at"`
|
||||
Fails []string `json:"fails,omitempty"`
|
||||
Silent []string `json:"silent,omitempty"`
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
Intended map[string][]string `json:"intended,omitempty"`
|
||||
Unintended map[string][]string `json:"unintended,omitempty"`
|
||||
Answers map[string]CandidateAnswer `json:"answers,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// PlacementProposal is an open proposal, the value of its key in PlacementProposalsBucket.
|
||||
type PlacementProposal struct {
|
||||
V int `json:"v"`
|
||||
ID string `json:"id"`
|
||||
By string `json:"by"`
|
||||
Why string `json:"why"`
|
||||
// At is when it was proposed, Written when its newest candidate was written (the answers' bound runs from it).
|
||||
At time.Time `json:"at"`
|
||||
Written time.Time `json:"written,omitempty"`
|
||||
// Changes are as proposed; Seed says they are the take-over (adopt, §7), ExportDigest the digest of the export it
|
||||
// proposed (ExportDigest), checked again at the warrant.
|
||||
Changes []PlacementProposalChange `json:"changes"`
|
||||
Seed bool `json:"seed,omitempty"`
|
||||
ExportDigest string `json:"export-digest,omitempty"`
|
||||
// Base is, by node whose key it changes, the revision of the key it was laid over (0: the node had none).
|
||||
Base map[string]uint64 `json:"base"`
|
||||
// Must is every node it must hear from (§3): each whose key it changes and each whose composition it changes
|
||||
// through another's, sorted; set by the holder once it composed, before then the nodes of Base.
|
||||
Must []string `json:"must,omitempty"`
|
||||
// HandBack says it is the operator's hand-back (§7): every key of Base deleted on its warrant, with no change and no
|
||||
// node's answer.
|
||||
HandBack bool `json:"hand-back,omitempty"`
|
||||
// State is ProposalAnswering, ProposalFailed or ProposalAsked; Verdict is the latest judgement, Ask the authorising
|
||||
// ask raised on a passing one.
|
||||
State string `json:"state"`
|
||||
Verdict *PlacementVerdict `json:"verdict,omitempty"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
// Words is what happened last, in words (laid again over moved keys, asked again).
|
||||
Words string `json:"words,omitempty"`
|
||||
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
|
||||
// value.
|
||||
Again time.Time `json:"again,omitempty"`
|
||||
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
|
||||
// placement-withdrawn, which only the holder's process can say on its stream.
|
||||
Withdrawn string `json:"withdrawn,omitempty"`
|
||||
}
|
||||
|
||||
// Nodes is every node whose key the proposal changes, sorted.
|
||||
func (p PlacementProposal) Nodes() []string { return sortedKeys(p.Base) }
|
||||
|
||||
func (p PlacementProposal) check() error {
|
||||
if err := CheckProposalID(p.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" {
|
||||
return fmt.Errorf("delivery: the proposal %s names no proposer or no why", p.ID)
|
||||
}
|
||||
if len(p.Base) == 0 || (len(p.Changes) == 0) != p.HandBack || (p.HandBack && p.Seed) {
|
||||
return fmt.Errorf("delivery: the proposal %s changes nothing, or is a hand-back with changes", p.ID)
|
||||
}
|
||||
for i, c := range p.Changes {
|
||||
if err := c.check(); err != nil {
|
||||
return fmt.Errorf("delivery: the proposal %s, change %d: %w", p.ID, i+1, err)
|
||||
}
|
||||
if (c.Op == OpSeed) != p.Seed {
|
||||
return fmt.Errorf("delivery: the proposal %s mixes the seed with other changes", p.ID)
|
||||
}
|
||||
if _, ok := p.Base[c.Node]; !ok {
|
||||
return fmt.Errorf("delivery: the proposal %s changes %s and names no base revision of its key", p.ID, c.Node)
|
||||
}
|
||||
}
|
||||
switch p.State {
|
||||
case ProposalAnswering, ProposalFailed:
|
||||
case ProposalAsked:
|
||||
if p.Ask == "" || p.Verdict == nil || p.Verdict.State != VerdictPass {
|
||||
return fmt.Errorf("delivery: the proposal %s is asked with no ask or no passing verdict", p.ID)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the proposal %s is in no state %q", p.ID, p.State)
|
||||
}
|
||||
if p.Seed && p.ExportDigest == "" {
|
||||
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
|
||||
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
|
||||
if err := p.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.V = Version
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// DecodePlacementProposal reads a proposal; it refuses an unknown version, an id that is none, no proposer or why, no
|
||||
// change, a change it does not take or of a node with no base revision, the seed mixed with other changes or without its
|
||||
// export digest, and a state it does not know.
|
||||
func DecodePlacementProposal(data []byte) (PlacementProposal, error) {
|
||||
var p PlacementProposal
|
||||
if err := decode(data, &p); err != nil {
|
||||
return PlacementProposal{}, err
|
||||
}
|
||||
if err := p.check(); err != nil {
|
||||
return PlacementProposal{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ExportDigest is the digest of an export's answer, every node's placement, as the seed proposes it: the same
|
||||
// placements give the same digest, whatever order they came in.
|
||||
func ExportDigest(placements map[string]Placement) string {
|
||||
h := sha256.New()
|
||||
for _, node := range sortedKeys(placements) {
|
||||
raw, _ := EncodePlacement(placements[node])
|
||||
fmt.Fprintf(h, "%s\n%s\n", node, raw)
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ChangesDigest is the digest of a proposal's changes, which its authorising ask is bound to (§5).
|
||||
func ChangesDigest(p PlacementProposal) string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
ID string `json:"id"`
|
||||
Changes []PlacementProposalChange `json:"changes"`
|
||||
Base map[string]uint64 `json:"base"`
|
||||
}{p.ID, p.Changes, p.Base})
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// PlacementWarrant is who approved a proposal, through which channel and when (§5, §6): the warrant's id as the teller
|
||||
// wrote it (the ask's id and when), and its words, which name the person and the channel that proved them
|
||||
// (asks.Warrant.Says).
|
||||
type PlacementWarrant struct {
|
||||
Warrant string `json:"warrant"`
|
||||
Words string `json:"words"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// PlacementEvent is what each of the seat's events carries (§6): the proposal whole (its id, by, why, changes, base
|
||||
// revisions and verdict with every node's answer); for placement-changed, by node the revisions before and after and
|
||||
// the modules added, removed or changed, and the warrant; for placement-withdrawn, why it ended.
|
||||
type PlacementEvent struct {
|
||||
Proposal PlacementProposal `json:"proposal"`
|
||||
Nodes []PlacementChange `json:"nodes,omitempty"`
|
||||
Warrant *PlacementWarrant `json:"warrant,omitempty"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
}
|
||||
|
||||
// SortChanges orders a placement-changed event's nodes by node.
|
||||
func SortChanges(c []PlacementChange) {
|
||||
sort.Slice(c, func(i, j int) bool { return c[i].Node < c[j].Node })
|
||||
}
|
||||
+156
-46
@@ -9,6 +9,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -32,15 +33,18 @@ func TestPlacement(t *testing.T) {
|
||||
if _, err := PlacementKey("a.b"); err == nil {
|
||||
t.Error("PlacementKey took a dotted node")
|
||||
}
|
||||
if PlacementBucket != "mesh-delivery-indexer_placement" || PlacementFile("ace") != "placement/ace.json" {
|
||||
t.Fatalf("bucket %q, file %q", PlacementBucket, PlacementFile("ace"))
|
||||
}
|
||||
for p, want := range map[string]string{"placement/ace.json": "ace", "./placement/ace.json": "ace",
|
||||
"placement/x/ace.json": "", "modules/ace.json": "", "placement/ace.yaml": ""} {
|
||||
if n, ok := PlacementFileNode(p); n != want || ok != (want != "") {
|
||||
t.Errorf("PlacementFileNode(%q) = %q, %v", p, n, ok)
|
||||
// ADR 0567 §1: the seat's three states are named after the holding module, and every reader takes the names here.
|
||||
for got, want := range map[string]string{PlacementBucket: "mesh-delivery-assigner_placement",
|
||||
PlacementProposalsBucket: "mesh-delivery-assigner_proposals", PlacementCandidatesBucket: "mesh-delivery-assigner_candidates",
|
||||
PlacementRead: "mesh-delivery-assigner.placement", PlacementCandidatesRead: "mesh-delivery-assigner.candidates",
|
||||
PlacementProposalsRead: "mesh-delivery-assigner.proposals"} {
|
||||
if got != want {
|
||||
t.Errorf("a bucket is named %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
if PlacementHistory != 64 || PlacementStreamDays != 30 {
|
||||
t.Errorf("history %d, stream %d days", PlacementHistory, PlacementStreamDays)
|
||||
}
|
||||
})
|
||||
|
||||
g14 := Placement{Node: "g14", Revision: "abc",
|
||||
@@ -129,10 +133,9 @@ func TestPlacement(t *testing.T) {
|
||||
|
||||
t.Run("what a change says", func(t *testing.T) {
|
||||
after := g14
|
||||
after.Revision = "def"
|
||||
after.Modules = []PlacedModule{{Module: "app", Ports: map[string]int{"8080": 18081}}, {Module: "new"}}
|
||||
got := ChangeOf(g14, after)
|
||||
want := PlacementChange{Node: "g14", Revision: "def", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
|
||||
want := PlacementChange{Node: "g14", Added: []string{"new"}, Removed: []string{"zsh"}, Changed: []string{"app"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ChangeOf = %+v, want %+v", got, want)
|
||||
}
|
||||
@@ -171,7 +174,7 @@ func TestPlacement(t *testing.T) {
|
||||
twice.Decided.Address = "10.77.0.2"
|
||||
r := CheckPlacements(map[string]Placement{"g14": clash, "novox": novox, "stranger": twice}, held, enrolled)
|
||||
for _, want := range []string{"holds no module ghost", "ghost and app are both published on host port 18080",
|
||||
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is not an enrolled node",
|
||||
"bound to ace/postgres", "pin of shell names g14/bash", "stranger is a node with no record",
|
||||
"mesh-store, held once for the mesh, is held on novox and stranger",
|
||||
"g14 and stranger both have the address 10.77.0.2", "novox, stranger are each placed as it"} {
|
||||
found := false
|
||||
@@ -184,58 +187,56 @@ func TestPlacement(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Issue 1126: a catalogue pull request's placement, one candidate key per node, and each node's answer in its have.
|
||||
// ADR 0567 §3: a proposal's candidates, one key `<proposal>.<node>` per node it must hear from, and each node's answer
|
||||
// in its have.
|
||||
t.Run("candidates", func(t *testing.T) {
|
||||
key, err := PlacementCandidateKey("feat/1.2", "g14")
|
||||
if err != nil || key != "feat/1=2e2.g14" {
|
||||
const id = "p-20261028t183000-1a2b3c"
|
||||
key, err := PlacementCandidateKey(id, "g14")
|
||||
if err != nil || key != id+".g14" {
|
||||
t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
|
||||
}
|
||||
if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" {
|
||||
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err)
|
||||
if p, n, err := ParsePlacementCandidateKey(key); err != nil || p != id || n != "g14" {
|
||||
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", p, n, err)
|
||||
}
|
||||
for _, bad := range []string{"g14", ".g14", "feat.g.14", "feat.", "fe=t.g14"} {
|
||||
for _, bad := range []string{"g14", ".g14", "p-x.g.14", "p-x.", "P-X.g14", "feat/1.g14"} {
|
||||
if _, _, err := ParsePlacementCandidateKey(bad); err == nil {
|
||||
t.Errorf("ParsePlacementCandidateKey(%q) took it", bad)
|
||||
}
|
||||
}
|
||||
if PlacementCandidatesBucket != "mesh-delivery-indexer_placement-candidates" {
|
||||
t.Errorf("bucket %q", PlacementCandidatesBucket)
|
||||
}
|
||||
pull := Head{Repository: CatalogueRepository, Number: 7, Head: "h1"}
|
||||
for name, c := range map[string]PlacementCandidate{
|
||||
"placed": {Group: "feat/x", Pull: pull, Placement: &g14},
|
||||
"removed": {Group: "feat/x", Pull: pull},
|
||||
"placed": {Proposal: id, Placement: &g14},
|
||||
"removed": {Proposal: id},
|
||||
"reached": {Proposal: id, Reached: true, Placement: &g14},
|
||||
} {
|
||||
raw, err := EncodePlacementCandidate(c)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
back, err := DecodePlacementCandidate(raw)
|
||||
if err != nil || back.Group != c.Group || back.Pull != pull || (back.Placement == nil) != (c.Placement == nil) ||
|
||||
if err != nil || back.Proposal != id || back.Reached != c.Reached || (back.Placement == nil) != (c.Placement == nil) ||
|
||||
(c.Placement != nil && !SamePlacement(*back.Placement, g14)) {
|
||||
t.Errorf("%s: round trip %+v, %v", name, back, err)
|
||||
}
|
||||
}
|
||||
for name, c := range map[string]PlacementCandidate{
|
||||
"no group": {Pull: pull},
|
||||
"no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}},
|
||||
"bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}},
|
||||
"no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}},
|
||||
"no proposal": {},
|
||||
"a group": {Proposal: "feat/x"},
|
||||
"bad place": {Proposal: id, Placement: &Placement{Node: "a.b"}},
|
||||
} {
|
||||
if _, err := EncodePlacementCandidate(c); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
answers := map[string]CandidateAnswer{
|
||||
"same": {Head: "h1", Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
|
||||
"differs": {Head: "h1", Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
|
||||
"refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
|
||||
"added": {Head: "h1", Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
|
||||
"a value": {Head: "h1", Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
|
||||
"same": {Proposal: id, Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
|
||||
"differs": {Proposal: id, Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
|
||||
"refused": {Proposal: id, Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
|
||||
"added": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
|
||||
"a value": {Proposal: id, Revision: 1, Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
|
||||
// A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126,
|
||||
// the seed's revert, mesh-catalog #526).
|
||||
"returns": {Head: "h1", Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}},
|
||||
"returns, the controller's differs": {Head: "h1", Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"},
|
||||
"returns": {Proposal: id, Revision: 1, Verdict: CandidateSame, Returns: ReturnsToController, Same: []string{"app", "node"}},
|
||||
"returns, the controller's differs": {Proposal: id, Revision: 1, Verdict: CandidateDiffers, Returns: ReturnsToController, Same: []string{"node"},
|
||||
Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}, Removed: []string{"old"}},
|
||||
}
|
||||
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers,
|
||||
@@ -398,7 +399,7 @@ func TestPlacement(t *testing.T) {
|
||||
t.Errorf("a difference past the cut: %+v", far.Differs)
|
||||
}
|
||||
// And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay.
|
||||
big := CandidateAnswer{Head: "h", Verdict: CandidateDiffers, Differs: far.Differs}
|
||||
big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: far.Differs}
|
||||
if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 ||
|
||||
fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" {
|
||||
t.Errorf("fitted as %+v", fitted)
|
||||
@@ -419,7 +420,7 @@ func TestPlacement(t *testing.T) {
|
||||
[]Composed{comp(sealedFile("nats.needs-jwt", "box-2", "0600"), sealedFile("nats.needs-salt", "box-5", "0600"),
|
||||
sealedFile("nats.conf", "box-7", "0600"), sealedFile("other.needs-key", "box-9", "0600"))},
|
||||
func(a, b string) (same, known bool) { return a == "box-4", true })
|
||||
got := CandidateAnswer{Head: "h", Differs: drift.Differs}
|
||||
got := CandidateAnswer{Proposal: "p-x1", Revision: 1, Differs: drift.Differs}
|
||||
if own := OwnValuesDiffer(got); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) {
|
||||
t.Errorf("the own secrets with another value are %+v, want nats jwt alone", own)
|
||||
}
|
||||
@@ -443,16 +444,16 @@ func TestPlacement(t *testing.T) {
|
||||
}
|
||||
for name, a := range map[string]CandidateAnswer{
|
||||
"no head": {Verdict: CandidateSame},
|
||||
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs},
|
||||
"differs with none": {Head: "h", Verdict: CandidateDiffers},
|
||||
"refused no reason": {Head: "h", Verdict: CandidateRefused},
|
||||
"unknown verdict": {Head: "h", Verdict: "maybe"},
|
||||
"diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
|
||||
"same with an added": {Head: "h", Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
|
||||
"differs refusing": {Head: "h", Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
||||
"refused and modules": {Head: "h", Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
|
||||
"returns refusing": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
||||
"returns refused": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
|
||||
"same with a diff": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Differs: answers["differs"].Differs},
|
||||
"differs with none": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers},
|
||||
"refused no reason": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused},
|
||||
"unknown verdict": {Proposal: "p-x1", Revision: 1, Verdict: "maybe"},
|
||||
"diff with no module": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
|
||||
"same with an added": {Proposal: "p-x1", Revision: 1, Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
|
||||
"differs refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
||||
"refused and modules": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
|
||||
"returns refusing": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
||||
"returns refused": {Proposal: "p-x1", Revision: 1, Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
|
||||
} {
|
||||
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
@@ -460,4 +461,113 @@ func TestPlacement(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// ADR 0567 §2, §8: a proposal's changes laid over the current keys, what waits for the lease write, and the proposal's
|
||||
// encoding and refusals.
|
||||
t.Run("proposal", func(t *testing.T) {
|
||||
current := map[string]Placement{"g14": g14, "novox": novox}
|
||||
laid, findings := LayChanges(current, []PlacementProposalChange{
|
||||
{Op: OpPlace, Node: "g14", Module: "new"},
|
||||
{Op: OpTakeOff, Node: "g14", Module: "zsh"},
|
||||
{Op: OpUnpin, Node: "g14", Provision: "shell"},
|
||||
{Op: OpDecide, Node: "novox", Field: "site", Value: json.RawMessage(`"attic"`)},
|
||||
})
|
||||
if len(findings) != 0 || len(laid) != 2 || !reflect.DeepEqual(laid["g14"].Assigned(), []string{"app", "new"}) ||
|
||||
len(laid["g14"].Holds) != 0 || laid["g14"].Pins != nil || laid["novox"].Decided.Site != "attic" || !laid["novox"].Decided.Hub {
|
||||
t.Fatalf("laid %+v, %v", laid, findings)
|
||||
}
|
||||
if !reflect.DeepEqual(current["g14"].Assigned(), []string{"zsh", "app"}) {
|
||||
t.Error("laying changed the current placement")
|
||||
}
|
||||
if fresh, f := LayChanges(current, []PlacementProposalChange{{Op: OpPlace, Node: "pi", Module: "zsh"}}); len(f) != 0 || fresh["pi"].Node != "pi" {
|
||||
t.Errorf("a node with no key: %+v, %v", fresh, f)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
change PlacementProposalChange
|
||||
want string
|
||||
}{
|
||||
"not placed": {PlacementProposalChange{Op: OpTakeOff, Node: "g14", Module: "nope"}, "not placed"},
|
||||
"no such field": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "colour", Value: json.RawMessage(`"x"`)}, "no decided field"},
|
||||
"wrong type": {PlacementProposalChange{Op: OpDecide, Node: "g14", Field: "hub", Value: json.RawMessage(`"x"`)}, "does not take"},
|
||||
"unknown op": {PlacementProposalChange{Op: "move", Node: "g14"}, "no change of placement"},
|
||||
"pin of nobody": {PlacementProposalChange{Op: OpPin, Node: "g14", Provision: "x"}, "provider"},
|
||||
"not held": {PlacementProposalChange{Op: OpUnhold, Node: "g14", Module: "app", Claim: "x"}, "holds no"},
|
||||
"bad port": {PlacementProposalChange{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"a": 1}}, "port"},
|
||||
"seed of another": {PlacementProposalChange{Op: OpSeed, Node: "g14", Whole: &novox}, "seed"},
|
||||
} {
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{c.change}); len(f) != 1 || !strings.Contains(f[0], c.want) {
|
||||
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
|
||||
}
|
||||
}
|
||||
for change, waits := range map[*PlacementProposalChange]bool{
|
||||
{Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false,
|
||||
{Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false,
|
||||
{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true,
|
||||
{Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true,
|
||||
{Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true,
|
||||
{Op: OpConfig, Node: "g14", Values: map[string]any{"puid": 1}}: false, {Op: OpConfig, Node: "g14", MeshValues: map[string]any{"a": 1}}: false,
|
||||
{Op: OpConfig, Node: "g14", Layer: map[string]any{"places": 1}}: true, {Op: OpConfig, Node: "g14", MeshWide: map[string]any{"accesses": 1}}: true,
|
||||
} {
|
||||
if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) {
|
||||
t.Errorf("%+v before the lease write: %q", *change, got)
|
||||
}
|
||||
}
|
||||
|
||||
const id = "p-20261028t183000-1a2b3c"
|
||||
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil {
|
||||
t.Errorf("NewProposalID = %q", got)
|
||||
}
|
||||
p := PlacementProposal{ID: id, By: "operator@anchor", Why: "move the app", At: time.Unix(1, 0).UTC(),
|
||||
Changes: []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, Base: map[string]uint64{"g14": 4},
|
||||
State: ProposalAnswering}
|
||||
raw, err := EncodePlacementProposal(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back, err := DecodePlacementProposal(raw); err != nil || back.ID != id || back.Base["g14"] != 4 || !reflect.DeepEqual(back.Nodes(), []string{"g14"}) {
|
||||
t.Fatalf("round trip %+v, %v", back, err)
|
||||
}
|
||||
seed := p
|
||||
seed.Seed, seed.ExportDigest = true, ExportDigest(current)
|
||||
seed.Changes = []PlacementProposalChange{{Op: OpSeed, Node: "g14", Whole: &g14}}
|
||||
if _, err := EncodePlacementProposal(seed); err != nil {
|
||||
t.Errorf("the seed: %v", err)
|
||||
}
|
||||
if ExportDigest(current) != ExportDigest(map[string]Placement{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) {
|
||||
t.Error("the export digest depends on order or not on the placements")
|
||||
}
|
||||
back := p
|
||||
back.HandBack, back.Changes = true, nil
|
||||
if _, err := EncodePlacementProposal(back); err != nil {
|
||||
t.Errorf("a hand-back: %v", err)
|
||||
}
|
||||
if k, err := PulseAnswerKey(id, "g14"); err != nil || k != "answer."+id+".g14" {
|
||||
t.Errorf("PulseAnswerKey = %q, %v", k, err)
|
||||
}
|
||||
if k, err := PulseAskKey(id); err != nil || k != "ask."+id || PlacementPulsesBucket != "mesh-delivery-assigner_pulses" {
|
||||
t.Errorf("PulseAskKey = %q, %v", k, err)
|
||||
}
|
||||
if ChangesDigest(p) == ChangesDigest(seed) {
|
||||
t.Error("two proposals' changes digest alike")
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"no id": func(p *PlacementProposal) { p.ID = "" },
|
||||
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
||||
"no why": func(p *PlacementProposal) { p.Why = " " },
|
||||
"no change": func(p *PlacementProposal) { p.Changes = nil },
|
||||
"no base": func(p *PlacementProposal) { p.Base = map[string]uint64{"novox": 1} },
|
||||
"no state": func(p *PlacementProposal) { p.State = "" },
|
||||
"asked, no ask": func(p *PlacementProposal) { p.State = ProposalAsked },
|
||||
"seed mixed": func(p *PlacementProposal) { p.Seed, p.ExportDigest = true, "d" },
|
||||
"bad change": func(p *PlacementProposal) { p.Changes[0].Op = "swap" },
|
||||
"seed, no digest": func(p *PlacementProposal) { *p = seed; p.ExportDigest = "" },
|
||||
"hand-back with changes": func(p *PlacementProposal) { p.HandBack = true },
|
||||
} {
|
||||
q := p
|
||||
q.Changes = append([]PlacementProposalChange(nil), p.Changes...)
|
||||
mut(&q)
|
||||
if _, err := EncodePlacementProposal(q); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+8
-19
@@ -537,13 +537,11 @@ type Forge struct {
|
||||
Act string `json:"act,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
Open []OpenPull `json:"open,omitempty"`
|
||||
// Placements are, on a merged message of the catalogue repository, every placement file of the merge commit, the
|
||||
// bytes exact, by node (PlacementFile; novox/hq ADR 0549 §2), and PlacementsRead says they were read, so a catalogue
|
||||
// whose last placement file was removed is told from a message that read none (older writers, another repository).
|
||||
// On a pull message of the catalogue (CatalogueRepository) they are every placement file at the pull request's head,
|
||||
// read whole or not at all, which the indexer writes as placement candidates (PlacementCandidate).
|
||||
Placements map[string]string `json:"placements,omitempty"`
|
||||
PlacementsRead bool `json:"placements-read,omitempty"`
|
||||
// RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is
|
||||
// the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is
|
||||
// refused, loudly, rather than read with them dropped.
|
||||
RetiredPlacements json.RawMessage `json:"placements,omitempty"`
|
||||
RetiredRead bool `json:"placements-read,omitempty"`
|
||||
}
|
||||
|
||||
func (f Forge) check() error {
|
||||
@@ -556,18 +554,9 @@ func (f Forge) check() error {
|
||||
if len(f.Open) > 0 {
|
||||
return fmt.Errorf("delivery: only an open message names open pull requests")
|
||||
}
|
||||
// A pull message of the catalogue carries its head's placement files, so its change of placement is composed and
|
||||
// answered by the nodes it places before it merges (placement candidates, issue 1126; ADR 0549 §4).
|
||||
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged && (f.Kind != ForgePull || f.Repository != CatalogueRepository) {
|
||||
return fmt.Errorf("delivery: only a merged message, or a pull message of the catalogue, carries placement files")
|
||||
}
|
||||
if len(f.Placements) > 0 && !f.PlacementsRead {
|
||||
return fmt.Errorf("delivery: a message with placement files must say it read them (placements-read)")
|
||||
}
|
||||
for node := range f.Placements {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return fmt.Errorf("delivery: a placement file names no node: %w", err)
|
||||
}
|
||||
if len(f.RetiredPlacements) > 0 || f.RetiredRead {
|
||||
return fmt.Errorf("delivery: a forge message carries placement files, which no repository holds since ADR 0567: " +
|
||||
"placement is the seat mesh-placement's state")
|
||||
}
|
||||
if err := checkRepository(f.Repository); err != nil {
|
||||
return err
|
||||
|
||||
@@ -340,8 +340,20 @@ type CheckJob struct {
|
||||
// Packages, when set, are the only packages each level tests (issue 890, ADR 0552 §4): a placed ask names the
|
||||
// packages the first ask left unmet, and the others are neither tested nor taken kept, so nothing is judged twice.
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
// NodeNames are the names of every node with a record (novox/hq ADR 0567 §12), read by the checker from each
|
||||
// `<node>.node` of node-engine's have: the names check of ADR 0507 §9 looks for them in the tree, so no repository
|
||||
// has to list them. The build step hands them to its test tool as the argument NodeNamesArg, and the build module
|
||||
// gives them to the tests in NodeNamesEnv.
|
||||
NodeNames []string `json:"node-names,omitempty"`
|
||||
}
|
||||
|
||||
// NodeNamesArg is the test tool's argument that carries a check's node names, and NodeNamesEnv the environment
|
||||
// variable a build module sets for the tests it runs from it, the names separated by commas.
|
||||
const (
|
||||
NodeNamesArg = "node-names"
|
||||
NodeNamesEnv = "MESH_NODE_NAMES"
|
||||
)
|
||||
|
||||
func (a StepAsk) check() error {
|
||||
if err := checkAskID(a.ID); err != nil {
|
||||
return err
|
||||
@@ -447,6 +459,13 @@ func (c CheckJob) check(id string) error {
|
||||
return fmt.Errorf("delivery: the check ask %s names a package %q that is no import path", id, p)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range c.NodeNames {
|
||||
if err := checkName("node", n); err != nil || strings.Contains(n, ",") || seen[n] {
|
||||
return fmt.Errorf("delivery: the check ask %s names the node %q, which is no node name or named twice", id, n)
|
||||
}
|
||||
seen[n] = true
|
||||
}
|
||||
return checkFetchArgs(id, c.Fetch)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ func checkAsk() StepAsk {
|
||||
return StepAsk{ID: "mh3k2x9qa1b2c4", Kind: AskCheck, Language: AskNode, Asker: "mesh-delivery-checker",
|
||||
At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC),
|
||||
Check: &CheckJob{Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit, "ref": "refs/pull/3/head", "base": askCommit},
|
||||
Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: ""}}
|
||||
Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: "",
|
||||
NodeNames: []string{"anchor", "laptop"}}}
|
||||
}
|
||||
|
||||
// 1887/A1a: an ask encodes and decodes with every field.
|
||||
@@ -65,6 +66,14 @@ func TestAskRefusesUnknownKindAndLanguage(t *testing.T) {
|
||||
t.Errorf("%s: encoded", name)
|
||||
}
|
||||
}
|
||||
// ADR 0567 §12: a check's node names are each a node name, once.
|
||||
for _, names := range [][]string{{"anchor", "anchor"}, {"a.b"}, {""}, {"a,b"}} {
|
||||
c := checkAsk()
|
||||
c.Check.NodeNames = names
|
||||
if _, err := EncodeStepAsk(c); err == nil {
|
||||
t.Errorf("the node names %q: encoded", names)
|
||||
}
|
||||
}
|
||||
// A copy alone fetches nothing and needs no path.
|
||||
a := buildAsk()
|
||||
a.Build.Fetch, a.Build.Path, a.Build.Artifacts = nil, "", []AskArtifact{{Artifact: "base", From: "registry/x@sha256:00"}}
|
||||
|
||||
@@ -38,14 +38,19 @@ const (
|
||||
// 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"
|
||||
// AskKindPlacement is the kind of the seat mesh-placement's authorising ask about a proposal (novox/hq ADR 0567 §5):
|
||||
// `placement.<proposal id>`. The kind needs no proof, as the bus's put does not: its yes is an Approve, which only a
|
||||
// channel proving who answered takes and the view never does (ViewAnswerFits).
|
||||
AskKindPlacement = "placement"
|
||||
|
||||
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 and a backup's Node, Module and FP, a record's Node, Module and the digest
|
||||
// Composed of the composition it would allow.
|
||||
// Composed of the composition it would allow, a placement's Proposal.
|
||||
type AskKey struct {
|
||||
Proposal string
|
||||
Kind string
|
||||
Group string
|
||||
Hash string
|
||||
@@ -61,11 +66,11 @@ func AskNeedsProof(kind string) (bool, error) {
|
||||
switch kind {
|
||||
case AskKindMerge:
|
||||
return true, nil
|
||||
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord:
|
||||
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindPlacement:
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s, %s, %s or %s", kind, AskKindMerge, AskKindHold,
|
||||
AskKindRestore, AskKindBackup, AskKindRecord)
|
||||
return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s, %s, %s, %s or %s", kind, AskKindMerge, AskKindHold,
|
||||
AskKindRestore, AskKindBackup, AskKindRecord, AskKindPlacement)
|
||||
}
|
||||
|
||||
// HoldAskKey is the key, in AssignerAsksBucket, of the ask about a module's held fingerprint: `hold.<module>.<hex>`.
|
||||
@@ -100,6 +105,15 @@ func BackupAskKey(node, module, fingerprint string) (string, error) {
|
||||
return AskKindBackup + "." + key, nil
|
||||
}
|
||||
|
||||
// PlacementAskKey is the key, in AssignerAsksBucket, of the authorising ask about a proposal of placement:
|
||||
// `placement.<proposal id>` (ADR 0567 §5).
|
||||
func PlacementAskKey(proposal string) (string, error) {
|
||||
if err := CheckProposalID(proposal); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return AskKindPlacement + "." + proposal, 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).
|
||||
@@ -154,6 +168,11 @@ func ParseAnyAskKey(key string) (AskKey, error) {
|
||||
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 AskKindPlacement:
|
||||
if err := CheckProposalID(rest); err != nil {
|
||||
return refused(err)
|
||||
}
|
||||
return AskKey{Kind: kind, Proposal: rest}, nil
|
||||
case AskKindRecord:
|
||||
parts := strings.Split(rest, ".")
|
||||
if len(parts) != 3 {
|
||||
|
||||
@@ -33,9 +33,17 @@ func TestParseAnyAskKey(t *testing.T) {
|
||||
if err != nil || restore != "restore.ace.mesh-mail."+strings.Repeat("d", 64) {
|
||||
t.Fatalf("RestoreAskKey = %q, %v", restore, err)
|
||||
}
|
||||
placement, err := PlacementAskKey("p-one")
|
||||
if err != nil || placement != "placement.p-one" {
|
||||
t.Fatalf("PlacementAskKey = %q, %v", placement, err)
|
||||
}
|
||||
if proof, err := AskNeedsProof(AskKindPlacement); proof || err != nil {
|
||||
t.Error("the assigner cannot keep a placement ask: its kind needs proof (its yes's level does)")
|
||||
}
|
||||
for key, want := range map[string]AskKey{
|
||||
hold: {Kind: AskKindHold, Module: "mesh-mail", FP: fp},
|
||||
restore: {Kind: AskKindRestore, Node: "ace", Module: "mesh-mail", FP: fp},
|
||||
hold: {Kind: AskKindHold, Module: "mesh-mail", FP: fp},
|
||||
restore: {Kind: AskKindRestore, Node: "ace", Module: "mesh-mail", FP: fp},
|
||||
placement: {Kind: AskKindPlacement, Proposal: "p-one"},
|
||||
} {
|
||||
if got, err := ParseAnyAskKey(key); err != nil || got != want {
|
||||
t.Errorf("ParseAnyAskKey(%q) = %+v, %v", key, got, err)
|
||||
@@ -44,7 +52,7 @@ func TestParseAnyAskKey(t *testing.T) {
|
||||
if got, err := ParseAnyAskKey(merge); err != nil || got.Kind != AskKindMerge || got.Group != "feat/r4g" || len(got.Hash) != 12 {
|
||||
t.Errorf("ParseAnyAskKey(%q) = %+v, %v", merge, got, err)
|
||||
}
|
||||
for _, bad := range []string{"want.ace.mesh-mail", "hold.mesh-mail", "restore.mesh-mail." + strings.Repeat("d", 64), "approve.x.y", "restore.a.b.c.d", ""} {
|
||||
for _, bad := range []string{"want.ace.mesh-mail", "hold.mesh-mail", "restore.mesh-mail." + strings.Repeat("d", 64), "approve.x.y", "restore.a.b.c.d", "placement.P", ""} {
|
||||
if _, err := ParseAnyAskKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
|
||||
t.Errorf("ParseAnyAskKey(%q) = %v, want a refusal naming it", bad, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user