Merge main into fix/1901-failure-contract: the capacity tests stay one function, so the SDK's test count does not grow (issue 1901)
This commit is contained in:
@@ -70,6 +70,11 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
|
||||
if s.Fetch == nil {
|
||||
return nil, fetched{}, Refuse("this build module fetches nothing")
|
||||
}
|
||||
if _, merges := args["base"]; merges {
|
||||
Phase(ctx, "fetch and merge")
|
||||
} else {
|
||||
Phase(ctx, "fetch")
|
||||
}
|
||||
out, err := s.Fetch(ctx, args)
|
||||
if err != nil {
|
||||
return nil, fetched{}, err
|
||||
@@ -126,6 +131,7 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
|
||||
if s.Copy == nil {
|
||||
return nil, Refuse("this build module copies no artifact (%s)", a.Artifact)
|
||||
}
|
||||
Phase(ctx, "copy "+a.Artifact)
|
||||
out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From})
|
||||
} else {
|
||||
if s.Build == nil {
|
||||
@@ -135,6 +141,7 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
|
||||
if job.Publish {
|
||||
args["publish"] = true
|
||||
}
|
||||
Phase(ctx, "build "+a.Artifact)
|
||||
out, err = s.Build(ctx, args)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -196,6 +203,13 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
return args
|
||||
}
|
||||
test := func(args map[string]any) (bool, error) {
|
||||
what := "the whole level"
|
||||
if dir, _ := args["module"].(string); dir != "" {
|
||||
what = dir
|
||||
} else if pkgs, _ := args["packages"].([]string); len(pkgs) > 0 {
|
||||
what = fmt.Sprintf("%d packages", len(pkgs))
|
||||
}
|
||||
Phase(ctx, fmt.Sprintf("%s: tests of %s", level, what))
|
||||
out, err := s.Test(ctx, args)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -287,6 +301,7 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string,
|
||||
if job.Run != "" {
|
||||
return nil, true, true
|
||||
}
|
||||
Phase(ctx, level+": digest")
|
||||
out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level}))
|
||||
if err != nil {
|
||||
if s.Logf != nil {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"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).
|
||||
|
||||
// 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).
|
||||
const BuildHoursSetting = "build-hours"
|
||||
|
||||
// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive.
|
||||
type BuildWindow struct {
|
||||
Start, End int
|
||||
}
|
||||
|
||||
// String is the window as the setting writes it.
|
||||
func (w BuildWindow) String() string {
|
||||
return fmt.Sprintf("%02d:%02d-%02d:%02d", w.Start/60, w.Start%60, w.End/60, w.End%60)
|
||||
}
|
||||
|
||||
// Open says whether t, in its own location, falls in the window.
|
||||
func (w BuildWindow) Open(t time.Time) bool {
|
||||
m := t.Hour()*60 + t.Minute()
|
||||
if w.Start < w.End {
|
||||
return m >= w.Start && m < w.End
|
||||
}
|
||||
return m >= w.Start || m < w.End
|
||||
}
|
||||
|
||||
// NextEdge is the first time after t, in t's location, at which the window opens or closes.
|
||||
func (w BuildWindow) NextEdge(t time.Time) time.Time {
|
||||
var next time.Time
|
||||
for day := 0; day <= 1; day++ {
|
||||
for _, m := range []int{w.Start, w.End} {
|
||||
e := time.Date(t.Year(), t.Month(), t.Day()+day, m/60, m%60, 0, 0, t.Location())
|
||||
if e.After(t) && (next.IsZero() || e.Before(next)) {
|
||||
next = e
|
||||
}
|
||||
}
|
||||
}
|
||||
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}
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// minuteOf reads HH:MM as minutes after midnight.
|
||||
func minuteOf(s string) (int, error) {
|
||||
var h, m int
|
||||
s = strings.TrimSpace(s)
|
||||
if n, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 {
|
||||
return 0, fmt.Errorf("%q is not a time HH:MM", s)
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// logf, and the puller writes it in the capacity record that build_usage shows.
|
||||
func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*BuildWindow, string) {
|
||||
said := ""
|
||||
say := func(why string) {
|
||||
if logf != nil && why != said {
|
||||
said = why
|
||||
logf("%s, so %s builds at any hour", why, node)
|
||||
}
|
||||
}
|
||||
fault := func(why string) (*BuildWindow, string) {
|
||||
say(why)
|
||||
return nil, why
|
||||
}
|
||||
return func() (*BuildWindow, string) {
|
||||
if path == "" {
|
||||
return nil, ""
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fault(fmt.Sprintf("the setting %s cannot be read: %v", BuildHoursSetting, err))
|
||||
}
|
||||
var s map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return fault(fmt.Sprintf("the settings file %s is not JSON: %v", path, err))
|
||||
}
|
||||
setting := ""
|
||||
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)
|
||||
if err != nil {
|
||||
return fault(err.Error())
|
||||
}
|
||||
said = ""
|
||||
return w, ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **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
|
||||
// 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
|
||||
// without a restart, and the result is said (the seat's current verb, the status tools, `capacity.<node>` in the pauses
|
||||
// state that the delivery view's build_usage reads).
|
||||
|
||||
// CoresPerStepSetting is a build module's setting of the processors one of its steps needs: a number above zero, the
|
||||
// same on every node (a step's need is the module's, not the node's). Each build module declares it with its own default
|
||||
// (DefaultCoresPerStep).
|
||||
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,
|
||||
// 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}
|
||||
|
||||
// Quota is the build slice's CPU quota as this process's cgroup holds it, in percent of one processor (1200 is 12
|
||||
// cores), and where it was read: the cgroup's cpu.max (buildcpu.ReadInForce, the tighter of its unit's and its slice's),
|
||||
// else the share the composer gave (buildcpu.ShareWord) over the machine's processors, else 0 and why none is known.
|
||||
func Quota() (percent int, from string) {
|
||||
f := buildcpu.ReadInForce()
|
||||
if f.Quota > 0 {
|
||||
path := f.Slice.Path
|
||||
if path == "" {
|
||||
path = f.Cgroup
|
||||
}
|
||||
return f.Quota, "the cpu.max of " + path
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(buildcpu.ShareWord))); err == nil && n >= 1 && n <= 100 {
|
||||
return buildcpu.Quota(n, runtime.NumCPU()), fmt.Sprintf("the share %d%% the composer gave (%s), of %d processors; no cpu.max is read", n, buildcpu.ShareWord, runtime.NumCPU())
|
||||
}
|
||||
return 0, "no CPU quota: neither a cpu.max on its cgroup nor a share from the composer"
|
||||
}
|
||||
|
||||
// EffectiveCapacity is the capacity a seat's holder judges by, from its configured capacity, its node's quota (percent of
|
||||
// one processor, 0 when none is known) and the cores one of its steps needs. Without a quota it is the configured one;
|
||||
// with one, min(configured, floor(quota cores / coresPerStep)), and one at least while the configured one is above
|
||||
// zero. A coresPerStep of zero or less is taken as 1.
|
||||
func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) delivery.Capacity {
|
||||
if coresPerStep <= 0 || math.IsNaN(coresPerStep) || math.IsInf(coresPerStep, 0) {
|
||||
coresPerStep = 1
|
||||
}
|
||||
c := delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep}
|
||||
switch {
|
||||
case configured <= 0:
|
||||
c.Effective = 0
|
||||
c.Why = fmt.Sprintf("its configured capacity is %d, so it pulls nothing", configured)
|
||||
return c
|
||||
case quotaPercent <= 0:
|
||||
c.Why = fmt.Sprintf("its configured capacity %d; %s, so the CPU bounds nothing", configured, quotaFrom)
|
||||
return c
|
||||
}
|
||||
c.QuotaCores = float64(quotaPercent) / 100
|
||||
c.ByCPU = int(math.Floor(c.QuotaCores/coresPerStep + 1e-9))
|
||||
if c.ByCPU < 1 {
|
||||
c.ByCPU = 1
|
||||
}
|
||||
byCPU := fmt.Sprintf("%g cores of the build slice (%s) / %g cores a step = %d", c.QuotaCores, quotaFrom, coresPerStep, c.ByCPU)
|
||||
if c.ByCPU < configured {
|
||||
c.Effective = c.ByCPU
|
||||
c.Why = fmt.Sprintf("%d, bounded by the CPU: %s, under its configured %d", c.Effective, byCPU, configured)
|
||||
} else {
|
||||
c.Why = fmt.Sprintf("%d, its configured capacity; the CPU allows %s", configured, byCPU)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// NodeCoresPerStep is the module's cores-per-step, read from its settings file at path at every call so a change is
|
||||
// followed at the next judgement; fallback when there is no path, no value or one that is not a number above zero, said
|
||||
// once per distinct fault through logf.
|
||||
func NodeCoresPerStep(path string, fallback float64, logf func(string, ...any)) func() float64 {
|
||||
said := ""
|
||||
say := func(why string) {
|
||||
if logf != nil && why != said {
|
||||
said = why
|
||||
logf("%s, so a step is taken to need %g cores", why, fallback)
|
||||
}
|
||||
}
|
||||
return func() float64 {
|
||||
if path == "" {
|
||||
return fallback
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
say(fmt.Sprintf("the setting %s cannot be read: %v", CoresPerStepSetting, err))
|
||||
return fallback
|
||||
}
|
||||
var s map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
say(fmt.Sprintf("the settings file %s is not JSON: %v", path, err))
|
||||
return fallback
|
||||
}
|
||||
v, ok := s[CoresPerStepSetting]
|
||||
if !ok {
|
||||
return fallback // not declared yet (a module before its manifest names it): the default, nothing to say
|
||||
}
|
||||
n, err := coresOf(v)
|
||||
if err != nil {
|
||||
say(fmt.Sprintf("the setting %s %s: %v", CoresPerStepSetting, v, err))
|
||||
return fallback
|
||||
}
|
||||
said = ""
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
// coresOf reads a cores-per-step value: a JSON number or a string holding one, above zero.
|
||||
func coresOf(v json.RawMessage) (float64, error) {
|
||||
var n float64
|
||||
if err := json.Unmarshal(v, &n); err != nil {
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return 0, fmt.Errorf("is neither a number nor a string")
|
||||
}
|
||||
if n, err = strconv.ParseFloat(strings.TrimSpace(s), 64); err != nil {
|
||||
return 0, fmt.Errorf("is not a number")
|
||||
}
|
||||
}
|
||||
if n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) {
|
||||
return 0, fmt.Errorf("is not above zero")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -3,12 +3,16 @@ package buildstep
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls
|
||||
// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing. ADR 0553 §4: a
|
||||
// step's threads are the composer's share over the module's own capacity; without the word, none.
|
||||
// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing.
|
||||
// ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And
|
||||
// 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)
|
||||
@@ -44,4 +48,87 @@ func TestNodeCapacityAndLimits(t *testing.T) {
|
||||
if l := NodeLimits(3); l.Percent != 75 || l.Slots != 3 || l.Threads < 1 {
|
||||
t.Errorf("from the word: %+v", l)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
configured, quota int
|
||||
cores float64
|
||||
effective, byCPU int
|
||||
}{
|
||||
{"shanks: 12 cores, configured 3", 3, 1200, 6, 2, 2},
|
||||
{"ace: 18 cores, configured 2", 2, 1800, 6, 2, 3},
|
||||
{"g14: 8 cores, configured 2", 2, 800, 6, 1, 1},
|
||||
{"node on shanks: 12 cores at 2", 3, 1200, 2, 3, 6},
|
||||
{"no quota: the configured one", 3, 0, 6, 3, 0},
|
||||
{"configured 0 pulls nothing", 0, 1200, 6, 0, 0},
|
||||
{"a quota under one step still builds one", 2, 100, 6, 1, 1},
|
||||
{"no cores a step is one", 2, 150, 0, 1, 1},
|
||||
} {
|
||||
got := EffectiveCapacity(c.configured, c.quota, "test", c.cores)
|
||||
if got.Effective != c.effective || got.ByCPU != c.byCPU || got.Why == "" {
|
||||
t.Errorf("%s: %+v, want effective %d, by CPU %d", c.name, got, c.effective, c.byCPU)
|
||||
}
|
||||
}
|
||||
file = filepath.Join(t.TempDir(), "settings.json")
|
||||
var said []string
|
||||
cores := NodeCoresPerStep(file, 6, func(f string, a ...any) { said = append(said, f) })
|
||||
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}} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if len(said) != 1 {
|
||||
t.Errorf("a value not above zero is said once, said %d times", len(said))
|
||||
}
|
||||
|
||||
// The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the
|
||||
// capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour.
|
||||
cet := time.FixedZone("CET", 3600)
|
||||
day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) }
|
||||
was := now
|
||||
defer func() { now = was }()
|
||||
for _, c := range []struct {
|
||||
name, setting, node string
|
||||
at time.Time
|
||||
effective int
|
||||
edge string // the window's next edge, HH:MM; "" for no window
|
||||
bad bool
|
||||
}{
|
||||
{"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},
|
||||
} {
|
||||
w, err := ParseBuildHours(c.setting, c.node)
|
||||
if (err != nil) != c.bad {
|
||||
t.Errorf("%s: parse %v", c.name, err)
|
||||
}
|
||||
if edge := ""; w != nil {
|
||||
edge = w.NextEdge(c.at).Format("15:04")
|
||||
if edge != c.edge {
|
||||
t.Errorf("%s: next edge %s, want %s", c.name, edge, c.edge)
|
||||
}
|
||||
} else if c.edge != "" {
|
||||
t.Errorf("%s: no window, want one", c.name)
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "settings.json")
|
||||
os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600)
|
||||
now = func() time.Time { return c.at }
|
||||
p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil)}
|
||||
got := p.CapacityDetail()
|
||||
if got.Effective != c.effective || got.OutsideHours != (c.effective == 0) || (c.effective == 0 && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) {
|
||||
t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective)
|
||||
}
|
||||
// A value that cannot be read builds at any hour, loudly: the record names the value and says why.
|
||||
if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) {
|
||||
t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ type MergeResult struct {
|
||||
Base, Head, Merge string
|
||||
Conflicts []string
|
||||
Changed []string
|
||||
// Programs are the changed files that are compiled programs (ExecutableKind of their first bytes, read in the workspace
|
||||
// at the merge); never nil for a clean merge, so a caller tells none from a module that reports none (issue 1577).
|
||||
Programs []Program
|
||||
}
|
||||
|
||||
// MergeInto makes, in the workspace dir that FetchCommit left at commit, the merge of commit into the trunk commit
|
||||
@@ -98,6 +101,16 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
|
||||
return res, fmt.Errorf("the paths the merge changes cannot be read: %w", err)
|
||||
}
|
||||
res.Changed = strings.Fields(changed)
|
||||
raw, err := git("diff", "--raw", "-z", "--no-renames", "--diff-filter=AM", base, res.Merge)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("the files the merge adds or modifies cannot be read: %w", err)
|
||||
}
|
||||
res.Programs = programsIn(dir, raw)
|
||||
history, err := historyPrograms(git, base, commit, res.Programs)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("the commits of the branch cannot be read for compiled programs: %w", err)
|
||||
}
|
||||
res.Programs = append(res.Programs, history...)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,30 @@ func (r *mergeRig) head(files map[string]string) string {
|
||||
return h
|
||||
}
|
||||
|
||||
// headThatAddsAndRemoves is head with an earlier commit on the branch that adds a program and a later one that removes it: the
|
||||
// branch's tree is clean, its history is not. It answers the head and the earlier commit.
|
||||
func (r *mergeRig) headThatAddsAndRemoves(files map[string]string) (head, added string) {
|
||||
realGit(r.t, r.work, "checkout", "-q", "-B", "pr", r.fork)
|
||||
writeFile(r.t, r.work, "gone/prog", "\x7fELF\x02\x01")
|
||||
realGit(r.t, r.work, "add", ".")
|
||||
realGit(r.t, r.work, "commit", "-q", "-m", "oops")
|
||||
added = realGit(r.t, r.work, "rev-parse", "HEAD")
|
||||
realGit(r.t, r.work, "rm", "-q", "gone/prog")
|
||||
return r.head2(files), added
|
||||
}
|
||||
|
||||
// head2 commits files on the current branch and pushes it as the pull request's head.
|
||||
func (r *mergeRig) head2(files map[string]string) string {
|
||||
for n, c := range files {
|
||||
writeFile(r.t, r.work, n, c)
|
||||
}
|
||||
realGit(r.t, r.work, "add", ".")
|
||||
realGit(r.t, r.work, "commit", "-q", "-m", "head")
|
||||
h := realGit(r.t, r.work, "rev-parse", "HEAD")
|
||||
realGit(r.t, r.work, "push", "-q", r.bare, "HEAD:refs/pull/1/head")
|
||||
return h
|
||||
}
|
||||
|
||||
// advance moves the trunk past the fork point with a commit changing files, pushes it and answers its sha.
|
||||
func (r *mergeRig) advance(files map[string]string) string {
|
||||
realGit(r.t, r.work, "checkout", "-q", "-B", "main", r.fork)
|
||||
@@ -102,7 +126,7 @@ func (r *mergeRig) fetch(head string) {
|
||||
// holds both sides' changes.
|
||||
func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) {
|
||||
r := newMergeRig(t)
|
||||
head := r.head(map[string]string{"b.txt": "head\n"})
|
||||
head, added := r.headThatAddsAndRemoves(map[string]string{"b.txt": "head\n", "prog": "\x7fELF\x02\x01"})
|
||||
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
|
||||
r.fetch(head)
|
||||
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
|
||||
@@ -112,8 +136,11 @@ func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.
|
||||
if res.Base != base || res.Head != head || len(res.Merge) != 40 || res.Merge == base || res.Merge == head || len(res.Conflicts) != 0 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Changed, []string{"b.txt"}) {
|
||||
t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt", res.Changed)
|
||||
if !reflect.DeepEqual(res.Changed, []string{"b.txt", "prog"}) {
|
||||
t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt, prog", res.Changed)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Programs, []Program{{"prog", "ELF", ""}, {"gone/prog", "ELF", added}}) { // issue 1577
|
||||
t.Errorf("programs = %v, want the ELF the head adds and the one an earlier commit added and a later one removed", res.Programs)
|
||||
}
|
||||
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != res.Merge {
|
||||
t.Errorf("the workspace is at %s, not at the merge %s", at, res.Merge)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// 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
|
||||
// time since the start, and written to the ask's running step key (delivery.Step.Phase), so its asker and the delivery
|
||||
// view read where it stands from the bus.
|
||||
|
||||
type phaseKey struct{}
|
||||
|
||||
// Phase says that the ask whose run ctx belongs to entered phase; nothing outside an ask's run (a tool called by hand).
|
||||
func Phase(ctx context.Context, phase string) {
|
||||
if r, ok := ctx.Value(phaseKey{}).(*phases); ok && r != nil {
|
||||
r.enter(strings.TrimSpace(phase))
|
||||
}
|
||||
}
|
||||
|
||||
// phases is one running ask's reporter: it writes the step key again with each new phase until the step ends.
|
||||
type phases struct {
|
||||
mu sync.Mutex
|
||||
put Put
|
||||
key string
|
||||
step delivery.Step
|
||||
ended bool
|
||||
logf func(format string, args ...any)
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (r *phases) enter(phase string) {
|
||||
if phase == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.ended || phase == r.step.Phase {
|
||||
return
|
||||
}
|
||||
at := now().UTC()
|
||||
r.step.Phase, r.step.PhaseAt = phase, at
|
||||
if r.logf != nil {
|
||||
r.logf("%s: %s, %s after its start", r.prefix, phase, at.Sub(r.step.Started).Round(time.Second))
|
||||
}
|
||||
if r.put == nil {
|
||||
return
|
||||
}
|
||||
if err := write(r.put, r.key, r.step); err != nil && r.logf != nil {
|
||||
r.logf("%s: the phase %s could not be written to %s: %v", r.prefix, phase, r.key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// end stops the writes (the step's end is written after it) and answers the step with its last phase, for the end.
|
||||
func (r *phases) end() delivery.Step {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ended = true
|
||||
return r.step
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Program is a changed file whose first bytes are an executable binary: a compiled program, which is build output and not
|
||||
// source (novox/hq issue 1577: `go build` in a module's folder writes one beside go.mod, and `git add -A` takes it).
|
||||
type Program struct {
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"` // ELF, Mach-O or PE
|
||||
// Commit is set for a program a commit of the branch added and a later one removed (or replaced): the merge's tree is clean,
|
||||
// but the merge commit carries the blob into the trunk's history for good. Empty for a program the merge's tree holds.
|
||||
Commit string `json:"commit,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutableKind says what executable format the first bytes of a file are, or "" when they are none. A PE needs its header
|
||||
// ("PE\0\0" at the offset in bytes 0x3c) so a text starting with "MZ" is not one; a fat Mach-O (0xCAFEBABE) is told from a
|
||||
// Java class by its architecture count (bytes 4 to 7, fewer than 0x20; a class has its version there).
|
||||
func ExecutableKind(head []byte) string {
|
||||
switch {
|
||||
case bytes.HasPrefix(head, []byte("\x7fELF")):
|
||||
return "ELF"
|
||||
case bytes.HasPrefix(head, []byte("MZ")):
|
||||
if len(head) >= 0x40 {
|
||||
off := int(head[0x3c]) | int(head[0x3d])<<8 | int(head[0x3e])<<16 | int(head[0x3f])<<24
|
||||
if off >= 0 && off+4 <= len(head) && bytes.Equal(head[off:off+4], []byte("PE\x00\x00")) {
|
||||
return "PE"
|
||||
}
|
||||
}
|
||||
case bytes.HasPrefix(head, []byte{0xcf, 0xfa, 0xed, 0xfe}), bytes.HasPrefix(head, []byte{0xce, 0xfa, 0xed, 0xfe}),
|
||||
bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xcf}), bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xce}),
|
||||
bytes.HasPrefix(head, []byte{0xca, 0xfe, 0xba, 0xbe}) && len(head) >= 8 && head[4] == 0 && head[5] == 0 && head[6] == 0 && head[7] < 0x20:
|
||||
return "Mach-O"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// programHeadSize is how many bytes of a file are read to tell its format: enough for a PE header at its usual offsets.
|
||||
const programHeadSize = 0x100
|
||||
|
||||
// historyPrograms names the compiled programs that a commit of base..head added or modified and the merge's tree no longer
|
||||
// holds at that path (those it holds are in final already): each with the commit that added it. git is the merge's git runner.
|
||||
func historyPrograms(git func(args ...string) (string, error), base, head string, final []Program) ([]Program, error) {
|
||||
revs, err := git("rev-list", "--no-merges", "--reverse", base+".."+head)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
held := map[string]bool{}
|
||||
for _, p := range final {
|
||||
held[p.Path] = true
|
||||
}
|
||||
out, seen := []Program{}, map[string]bool{}
|
||||
for _, c := range strings.Fields(revs) {
|
||||
raw, err := git("diff-tree", "-r", "-z", "--no-commit-id", "--no-renames", "--root", "--diff-filter=AM", c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := strings.Split(raw, "\x00")
|
||||
for i := 0; i+1 < len(f); i += 2 {
|
||||
meta := strings.Fields(strings.TrimPrefix(f[i], ":"))
|
||||
if len(meta) < 4 || (meta[1] != "100644" && meta[1] != "100755") || held[f[i+1]] || seen[meta[3]] {
|
||||
continue
|
||||
}
|
||||
seen[meta[3]] = true
|
||||
blob, err := git("cat-file", "blob", meta[3])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
head := []byte(blob)
|
||||
if len(head) > programHeadSize {
|
||||
head = head[:programHeadSize]
|
||||
}
|
||||
if k := ExecutableKind(head); k != "" {
|
||||
out = append(out, Program{Path: f[i+1], Kind: k, Commit: c})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// programsIn reads the output of `git diff --raw -z --no-renames --diff-filter=AM` (":<old mode> <new mode> <old sha> <new
|
||||
// sha> <status>\0<path>\0" per file) and names each added or modified regular file of the tree dir that is a compiled
|
||||
// program, from its first bytes read locally. A symlink (mode 120000) and a submodule (160000) are not read. It never
|
||||
// answers nil, so a caller can tell no programs from none reported.
|
||||
func programsIn(dir, raw string) []Program {
|
||||
out := []Program{}
|
||||
f := strings.Split(raw, "\x00")
|
||||
for i := 0; i+1 < len(f); i += 2 {
|
||||
meta := strings.Fields(strings.TrimPrefix(f[i], ":"))
|
||||
if len(meta) < 2 || (meta[1] != "100644" && meta[1] != "100755") {
|
||||
continue
|
||||
}
|
||||
p := f[i+1]
|
||||
full := filepath.Join(dir, filepath.FromSlash(p))
|
||||
if st, err := os.Lstat(full); err != nil || !st.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
fh, err := os.Open(full)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
head := make([]byte, programHeadSize)
|
||||
n, _ := io.ReadFull(fh, head)
|
||||
fh.Close()
|
||||
if k := ExecutableKind(head[:n]); k != "" {
|
||||
out = append(out, Program{Path: p, Kind: k})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pe(offset int, magic string) []byte {
|
||||
b := make([]byte, 0x100)
|
||||
copy(b, "MZ")
|
||||
b[0x3c] = byte(offset)
|
||||
copy(b[offset:], magic)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestExecutableKindTellsProgramsFromText(t *testing.T) { // issue 1577
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
head []byte
|
||||
want string
|
||||
}{
|
||||
{"elf", []byte("\x7fELF\x02\x01\x01"), "ELF"},
|
||||
{"mach-o 64", []byte{0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1}, "Mach-O"},
|
||||
{"mach-o 32 big", []byte{0xfe, 0xed, 0xfa, 0xce, 0, 0, 0, 7}, "Mach-O"},
|
||||
{"fat mach-o", []byte{0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2}, "Mach-O"},
|
||||
{"java class", []byte{0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34}, ""},
|
||||
{"pe", pe(0x80, "PE\x00\x00"), "PE"},
|
||||
{"mz text", []byte("MZ is a text file starting with two letters"), ""},
|
||||
{"mz without a pe header", pe(0x80, "XX\x00\x00"), ""},
|
||||
{"text", []byte("package main"), ""},
|
||||
{"empty", nil, ""},
|
||||
} {
|
||||
if got := ExecutableKind(c.head); got != c.want {
|
||||
t.Errorf("%s: kind = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgramsInNamesTheAddedAndModifiedRegularFilesThatArePrograms(t *testing.T) { // issue 1577
|
||||
dir := t.TempDir()
|
||||
write := func(name string, b []byte) {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), b, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("m/prog", []byte("\x7fELF\x02\x01"))
|
||||
write("m/notes", []byte("plain text"))
|
||||
write("m/mac", []byte{0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1})
|
||||
if err := os.Symlink("prog", filepath.Join(dir, "m/link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := func(mode, path string) string { return ":000000 " + mode + " 0000 1111 A\x00" + path + "\x00" }
|
||||
for _, c := range []struct {
|
||||
name, raw string
|
||||
want []Program
|
||||
}{
|
||||
{"none", "", []Program{}},
|
||||
{"a program and a text", line("100755", "m/prog") + line("100644", "m/notes"), []Program{{"m/prog", "ELF", ""}}},
|
||||
{"two programs", line("100644", "m/prog") + line("100755", "m/mac"), []Program{{"m/prog", "ELF", ""}, {"m/mac", "Mach-O", ""}}},
|
||||
{"a symlink and a submodule are not read", line("120000", "m/link") + line("160000", "m/sub"), []Program{}},
|
||||
{"a path the tree lacks", line("100644", "m/gone"), []Program{}},
|
||||
} {
|
||||
if got := programsIn(dir, c.raw); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: programs = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
-13
@@ -77,6 +77,15 @@ type Puller struct {
|
||||
// for this node, and wakes the puller on each change (watchCapacity): WatchConfigServed in a module, a test's own
|
||||
// double. Until it hands a value the puller judges by Capacity.
|
||||
WatchConfig func(key string, handle func(stdio.ProvisionChange) error) error
|
||||
// CoresPerStep is what one ask of this module needs, read at every judgement (NodeCoresPerStep); with it the capacity
|
||||
// judged by is bounded by the node's build slice: min(the configured capacity, floor(the quota's cores / CoresPerStep))
|
||||
// (EffectiveCapacity). Nil bounds nothing by the CPU. Quota reads the quota (Quota when nil; a test's own).
|
||||
CoresPerStep func() float64
|
||||
Quota func() (percent int, from string)
|
||||
// BuildHours is the node's build window, read at every judgement (NodeBuildHours); with WatchConfig set the value
|
||||
// mesh-config resolves for `build-hours` takes its place once handed. Outside the window the capacity judged by is 0:
|
||||
// steps running finish, no new one is taken; the puller wakes at the window's next edge. Nil builds at any hour.
|
||||
BuildHours func() (*BuildWindow, string)
|
||||
// Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is
|
||||
// declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put
|
||||
// again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need
|
||||
@@ -92,6 +101,16 @@ type Puller struct {
|
||||
// watched is the capacity the config watch handed last, used in place of Capacity once hasWatched.
|
||||
watched int
|
||||
hasWatched bool
|
||||
// hours is the build window the config watch handed last, and hoursFault why its value cannot be read; used in place
|
||||
// of BuildHours once hasHours.
|
||||
hours *BuildWindow
|
||||
hoursFault string
|
||||
hasHours bool
|
||||
// judged is the capacity said last (log and capacity.<node>), so a change is said once.
|
||||
judged delivery.Capacity
|
||||
hasJudged bool
|
||||
capWritten bool
|
||||
capFault string
|
||||
// declined are the kinds whose worker handed an ask this node declined, left out of the pulls without a wait until
|
||||
// the next pull's wait ends.
|
||||
declined map[string]bool
|
||||
@@ -216,23 +235,128 @@ func (p *Puller) room() bool {
|
||||
|
||||
// roomNow is room, with the capacity and the asks in hand it was judged on.
|
||||
func (p *Puller) roomNow() (ok bool, capacity, inHand int) {
|
||||
p.mu.Lock()
|
||||
watched, has := p.watched, p.hasWatched
|
||||
p.mu.Unlock()
|
||||
if has {
|
||||
capacity = watched
|
||||
} else if p.Capacity != nil {
|
||||
capacity = p.Capacity()
|
||||
}
|
||||
capacity = p.CapacityDetail().Effective
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return !p.paused && p.inHand < capacity, capacity, p.inHand
|
||||
}
|
||||
|
||||
// CapacityNow is the capacity the puller judges by: the config watch's last value, else Capacity.
|
||||
// CapacityNow is the capacity the puller judges by (CapacityDetail's Effective).
|
||||
func (p *Puller) CapacityNow() int {
|
||||
_, capacity, _ := p.roomNow()
|
||||
return capacity
|
||||
return p.CapacityDetail().Effective
|
||||
}
|
||||
|
||||
// CapacityDetail is the capacity the puller judges by now and why: the configured one (the config watch's last value,
|
||||
// else Capacity), bounded by the node's build slice when CoresPerStep is set (EffectiveCapacity), its quota read now. A
|
||||
// change from what it said last is logged and written to `capacity.<node>` of the pauses state (PausePut), so the
|
||||
// delivery view says it too.
|
||||
func (p *Puller) CapacityDetail() delivery.Capacity {
|
||||
p.mu.Lock()
|
||||
watched, has := p.watched, p.hasWatched
|
||||
p.mu.Unlock()
|
||||
configured, from := 0, "the settings file"
|
||||
switch {
|
||||
case has:
|
||||
configured, from = watched, "mesh-config, as the config watch handed it"
|
||||
case p.Capacity != nil:
|
||||
configured = p.Capacity()
|
||||
}
|
||||
var c delivery.Capacity
|
||||
if p.CoresPerStep == nil {
|
||||
c = delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: "not judged: the module names no cores a step needs",
|
||||
Why: fmt.Sprintf("its configured capacity %d", configured)}
|
||||
} else {
|
||||
quota := p.Quota
|
||||
if quota == nil {
|
||||
quota = Quota
|
||||
}
|
||||
percent, qfrom := quota()
|
||||
c = EffectiveCapacity(configured, percent, qfrom, p.CoresPerStep())
|
||||
}
|
||||
c.ConfiguredFrom = from
|
||||
w, fault := p.window()
|
||||
if fault != "" {
|
||||
c.BuildHoursFault = fault
|
||||
c.Why = fmt.Sprintf("%s; at any hour, because its build hours cannot be read: %s", c.Why, fault)
|
||||
}
|
||||
if w != nil {
|
||||
at := now()
|
||||
zone, _ := at.Zone()
|
||||
c.BuildHours = w.String() + " " + zone
|
||||
if !w.Open(at) && c.Effective > 0 {
|
||||
c.OutsideHours = true
|
||||
c.Why = fmt.Sprintf("0, outside its build hours %s until %s (otherwise %s)", c.BuildHours, w.NextEdge(at).Format("15:04"), c.Why)
|
||||
c.Effective = 0
|
||||
} else if c.Effective > 0 {
|
||||
c.Why = fmt.Sprintf("%s; within its build hours %s until %s", c.Why, c.BuildHours, w.NextEdge(at).Format("15:04"))
|
||||
}
|
||||
}
|
||||
p.sayCapacity(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// window is the node's build window now (the config watch's last value, else BuildHours, else none), and why its value
|
||||
// cannot be read when it cannot.
|
||||
func (p *Puller) window() (*BuildWindow, string) {
|
||||
p.mu.Lock()
|
||||
w, fault, has := p.hours, p.hoursFault, p.hasHours
|
||||
p.mu.Unlock()
|
||||
if has {
|
||||
return w, fault
|
||||
}
|
||||
if p.BuildHours != nil {
|
||||
return p.BuildHours()
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// untilEdge is how long until the build window's next edge, and false when the node has no window.
|
||||
func (p *Puller) untilEdge() (time.Duration, bool) {
|
||||
w, _ := p.window()
|
||||
if w == nil {
|
||||
return 0, false
|
||||
}
|
||||
at := now()
|
||||
return w.NextEdge(at).Sub(at), true
|
||||
}
|
||||
|
||||
// sayCapacity logs c when it differs from what was said last, and writes it to the node's capacity key until a write
|
||||
// lands (a failed write is tried again at the next judgement, its error said once).
|
||||
func (p *Puller) sayCapacity(c delivery.Capacity) {
|
||||
p.mu.Lock()
|
||||
same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU &&
|
||||
p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours &&
|
||||
p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault
|
||||
if !same {
|
||||
p.judged, p.hasJudged, p.capWritten = c, true, false
|
||||
}
|
||||
write := !p.capWritten && p.PausePut != nil
|
||||
p.mu.Unlock()
|
||||
if !same {
|
||||
p.logf("the %s build capacity of %s is %s (configured from %s)", p.Language, p.Node, c.Why, c.ConfiguredFrom)
|
||||
}
|
||||
if !write {
|
||||
return
|
||||
}
|
||||
key, err := delivery.CapacityKey(p.Node)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.At = now()
|
||||
raw, err := delivery.EncodeCapacity(c)
|
||||
if err == nil {
|
||||
_, err = p.PausePut(key, json.RawMessage(raw))
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err == nil {
|
||||
p.capWritten, p.capFault = true, ""
|
||||
return
|
||||
}
|
||||
if err.Error() != p.capFault {
|
||||
p.capFault = err.Error()
|
||||
p.logf("the %s build capacity of %s could not be written to %s (tried again at the next judgement): %v", p.Language, p.Node, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream
|
||||
@@ -248,6 +372,7 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err)
|
||||
}
|
||||
go p.watchCapacity() // WatchConfigServed waits for Serve, which a module starts after its pull
|
||||
go p.watchHours()
|
||||
wait := p.PullWait
|
||||
if wait <= 0 {
|
||||
wait = DefaultPullWait
|
||||
@@ -260,16 +385,29 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
// Parked until something wakes it: a capacity change (the config watch), a resume, or a step's end. Every
|
||||
// park logs once, so a puller that sits idle says why (issue 1887).
|
||||
p.logf("the %s puller on %s parks: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
|
||||
var edge <-chan time.Time // the build window's next edge, when the node has one: one timer, not a poll
|
||||
stop := func() bool { return false }
|
||||
if d, ok := p.untilEdge(); ok {
|
||||
t := time.NewTimer(d + time.Second)
|
||||
edge, stop = t.C, t.Stop
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
return ctx.Err()
|
||||
case <-p.wake:
|
||||
case <-edge:
|
||||
}
|
||||
stop()
|
||||
_, capacity, inHand = p.roomNow()
|
||||
p.logf("the %s puller on %s wakes: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
|
||||
continue
|
||||
}
|
||||
msg, err := p.next(ctx, wait)
|
||||
pullWait := wait
|
||||
if d, ok := p.untilEdge(); ok && d < pullWait {
|
||||
pullWait = d // a pull does not wait past the window's close
|
||||
}
|
||||
msg, err := p.next(ctx, pullWait)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
@@ -395,6 +533,7 @@ func (p *Puller) take(ctx context.Context, msg *Pulled) {
|
||||
}
|
||||
p.running[ask.ID] = RunningAsk{ID: ask.ID, Kind: ask.Kind, Module: module, Started: step.Started}
|
||||
p.mu.Unlock()
|
||||
p.logf("the %s ask %s starts on %s: %s", ask.Kind, ask.ID, p.Node, step.What)
|
||||
go p.run(ctx, msg, ask, key, step)
|
||||
}
|
||||
|
||||
@@ -425,7 +564,11 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
}
|
||||
}
|
||||
}()
|
||||
result, err := protect(ctx, func(ctx context.Context) (any, error) { return p.Run(ctx, ask) })
|
||||
ph := &phases{put: p.Put, key: key, step: step, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
|
||||
result, err := protect(ctx, func(ctx context.Context) (any, error) {
|
||||
return p.Run(context.WithValue(ctx, phaseKey{}, ph), ask)
|
||||
})
|
||||
step = ph.end() // its end keeps the phase it ended in
|
||||
close(done)
|
||||
if ctx.Err() != nil {
|
||||
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
|
||||
@@ -438,6 +581,15 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
_ = msg.Nak()
|
||||
return
|
||||
}
|
||||
outcome := "passed"
|
||||
if err != nil {
|
||||
outcome = "did not pass: " + err.Error()
|
||||
if len(outcome) > 300 {
|
||||
outcome = outcome[:300] + "..."
|
||||
}
|
||||
}
|
||||
p.logf("the %s ask %s ended on %s after %s, in its phase %q; %s", ask.Kind, ask.ID, p.Node,
|
||||
now().Sub(step.Started).Round(time.Second), step.Phase, outcome)
|
||||
if aerr := msg.Ack(); aerr != nil {
|
||||
p.logf("the ask %s ended on %s and its acknowledgement failed: %v", ask.ID, p.Node, aerr)
|
||||
}
|
||||
@@ -474,6 +626,42 @@ func (p *Puller) watchCapacity() {
|
||||
}
|
||||
}
|
||||
|
||||
// watchHours follows the module's config value `build-hours` when WatchConfig and BuildHours are set, as watchCapacity
|
||||
// follows `capacity`: each value handed is kept, used in place of BuildHours, and wakes the puller. A value that cannot
|
||||
// be read is no window, said; a watch that cannot start leaves the puller on BuildHours, said once.
|
||||
func (p *Puller) watchHours() {
|
||||
if p.WatchConfig == nil || p.BuildHours == nil {
|
||||
return
|
||||
}
|
||||
err := p.WatchConfig(BuildHoursSetting, func(c stdio.ProvisionChange) error {
|
||||
setting := ""
|
||||
var w *BuildWindow
|
||||
var why string
|
||||
switch {
|
||||
case c.Refused != "":
|
||||
why = "its build hours are no longer answered: " + c.Refused
|
||||
case len(c.Value) > 0 && string(c.Value) != "null" && json.Unmarshal(c.Value, &setting) != nil:
|
||||
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 {
|
||||
why = err.Error()
|
||||
}
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.hours, p.hoursFault, p.hasHours = w, why, true
|
||||
p.mu.Unlock()
|
||||
if why != "" {
|
||||
p.logf("the %s puller on %s builds at any hour: %s", p.Language, p.Node, why)
|
||||
}
|
||||
p.Wake()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logf("the %s build hours of %s cannot be followed on the bus, so they are read from the settings file: %v", p.Language, p.Node, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WatchConfigServed is stdio.WatchConfig once Serve has the runtime's channel (stdio.Served): a build module starts its
|
||||
// pull before it serves, and a watch asked before that would be refused.
|
||||
func WatchConfigServed(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
|
||||
@@ -104,20 +104,38 @@ func TestPullTakesDeliveryBeforeCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1887/A2a, 1887/AC-pull: given capacity 2 and three asks waiting, two run and the third waits, and it starts when
|
||||
// one ends, with no timer.
|
||||
// one ends, with no timer. Issue 1806's follow-up: the 2 is the CPU's, a configured 3 bounded by a 12-core slice at 6
|
||||
// cores a step, said once in capacity.<node>; and issue 1890: a run's phase is written to its step key and logged with
|
||||
// its ask id, and its end keeps the last phase.
|
||||
func TestPullHoldsAtMostItsCapacity(t *testing.T) {
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
asks := []delivery.StepAsk{}
|
||||
for i := 0; i < 3; i++ {
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
a := testAsk(delivery.AskDelivery)
|
||||
asks = append(asks, a)
|
||||
_ = src.PublishAsk(a)
|
||||
}
|
||||
release := make(chan struct{})
|
||||
var mu sync.Mutex
|
||||
started := 0
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 2 },
|
||||
var said []string
|
||||
capacities := map[string][]byte{}
|
||||
quota := 1200
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 3 },
|
||||
CoresPerStep: func() float64 { return 6 },
|
||||
Quota: func() (int, string) { mu.Lock(); defer mu.Unlock(); return quota, "a test's slice" },
|
||||
PausePut: func(key string, v any) (uint64, error) {
|
||||
mu.Lock()
|
||||
capacities[key] = v.(json.RawMessage)
|
||||
mu.Unlock()
|
||||
return 1, nil
|
||||
},
|
||||
Logf: func(f string, a ...any) { mu.Lock(); said = append(said, fmt.Sprintf(f, a...)); mu.Unlock() },
|
||||
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
mu.Lock()
|
||||
started++
|
||||
mu.Unlock()
|
||||
Phase(ctx, "unit: tests of 3 packages")
|
||||
<-release
|
||||
return nil, nil
|
||||
}}
|
||||
@@ -129,12 +147,49 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) {
|
||||
if p.InHand() != 2 || src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(delivery.AskDelivery))
|
||||
}
|
||||
c := p.CapacityDetail()
|
||||
if c.Effective != 2 || c.Configured != 3 || c.ByCPU != 2 || c.QuotaCores != 12 || !strings.Contains(c.Why, "bounded by the CPU") {
|
||||
t.Fatalf("the capacity judged by = %+v, want 2 of a configured 3 bounded by 12 cores / 6", c)
|
||||
}
|
||||
mu.Lock()
|
||||
raw := capacities["capacity.shanks"]
|
||||
capLines := 0
|
||||
for _, l := range said {
|
||||
if strings.Contains(l, "build capacity of shanks is 2, bounded by the CPU") {
|
||||
capLines++
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if got, err := delivery.DecodeCapacity(raw); err != nil || got.Effective != 2 || capLines != 1 {
|
||||
t.Fatalf("capacity.shanks = %s (%v), said %d times; want effective 2, said once", raw, err, capLines)
|
||||
}
|
||||
waitFor(t, "a phase on the step key", func() bool {
|
||||
key, _ := delivery.AskStepKey("shanks", p.Running()[0].ID)
|
||||
s, _ := st.get(key)
|
||||
return s.State == delivery.StepRunning && s.Phase == "unit: tests of 3 packages" && !s.PhaseAt.IsZero()
|
||||
})
|
||||
release <- struct{}{}
|
||||
waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
|
||||
// Taken from the source comes a moment before its run starts: wait for the start, not only the take.
|
||||
waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 })
|
||||
close(release)
|
||||
waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
|
||||
for _, a := range asks {
|
||||
key, _ := delivery.AskStepKey("shanks", a.ID)
|
||||
if s, _ := st.get(key); s.State != delivery.StepPassed || s.Phase != "unit: tests of 3 packages" {
|
||||
t.Errorf("the end of %s = %+v, want passed in its last phase", a.ID, s)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
logged := strings.Join(said, "\n")
|
||||
quota = 600 // the cap lowered: one step at 6 cores, followed at the next judgement
|
||||
mu.Unlock()
|
||||
if !strings.Contains(logged, "the delivery ask "+asks[0].ID+" on shanks: unit: tests of 3 packages") {
|
||||
t.Errorf("no journal line names the ask's phase: %s", logged)
|
||||
}
|
||||
if c := p.CapacityDetail(); c.Effective != 1 {
|
||||
t.Errorf("after the quota fell to 6 cores, capacity %+v, want 1", c)
|
||||
}
|
||||
}
|
||||
|
||||
// An ask that is not one, or not this seat's, is dropped and runs nowhere. And issue 890/AC1: a check ask needing
|
||||
|
||||
@@ -14,10 +14,13 @@ func SeatTools(seat, what string, p *Puller) []stdio.Tool {
|
||||
Run: func(map[string]any) (any, error) { return run() }}
|
||||
}
|
||||
current := func() (any, error) {
|
||||
return map[string]any{"node": p.Node, "capacity": p.CapacityNow(), "running": p.Running(), "paused": p.Paused()}, nil
|
||||
c := p.CapacityDetail()
|
||||
return map[string]any{"node": p.Node, "capacity": c.Effective, "capacity_why": c.Why, "capacity_detail": c,
|
||||
"running": p.Running(), "paused": p.Paused()}, nil
|
||||
}
|
||||
return []stdio.Tool{
|
||||
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity, "+
|
||||
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity (the configured one, "+
|
||||
"bounded by the cores its node's build slice gives over the cores a step needs) and why, "+
|
||||
"and whether it is paused.", current),
|
||||
verb("pause", "Take no new "+what+" ask on this node until resumed; an ask running here ends in its place. Kept "+
|
||||
"across a restart (the node's pause key).", func() (any, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -14,10 +15,108 @@ import (
|
||||
// byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own
|
||||
// internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the
|
||||
// controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller,
|
||||
// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13).
|
||||
// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy
|
||||
// gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and
|
||||
// only those more.
|
||||
// createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it.
|
||||
var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `)
|
||||
|
||||
// beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3,
|
||||
// issue 1126), by user: a node's account reads every node's own facts, `<node>.node` of the have bucket where the node's
|
||||
// record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the
|
||||
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
|
||||
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
|
||||
// (issue 1790) is the other row beyond, matched by createdAdvisory.
|
||||
func beyondTheControllers(user string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
node, ok := strings.CutPrefix(user, "node.")
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for _, s := range append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...) {
|
||||
out[s] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
|
||||
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
|
||||
func mayBeyondTheControllers(user string) map[string]bool {
|
||||
if _, ok := strings.CutPrefix(user, "node."); !ok {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{VaultOwnCall(Principal{Kind: KindNode, Node: strings.TrimPrefix(user, "node.")}): true,
|
||||
VaultSecretChanged: true}
|
||||
}
|
||||
|
||||
// quotedToken is a quoted token of a composed list.
|
||||
var quotedToken = regexp.MustCompile(`"[^"]*"`)
|
||||
|
||||
// grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its
|
||||
// password's hash, every subject it may publish or subscribe to), by user.
|
||||
func grantsByUser(list string) (string, map[string]map[string]bool) {
|
||||
var outside strings.Builder
|
||||
users := map[string]map[string]bool{}
|
||||
user := ""
|
||||
for _, line := range strings.SplitAfter(list, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok {
|
||||
user, _, _ = strings.Cut(name, `"`)
|
||||
users[user] = map[string]bool{}
|
||||
} else if user != "" && trimmed == "]" {
|
||||
user = ""
|
||||
}
|
||||
if user == "" {
|
||||
outside.WriteString(line)
|
||||
continue
|
||||
}
|
||||
for _, q := range quotedToken.FindAllString(line, -1) {
|
||||
users[user][strings.Trim(q, `"`)] = true
|
||||
}
|
||||
}
|
||||
return outside.String(), users
|
||||
}
|
||||
|
||||
// grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it
|
||||
// names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and
|
||||
// the copy's beyond them are exactly those, every one of them there.
|
||||
func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
gotOutside, gotUsers := grantsByUser(got)
|
||||
wantOutside, wantUsers := grantsByUser(want)
|
||||
if gotOutside != wantOutside {
|
||||
t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside)
|
||||
}
|
||||
for user, was := range wantUsers {
|
||||
now, ok := gotUsers[user]
|
||||
if !ok {
|
||||
t.Errorf("the copy has no user %s", user)
|
||||
continue
|
||||
}
|
||||
intended := beyondTheControllers(user)
|
||||
for g := range was {
|
||||
if !now[g] {
|
||||
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
||||
}
|
||||
}
|
||||
for g := range now {
|
||||
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) {
|
||||
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
|
||||
}
|
||||
}
|
||||
for g := range intended {
|
||||
if !now[g] {
|
||||
t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
for user := range gotUsers {
|
||||
if _, ok := wantUsers[user]; !ok {
|
||||
t.Errorf("the copy has a user %s the controller's has not", user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
|
||||
if err != nil {
|
||||
@@ -56,10 +155,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
// the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state
|
||||
// missing at its start is bound when it appears. The controller's composer is frozen and its list no longer
|
||||
// loaded; while it is not, this row is left out of the comparison and nothing else.
|
||||
list = createdAdvisory.ReplaceAllString(list, "")
|
||||
if list != want[i].List {
|
||||
t.Errorf("the list differs from the controller's:\n%s", list)
|
||||
}
|
||||
grantsAsTheControllers(t, list, want[i].List)
|
||||
// And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the
|
||||
// grants part publishes them.
|
||||
raw, err := json.Marshal(MembershipsFor(f))
|
||||
@@ -109,6 +205,25 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS")
|
||||
}
|
||||
})
|
||||
// Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no
|
||||
// other node's.
|
||||
t.Run("a node asks the vault its own secrets", func(t *testing.T) {
|
||||
one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true}
|
||||
perms, err := PermissionsFor(one)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) {
|
||||
t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)),
|
||||
MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})))
|
||||
}
|
||||
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) {
|
||||
t.Error("a node asks own while the vault does not serve it")
|
||||
}
|
||||
if !slices.Contains(perms.Subscribe, VaultSecretChanged) {
|
||||
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
|
||||
}
|
||||
})
|
||||
t.Run("the controller's golden server configuration", func(t *testing.T) {
|
||||
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
||||
got, err := Compose(
|
||||
@@ -135,8 +250,6 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != string(want) {
|
||||
t.Errorf("the configuration differs from the controller's golden:\n%s", got)
|
||||
}
|
||||
grantsAsTheControllers(t, got, string(want))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +213,10 @@ type Principal struct {
|
||||
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
|
||||
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
|
||||
StatesItsKeyToTheVault bool
|
||||
// AsksItsOwnSecrets is a machine principal whose node-engine may ask the vault's `own` in its own name (novox/hq
|
||||
// ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes
|
||||
// itself. Set while the vault's holder serves it.
|
||||
AsksItsOwnSecrets bool
|
||||
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
|
||||
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
|
||||
FollowsGrantsUserList bool
|
||||
@@ -344,6 +348,10 @@ var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"}
|
||||
// novox/hq ADR 0372); granted as Principal.StatesItsKeyToTheVault says.
|
||||
var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"}
|
||||
|
||||
// VaultOwn is the vault's verb a machine's node-engine asks in its own name for its modules' own secrets sealed to its
|
||||
// key (issue 1126, ADR 0550 §5); granted as Principal.AsksItsOwnSecrets says.
|
||||
var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
||||
@@ -882,6 +890,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, haveKeys...)
|
||||
// And every node's own facts, `<node>.node`, its record among them (issue 1126, ADR 0555 §3): a node agent composing
|
||||
// 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
|
||||
// its own node alone. Read only.
|
||||
pub = append(pub, NodeComposeReads(p.Node)...)
|
||||
// And an object of the declaration bucket by its name, and nothing of the bucket's list or writing
|
||||
// (novox/hq ADR 0486 §2, issue 1697).
|
||||
fileReads, err := DeclarationFilesReads(p.Node)
|
||||
@@ -915,6 +931,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if p.StatesItsKeyToTheVault {
|
||||
pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
// And, while the vault serves it, its modules' own secrets in its own name (issue 1126): sealed to its key, for the
|
||||
// compositions it makes itself.
|
||||
if p.AsksItsOwnSecrets {
|
||||
pub = append(pub, VaultOwnCall(p))
|
||||
// And the vault's word that a secret changed (names and generations, never a value), so the node composes again
|
||||
// on it rather than at the next unrelated change (ADR 0547 §2).
|
||||
sub = append(sub, VaultSecretChanged)
|
||||
}
|
||||
// And, on the node holding mesh-broker, the grants part's sealed user list it follows from take-over (novox/hq ADR
|
||||
// 0547 clause 7): one key, and an object by its name.
|
||||
if p.FollowsGrantsUserList {
|
||||
@@ -1784,3 +1808,12 @@ const (
|
||||
nodeToolsService = "node-tools"
|
||||
leaseKey = "holder"
|
||||
)
|
||||
|
||||
// VaultOwnCall is the subject a machine principal's node-engine asks the vault's `own` on, in its own name.
|
||||
func VaultOwnCall(p Principal) string {
|
||||
return "mesh.seat." + VaultOwn.Seat + "." + CallKind + "." + VaultOwn.Verb + "." + CallerToken(p.Username())
|
||||
}
|
||||
|
||||
// VaultSecretChanged is the vault's event that a secret changed (a name and its generation, never a value), which a
|
||||
// node composing its own hears to compose again (issue 1126, ADR 0547 §2).
|
||||
var VaultSecretChanged = "mesh.mod." + VaultOwn.Seat + ".event.secret-changed"
|
||||
|
||||
@@ -3,6 +3,7 @@ package buscompose
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -306,3 +307,18 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err
|
||||
sort.Strings(undeclared)
|
||||
return undeclared, nil
|
||||
}
|
||||
|
||||
// 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, modules and manifests and of the builder's builds, and of mesh-config's
|
||||
// resolved values only its own node's, `<node>.>`. 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.ModulesBucket, delivery.ManifestsBucket, delivery.BuilderBuildsBucket} {
|
||||
out = append(out, readGrants(bucket, "")...)
|
||||
}
|
||||
return append(out, readGrants(ResolvedBucket, node+".>")...)
|
||||
}
|
||||
|
||||
@@ -159,6 +159,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn),
|
||||
FollowsGrantsUserList: bus})
|
||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||
// per machine that runs it, delivered to the module running it.
|
||||
|
||||
@@ -75,6 +75,19 @@ func deliveryVerbs() []Verb {
|
||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
||||
[]string{"id", "why"})},
|
||||
deliveryRemovedInR6("retire-history"),
|
||||
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
|
||||
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
|
||||
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
|
||||
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
|
||||
"reason, naming why.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
||||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
||||
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
|
||||
}},
|
||||
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
|
||||
Optional: true},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,16 @@ var defaultSeats = append([]Seat{
|
||||
"first or changed key is recorded and the machine's core secrets are said changed; the same key " +
|
||||
"again changes nothing.",
|
||||
Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)},
|
||||
// A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547
|
||||
// §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says.
|
||||
{Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " +
|
||||
"alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " +
|
||||
"credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " +
|
||||
"credential the vault does not hold is named missing; never a value in the clear.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{
|
||||
"type": "array", "items": map[string]any{"type": "object"},
|
||||
"description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " +
|
||||
"provision, consumer, consumer_module, provider, local}"}}}},
|
||||
{Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " +
|
||||
"into the vault's, as they are and once, from the controller alone. No value is in any of them.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{
|
||||
|
||||
@@ -121,6 +121,8 @@ func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
||||
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
|
||||
"generation", "made_at", "call", "sealed", "read_back_to"},
|
||||
"move-asks": {"asks"},
|
||||
// And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key.
|
||||
"own": {"credentials"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
@@ -148,7 +150,7 @@ func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "move-asks"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "node-key", "move-asks", "own"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving the move was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -126,6 +126,13 @@ var ControllerVerbs = []Verb{
|
||||
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
|
||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"}),
|
||||
Replaces: []string{"hostnamectl", "uptime"}},
|
||||
{Name: "placement-export", Description: "Each machine's placement as the catalogue's placement file says it (novox/hq ADR 0555 " +
|
||||
"§6, issue 1126): what a person decided of the machine (endpoint, site, hub, address on the private network, public " +
|
||||
"domain, accounts, adoption, the tunnel found at adoption) and where its modules run (in order, with host ports, " +
|
||||
"bindings, config placement layers, pins and holdings), read by the controller's own code from its own store, as JSON. " +
|
||||
"Read-only; the one verb the controller gains for its exit (the operator, 2026-10-26 19:06:40), read once by the seed " +
|
||||
"of the placement files.",
|
||||
Input: schema(map[string]string{"node": "one machine's name; every machine when absent"}, nil)},
|
||||
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
|
||||
"and which machines run it.",
|
||||
Input: schema(nil, nil)},
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestTheDeliverySeatHasNoPlanVerbs(t *testing.T) {
|
||||
if _, ok := rows["mark-ready"]; ok {
|
||||
t.Errorf("mark-ready is still promised")
|
||||
}
|
||||
for _, kept := range []string{"deliveries", "show", "groups", "checks", "table", "recheck", "close", "stalled"} {
|
||||
for _, kept := range []string{"deliveries", "show", "groups", "checks", "table", "recheck", "close", "stalled", "settle"} {
|
||||
if v, ok := rows[kept]; !ok || strings.HasPrefix(v.Description, "Removed") {
|
||||
t.Errorf("%s is not kept (ADR 0539 clause 17)", kept)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -16,6 +18,26 @@ import (
|
||||
// (mesh-hand-compose) gets that one. Each module's Data says whether its manifest declares data. The compositions are not
|
||||
// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest).
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
return compositions(in, node, fps, mode, false)
|
||||
}
|
||||
|
||||
// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's
|
||||
// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make,
|
||||
// never what may be applied.
|
||||
var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry")
|
||||
|
||||
// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list.
|
||||
const BusUsersStandIn = "the bus's user list"
|
||||
|
||||
// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue
|
||||
// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with
|
||||
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
|
||||
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
|
||||
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
return compositions(in, node, fps, Reading, true)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
found := false
|
||||
@@ -35,6 +57,24 @@ func Compositions(in Input, node string, fps map[string]string, mode Mode) (out
|
||||
if len(problems) > 0 {
|
||||
return nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
}
|
||||
if applied {
|
||||
var stood []string
|
||||
for _, x := range d.StoodIn {
|
||||
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
|
||||
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
|
||||
if x != BusUsersStandIn {
|
||||
stood = append(stood, x)
|
||||
}
|
||||
}
|
||||
stood = append(stood, d.Foreseen...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
stood = append(stood, "port "+p)
|
||||
}
|
||||
if len(stood) > 0 {
|
||||
sort.Strings(stood)
|
||||
return nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
|
||||
}
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the
|
||||
// provider's node, and the local name where a module keeps several (Credential's and Issued's key).
|
||||
type CredentialKey struct {
|
||||
Provision string `json:"provision"`
|
||||
Consumer string `json:"consumer"`
|
||||
ConsumerModule string `json:"consumer_module"`
|
||||
Provider string `json:"provider"`
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126):
|
||||
// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's,
|
||||
// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the
|
||||
// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the
|
||||
// node's own apply refuses (CompositionsApplied).
|
||||
func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.resolution(node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, n := range resolved.Needs {
|
||||
if n.ByRecord || n.From == "" {
|
||||
continue
|
||||
}
|
||||
asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For,
|
||||
Provider: n.From, Local: n.Local})
|
||||
}
|
||||
rows, err := c.rowsFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer,
|
||||
ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local})
|
||||
}
|
||||
sortKeys(asConsumer)
|
||||
sortKeys(asProvider)
|
||||
return asConsumer, asProvider, nil
|
||||
}
|
||||
|
||||
func sortKeys(ks []CredentialKey) {
|
||||
sort.Slice(ks, func(i, j int) bool {
|
||||
a, b := ks[i], ks[j]
|
||||
switch {
|
||||
case a.Provision != b.Provision:
|
||||
return a.Provision < b.Provision
|
||||
case a.Provider != b.Provider:
|
||||
return a.Provider < b.Provider
|
||||
case a.Consumer != b.Consumer:
|
||||
return a.Consumer < b.Consumer
|
||||
case a.ConsumerModule != b.ConsumerModule:
|
||||
return a.ConsumerModule < b.ConsumerModule
|
||||
}
|
||||
return a.Local < b.Local
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -78,6 +79,16 @@ func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T)
|
||||
t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
|
||||
|
||||
// 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the
|
||||
// Input is refused, naming it; held with its value, it composes.
|
||||
fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)}
|
||||
if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") {
|
||||
t.Errorf("a stand-in was not refused for a node's own apply: %v", err)
|
||||
}
|
||||
if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 {
|
||||
t.Errorf("a held secret with its value is refused for a node's own apply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or
|
||||
@@ -161,6 +172,15 @@ func aGrantMesh(t *testing.T) Input {
|
||||
// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor).
|
||||
func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
// 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's
|
||||
// s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these.
|
||||
pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"}
|
||||
if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair {
|
||||
t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair)
|
||||
}
|
||||
if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair {
|
||||
t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair)
|
||||
}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 {
|
||||
t.Fatalf("grants are %+v", got.Rendering.Grants)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"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.
|
||||
// What the machine observes (its record) is not in it.
|
||||
func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
var n *compose.Node
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == node {
|
||||
n = &in.Nodes[i]
|
||||
}
|
||||
}
|
||||
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.
|
||||
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,
|
||||
AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
|
||||
// The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
|
||||
// one whether or not the hub took it; and the names a person gave the hub's carried peers.
|
||||
if t, ok := in.Tunnels[node]; ok {
|
||||
rt := recordTunnelOf(t.Tunnel)
|
||||
p.Decided.Tunnel = &rt
|
||||
} else if in.Adopted != nil && in.Adopted.Hub == node {
|
||||
rt := recordTunnelOf(in.Adopted.Tunnel)
|
||||
p.Decided.Tunnel = &rt
|
||||
}
|
||||
if n.Place.Hub {
|
||||
for _, c := range in.CarriedPeers {
|
||||
if c.Named != "" {
|
||||
if p.Decided.PeerNames == nil {
|
||||
p.Decided.PeerNames = map[string]string{}
|
||||
}
|
||||
p.Decided.PeerNames[c.PublicKey] = c.Named
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, module := range n.Assigned {
|
||||
m := delivery.PlacedModule{Module: module}
|
||||
for port, at := range n.Ports[module] {
|
||||
if m.Ports == nil {
|
||||
m.Ports = map[string]int{}
|
||||
}
|
||||
m.Ports[strconv.Itoa(port)] = at
|
||||
}
|
||||
for provision, to := range n.Bound[module] {
|
||||
if m.Bindings == nil {
|
||||
m.Bindings = map[string]delivery.Provider{}
|
||||
}
|
||||
m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
// A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
|
||||
// the placement. A module in the config form has mesh-config's values laid into its layer, which are not.
|
||||
settingsForm := !in.Catalogue[module].DeclaresConfig()
|
||||
for _, layer := range n.Settings[module] {
|
||||
if layer.From == catalogue.MeshWideLayer {
|
||||
m.HasMesh, m.MeshWide = true, layer.Placement
|
||||
if settingsForm && len(layer.Values) > 0 {
|
||||
m.MeshValues = layer.Values
|
||||
}
|
||||
} else {
|
||||
m.HasLayer, m.Layer = true, layer.Placement
|
||||
if settingsForm && len(layer.Values) > 0 {
|
||||
m.Values = layer.Values
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Modules = append(p.Modules, m)
|
||||
}
|
||||
for provision, to := range n.Pins {
|
||||
if p.Pins == nil {
|
||||
p.Pins = map[string]delivery.Provider{}
|
||||
}
|
||||
p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
for _, h := range in.Holdings {
|
||||
if h.Node == node {
|
||||
p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(p.Holds, func(i, j int) bool {
|
||||
if p.Holds[i].Claim != p.Holds[j].Claim {
|
||||
return p.Holds[i].Claim < p.Holds[j].Claim
|
||||
}
|
||||
return p.Holds[i].Module < p.Holds[j].Module
|
||||
})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel {
|
||||
out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, peer := range t.Peers {
|
||||
out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check
|
||||
// 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
|
||||
// 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);
|
||||
// - 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
|
||||
// node's own have key, `<node>.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided,
|
||||
// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both;
|
||||
// - every module's manifest: the indexer's `modules.<module>` and its manifests by digest, or the digest the
|
||||
// composing node's want names where it runs another build (Scope.Kept);
|
||||
// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the
|
||||
// caller composes every node (ADR 0547 §2, the prepare step's L4).
|
||||
//
|
||||
// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials),
|
||||
// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet
|
||||
// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one
|
||||
// the composition stands in for it, and Compose refuses a composition that stands in for anything (compose.ErrStoodIn):
|
||||
// a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is
|
||||
// always empty.
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a
|
||||
// read that failed, never an absent key (an absent key is an empty answer).
|
||||
type Reader interface {
|
||||
// Placements is every node's placement, by node (delivery.PlacementBucket).
|
||||
Placements(ctx context.Context) (map[string]delivery.Placement, error)
|
||||
// Records is every node's record, by node: HaveNode.Record of each node's own have key, `<node>.node` of
|
||||
// delivery.HaveBucket (ADR 0555 §3).
|
||||
Records(ctx context.Context) (map[string]delivery.NodeRecord, error)
|
||||
// Modules is every module the indexer holds, by module (delivery.ModulesBucket).
|
||||
Modules(ctx context.Context) (map[string]delivery.Module, error)
|
||||
// Manifest is the raw manifest of a digest (delivery.ManifestsBucket).
|
||||
Manifest(ctx context.Context, digest string) ([]byte, error)
|
||||
// Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`,
|
||||
// keys `<node>.<module>.<key>`), decoded; nil when it has none.
|
||||
Values(ctx context.Context, node, module string) (map[string]any, error)
|
||||
// OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers
|
||||
// the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself.
|
||||
OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error)
|
||||
// Credentials is, for the composing node, each pair credential it reads as a consumer, held and sealed to its key,
|
||||
// and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key
|
||||
// the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it.
|
||||
Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error)
|
||||
}
|
||||
|
||||
// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the
|
||||
// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take
|
||||
// compose's defaults.
|
||||
type Mesh struct {
|
||||
OverlayCIDR string
|
||||
InternalSuffix string
|
||||
Authority string
|
||||
// Predefined is the list of predefined keys mesh-config published, which a manifest's values falling back to a
|
||||
// predefined key are judged against; nil reads each manifest as the indexer does (catalogue.ParseManifestForInputs).
|
||||
Predefined catalogue.PredefinedList
|
||||
}
|
||||
|
||||
// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the
|
||||
// composition check sets it to compose every node. Kept is, by module, the manifest digest the composing node's wants
|
||||
// name where it differs from the indexer's trunk: that node composes the module as it runs it.
|
||||
type Scope struct {
|
||||
Node string
|
||||
Every bool
|
||||
Kept map[string]string
|
||||
}
|
||||
|
||||
// Input is the composition input for s, read through r.
|
||||
func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) {
|
||||
placements, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
|
||||
}
|
||||
modules, err := r.Modules(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the indexer's modules: %w", err)
|
||||
}
|
||||
if _, ok := placements[s.Node]; !ok && !s.Every {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
}
|
||||
|
||||
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
|
||||
Catalogue: map[string]catalogue.Manifest{}}
|
||||
if in.Authority == "" {
|
||||
// The mesh's own certificate, public, as the composing node holds it; for the check of every node, as the first
|
||||
// node holding one does.
|
||||
if rec, ok := records[s.Node]; ok && rec.Authority != "" {
|
||||
in.Authority = rec.Authority
|
||||
} else {
|
||||
for _, node := range sortedKeys(records) {
|
||||
if a := records[node].Authority; a != "" {
|
||||
in.Authority = a
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
|
||||
}
|
||||
if mesh.Predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
for _, name := range sortedKeys(modules) {
|
||||
m := modules[name]
|
||||
if m.Manifest == "" {
|
||||
continue // a registered module the indexer has not read: nothing to compose it from
|
||||
}
|
||||
man, err := read(name, m.Manifest)
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
in.Catalogue[name] = man
|
||||
}
|
||||
|
||||
// The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes
|
||||
// with mesh-sdk and always wins, as the controller's providedModules and the grants part have it.
|
||||
provided, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
var private catalogue.Manifest
|
||||
if err := json.Unmarshal(provided, &private); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the private network's manifest mesh-sdk provides: %w", err)
|
||||
}
|
||||
in.Catalogue[overlay.Name] = private
|
||||
|
||||
var missing []string
|
||||
for _, node := range nodesOf(placements, records) {
|
||||
rec, hasRecord := records[node]
|
||||
p := placements[node]
|
||||
if !hasRecord {
|
||||
if len(p.Modules) > 0 {
|
||||
missing = append(missing, node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
n := nodeOf(rec, p)
|
||||
withValues := s.Every || node == s.Node
|
||||
for _, m := range p.Modules {
|
||||
var values map[string]any
|
||||
if withValues {
|
||||
if values, err = r.Values(ctx, node, m.Module); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
}
|
||||
}
|
||||
if layers := m.Layers(node, values); layers != nil {
|
||||
if n.Settings == nil {
|
||||
n.Settings = map[string][]catalogue.Layer{}
|
||||
}
|
||||
n.Settings[m.Module] = layers
|
||||
}
|
||||
}
|
||||
if node == s.Node {
|
||||
for _, module := range sortedKeys(s.Kept) {
|
||||
man, err := read(module, s.Kept[module])
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
if n.Kept == nil {
|
||||
n.Kept = map[string]catalogue.Manifest{}
|
||||
}
|
||||
n.Kept[module] = man
|
||||
}
|
||||
}
|
||||
in.Nodes = append(in.Nodes, n)
|
||||
in.Holdings = append(in.Holdings, p.Held()...)
|
||||
// A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the
|
||||
// controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over
|
||||
// (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers).
|
||||
if t := p.Decided.Tunnel; t != nil {
|
||||
if rec.TunnelKey == t.PublicKey {
|
||||
if in.Tunnels == nil {
|
||||
in.Tunnels = map[string]compose.FoundTunnel{}
|
||||
}
|
||||
in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*t), NodeAdopted: p.Decided.Adopted}
|
||||
}
|
||||
if p.Decided.Hub {
|
||||
in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*t), Hub: node, Adopted: rec.TunnelKey == t.PublicKey}
|
||||
}
|
||||
}
|
||||
}
|
||||
if in.Adopted != nil && in.Adopted.Adopted {
|
||||
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return compose.Input{}, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
if !s.Every {
|
||||
if _, ok := records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||
// Asked only when a module placed on it declares one: a node none of whose modules has a secret composes
|
||||
// whether or not the vault answers.
|
||||
if declaresOwnSecrets(in, s.Node) {
|
||||
if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err)
|
||||
}
|
||||
}
|
||||
// The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which
|
||||
// the composition reads for a core module as its kept boxes.
|
||||
for _, o := range in.OwnSecrets {
|
||||
if json.Valid([]byte(o.Sealed)) {
|
||||
in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: o.Node, Module: o.Module, Name: o.Name,
|
||||
SealedTo: o.SealedTo, Box: json.RawMessage(o.Sealed)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Compose is the node's compositions over the bus (ADR 0547 §2): Input for s, then compose.CompositionsApplied at the
|
||||
// fingerprints fps gives each module: composed in the Reading mode as the controller composes them, and refused when it
|
||||
// stands in for anything the input does not carry (compose.ErrStoodIn), since the node applies what it composed.
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5).
|
||||
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(asConsumer)+len(asProvider) > 0 {
|
||||
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
|
||||
return nil, nil, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)
|
||||
}
|
||||
}
|
||||
return compose.CompositionsApplied(in, s.Node, fps)
|
||||
}
|
||||
|
||||
// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from.
|
||||
func declaresOwnSecrets(in compose.Input, node string) bool {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name != node {
|
||||
continue
|
||||
}
|
||||
for _, module := range n.Assigned {
|
||||
m, ok := n.Kept[module]
|
||||
if !ok {
|
||||
m = in.Catalogue[module]
|
||||
}
|
||||
if len(m.OwnSecrets) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
|
||||
// decided of it, and where its modules run).
|
||||
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
d := p.Decided
|
||||
n := compose.Node{
|
||||
Name: rec.Node,
|
||||
Place: compose.Place{Key: rec.TunnelKey, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address},
|
||||
Assigned: p.Assigned(), PublicDomain: d.PublicDomain,
|
||||
Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentAccountHome: d.AgentHome,
|
||||
Adopted: d.Adopted, Taken: d.Taken, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey,
|
||||
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
|
||||
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
|
||||
}
|
||||
if len(rec.Capabilities) > 0 {
|
||||
n.Capabilities = make(map[string]bool, len(rec.Capabilities))
|
||||
for _, c := range rec.Capabilities {
|
||||
n.Capabilities[c] = true
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
|
||||
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, p := range t.Peers {
|
||||
out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key
|
||||
// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers).
|
||||
func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer {
|
||||
byKey := map[string]string{}
|
||||
for node, rec := range records {
|
||||
if rec.TunnelKey != "" {
|
||||
byKey[rec.TunnelKey] = node
|
||||
}
|
||||
}
|
||||
var out []compose.CarriedPeer
|
||||
for _, p := range t.Peers {
|
||||
address, _, _ := strings.Cut(p.Address, "/")
|
||||
out = append(out, compose.CarriedPeer{PublicKey: p.PublicKey, Address: address, EnrolledAs: byKey[p.PublicKey], Named: names[p.PublicKey]})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
a, errA := netip.ParseAddr(out[i].Address)
|
||||
b, errB := netip.ParseAddr(out[j].Address)
|
||||
if errA != nil || errB != nil {
|
||||
return out[i].Address < out[j].Address
|
||||
}
|
||||
return a.Less(b)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in.
|
||||
func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string {
|
||||
seen := map[string]bool{}
|
||||
for n := range placements {
|
||||
seen[n] = true
|
||||
}
|
||||
for n := range records {
|
||||
seen[n] = true
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// bus is a Reader over values held in memory, counting whose config values were read.
|
||||
type bus struct {
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
manifests map[string][]byte
|
||||
values map[string]map[string]any // by node.module
|
||||
valuesRead []string
|
||||
own []compose.OwnSecretRow
|
||||
ownErr error
|
||||
}
|
||||
|
||||
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
return b.placements, nil
|
||||
}
|
||||
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
|
||||
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
|
||||
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.manifests[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("no such manifest")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
|
||||
return b.own, b.ownErr
|
||||
}
|
||||
|
||||
func (b *bus) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
b.valuesRead = append(b.valuesRead, node+"."+module)
|
||||
return b.values[node+"."+module], nil
|
||||
}
|
||||
|
||||
func publicKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the
|
||||
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
|
||||
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
|
||||
func TestInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := delivery.ManifestDigest(xterm)
|
||||
// A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
|
||||
// layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it.
|
||||
media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
|
||||
`"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
|
||||
`"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
|
||||
mediaDigest := delivery.ManifestDigest(media)
|
||||
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
|
||||
caps := []string{"container-runtime", "systemd", "wireguard"}
|
||||
tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
mk := func() *bus {
|
||||
return &bus{
|
||||
placements: map[string]delivery.Placement{
|
||||
"anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
|
||||
Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
|
||||
Modules: []delivery.PlacedModule{{Module: overlay.Name}},
|
||||
Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
|
||||
"laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
|
||||
Modules: []delivery.PlacedModule{{Module: overlay.Name},
|
||||
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}},
|
||||
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
|
||||
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
|
||||
},
|
||||
records: map[string]delivery.NodeRecord{
|
||||
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
|
||||
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
|
||||
},
|
||||
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
|
||||
manifests: map[string][]byte{digest: xterm, mediaDigest: media},
|
||||
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
|
||||
"laptop.media": {"lines": "not mesh-config's"}},
|
||||
own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
|
||||
}
|
||||
}
|
||||
|
||||
// What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it.
|
||||
var private catalogue.Manifest
|
||||
raw, _ := json.Marshal(overlay.Manifest())
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := catalogue.ParseManifestForInputs(xterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedMedia, err := catalogue.ParseManifestForInputs(media)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true}
|
||||
tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
want := compose.Input{
|
||||
Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia},
|
||||
Nodes: []compose.Node{
|
||||
{Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true,
|
||||
Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true},
|
||||
{Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here",
|
||||
Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet,
|
||||
Ports: map[string]map[int]int{"xterm": {80: 8080}},
|
||||
Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}},
|
||||
"media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}},
|
||||
{From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}},
|
||||
},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}},
|
||||
OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
|
||||
// The same value as a core credential's box, for a core module (ADR 0349 §16).
|
||||
KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}},
|
||||
Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}},
|
||||
Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true},
|
||||
// The controller's order is by address; laptop enrolled under its peer's key, and a person named the other.
|
||||
Authority: "the mesh's certificate",
|
||||
CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"},
|
||||
{PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}},
|
||||
}
|
||||
|
||||
b := mk()
|
||||
got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.MarshalIndent(got, "", " ")
|
||||
wj, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
|
||||
}
|
||||
|
||||
// The same composer over both: the compositions from the bus are the store's, byte for byte.
|
||||
fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64),
|
||||
"media": "src2:" + strings.Repeat("c", 64)}
|
||||
fromBus, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) {
|
||||
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
|
||||
}
|
||||
|
||||
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
|
||||
// placement the bus holds, and read back it is the store's input again.
|
||||
for node, placed := range mk().placements {
|
||||
got, err := Export(want, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) {
|
||||
pj, _ := delivery.EncodePlacement(placed)
|
||||
t.Errorf("%s exported as %s, want %s", node, gj, pj)
|
||||
}
|
||||
}
|
||||
if _, err := Export(want, "stranger"); err == nil {
|
||||
t.Error("a node the input lacks was exported")
|
||||
}
|
||||
|
||||
b = mk()
|
||||
if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil {
|
||||
t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
// Every node's placed modules, and nothing placed nowhere: anchor places no xterm.
|
||||
t.Errorf("the check read %v", b.valuesRead)
|
||||
}
|
||||
|
||||
// A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node
|
||||
// with no secret composed nothing while the vault did not answer); one whose module does, refuses without it.
|
||||
quiet := mk()
|
||||
quiet.ownErr = errors.New("no responders")
|
||||
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil {
|
||||
t.Errorf("anchor, with no own secret, needed the vault: %v", err)
|
||||
}
|
||||
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil {
|
||||
t.Error("laptop's media, which declares an own secret, composed without the vault")
|
||||
}
|
||||
|
||||
kept := mk()
|
||||
kept.manifests["old"] = xterm
|
||||
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "old"}})
|
||||
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": parsed}) || in.Nodes[0].Kept != nil {
|
||||
t.Errorf("the want's manifest is kept on the composing node alone: %v, %v", in.Nodes, err)
|
||||
}
|
||||
|
||||
for name, c := range map[string]struct {
|
||||
change func(*bus)
|
||||
scope Scope
|
||||
says string
|
||||
}{
|
||||
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
|
||||
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
|
||||
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
|
||||
"a kept one unread": {func(b *bus) {}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "gone"}}, "digest gone"},
|
||||
} {
|
||||
b := mk()
|
||||
c.change(b)
|
||||
if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
r.BusUsers = c.in.BusUsers
|
||||
if r.BusUsers == "" {
|
||||
r.BusUsers = ForeseenBusUsers
|
||||
foreseen("the bus's user list")
|
||||
foreseen(BusUsersStandIn)
|
||||
}
|
||||
if len(c.in.BusMissing) > 0 {
|
||||
out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+
|
||||
|
||||
+43
-10
@@ -166,8 +166,9 @@ func checkFingerprint(fp, of string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Want is a node's want for a module: the fingerprint FP the node should run and the digest Composed of the one
|
||||
// composition of it the node may apply (ADR 0539 clause 1), or Removed for a module that left the node's modules (then
|
||||
// Want is a node's want for a module: the fingerprint FP the node should run and, before ADR 0547's second milestone,
|
||||
// the digest Composed of the one composition of it the node may apply (ADR 0539 clause 1; from that milestone the
|
||||
// node composes its own, ADR 0547 §2, and a want names none), or Removed for a module that left the node's modules (then
|
||||
// both are empty). When the module's newest fingerprint is not yet allowed on this node, Newest names it and Rule the
|
||||
// rule that holds the node back; both are empty when FP is the newest. Backup is the answer to a failed backup before
|
||||
// the apply, BackupAgain or BackupSkip (clause 16), empty when none was asked. Words say why in a sentence. The
|
||||
@@ -185,11 +186,18 @@ type Want struct {
|
||||
// is released. A put's want names no newest fingerprint or rule.
|
||||
Put bool `json:"put,omitempty"`
|
||||
Hold bool `json:"hold,omitempty"`
|
||||
// Yes is the digest of a composition a person allowed the node to move to (novox/hq ADR 0547 §3): a move of a module
|
||||
// of policy record that the node agent judged to recreate a container or move its data is applied only when its
|
||||
// want names the composition so allowed. Empty when none was asked.
|
||||
Yes string `json:"yes,omitempty"`
|
||||
// Self says the node composes this module itself (ADR 0547 §2): the want names the build alone, and no composition.
|
||||
// A want that is not Self names the one composition it allows (ADR 0539 clause 1), as before.
|
||||
Self bool `json:"self,omitempty"`
|
||||
}
|
||||
|
||||
func (w Want) check() error {
|
||||
if w.Removed {
|
||||
if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold {
|
||||
if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold || w.Self || w.Yes != "" {
|
||||
return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule, backup answer or put")
|
||||
}
|
||||
return nil
|
||||
@@ -203,11 +211,22 @@ func (w Want) check() error {
|
||||
if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.Composed == "" {
|
||||
return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1)", w.FP)
|
||||
// From ADR 0547's second milestone a node the catalogue places composes its own modules (§2): its want is Self and
|
||||
// names the build alone. Any other want names the one composition it allows (ADR 0539 clause 1).
|
||||
switch {
|
||||
case w.Self && w.Composed != "":
|
||||
return fmt.Errorf("delivery: the want of %s says the node composes it, and names a composition", w.FP)
|
||||
case !w.Self && w.Composed == "":
|
||||
return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1), unless the node composes it itself", w.FP)
|
||||
case w.Composed != "":
|
||||
if err := checkDigest(w.Composed, "a want's composition digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := checkDigest(w.Composed, "a want's composition digest"); err != nil {
|
||||
return err
|
||||
if w.Yes != "" {
|
||||
if err := checkDigest(w.Yes, "the digest a want's yes allows"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch w.Backup {
|
||||
case "", BackupAgain, BackupSkip:
|
||||
@@ -309,11 +328,14 @@ type NodeWant struct {
|
||||
V int `json:"v"`
|
||||
Composed string `json:"composed"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// Self says the node composes its own composition (ADR 0547 §2) and the want names none.
|
||||
Self bool `json:"self,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form.
|
||||
// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form, and a want that names no
|
||||
// composition unless it says the node composes its own (Self, ADR 0547 §2).
|
||||
func EncodeNodeWant(w NodeWant) ([]byte, error) {
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil {
|
||||
if err := w.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.V = Version
|
||||
@@ -332,12 +354,23 @@ func DecodeNodeWant(data []byte) (NodeWant, error) {
|
||||
if err := d.Decode(&w); err != nil {
|
||||
return NodeWant{}, fmt.Errorf("delivery: a node's own want does not fit its shape: %w", err)
|
||||
}
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil {
|
||||
if err := w.check(); err != nil {
|
||||
return NodeWant{}, err
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// check refuses a node's own want that names no composition and does not say the node composes its own, or does both.
|
||||
func (w NodeWant) check() error {
|
||||
switch {
|
||||
case w.Self && w.Composed != "":
|
||||
return fmt.Errorf("delivery: the node's own want says the node composes it, and names a composition")
|
||||
case w.Self:
|
||||
return nil
|
||||
}
|
||||
return checkDigest(w.Composed, "the node's own want's composition digest")
|
||||
}
|
||||
|
||||
// EncodeHeld is h as JSON at Version; it refuses a node name a key refuses.
|
||||
func EncodeHeld(h Held) ([]byte, error) {
|
||||
if err := h.check(); err != nil {
|
||||
|
||||
@@ -86,6 +86,9 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err
|
||||
if !ok {
|
||||
return delivery.Step{}, errors.New("the watch of the ask's step ended")
|
||||
}
|
||||
if seen := stepSeenOf(ctx); seen != nil {
|
||||
seen(step)
|
||||
}
|
||||
if step.State != delivery.StepRunning {
|
||||
return step, nil
|
||||
}
|
||||
@@ -108,6 +111,20 @@ func (k Asker) Wait(ctx context.Context, a delivery.StepAsk) (delivery.Step, err
|
||||
}
|
||||
}
|
||||
|
||||
type stepSeenKey struct{}
|
||||
|
||||
// WithStepSeen is ctx carrying seen, which Asker.Wait hands every value the ask's step key takes while it waits: the node
|
||||
// that took it, each phase it says while running (delivery.Step.Phase), and its end. A try names its asks' progress so
|
||||
// (issue 1890). seen runs in the waiting goroutine and must not block.
|
||||
func WithStepSeen(ctx context.Context, seen func(delivery.Step)) context.Context {
|
||||
return context.WithValue(ctx, stepSeenKey{}, seen)
|
||||
}
|
||||
|
||||
func stepSeenOf(ctx context.Context) func(delivery.Step) {
|
||||
seen, _ := ctx.Value(stepSeenKey{}).(func(delivery.Step))
|
||||
return seen
|
||||
}
|
||||
|
||||
// Ask puts a and waits for its step; a step that did not pass is an error naming its node, its state and its reason.
|
||||
func (k Asker) Ask(ctx context.Context, a delivery.StepAsk) (delivery.Step, error) {
|
||||
if err := k.Put(ctx, a); err != nil {
|
||||
|
||||
@@ -26,9 +26,10 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
|
||||
return delivery.Step{ID: a.ID, Node: "ace", State: delivery.StepPassed, Result: []byte(`{"artifacts":[]}`)}
|
||||
}}
|
||||
k := Asker{Publisher: f, Waiter: f}
|
||||
step, err := k.Ask(context.Background(), goAsk("mvl0frv18d1957"))
|
||||
if err != nil || step.Node != "ace" || len(f.Asks()) != 1 {
|
||||
t.Fatalf("step %+v, %v, asks %d", step, err, len(f.Asks()))
|
||||
var seen []delivery.Step // issue 1890: a waiter that asks to see each value of the step sees it
|
||||
step, err := k.Ask(WithStepSeen(context.Background(), func(s delivery.Step) { seen = append(seen, s) }), goAsk("mvl0frv18d1957"))
|
||||
if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" {
|
||||
t.Fatalf("step %+v, %v, asks %d, seen %+v", step, err, len(f.Asks()), seen)
|
||||
}
|
||||
f.Answer = func(a delivery.StepAsk) delivery.Step {
|
||||
return delivery.Step{ID: a.ID, Node: "shanks", State: delivery.StepRefused, Reason: "go 1.27 installed"}
|
||||
|
||||
+40
-4
@@ -48,7 +48,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -97,6 +99,10 @@ const (
|
||||
// composition is applied, and the node waits for the answer in its want (novox/hq ADR 0539 clause 16). Detail
|
||||
// carries the backup's own words.
|
||||
PhaseHeld Phase = "held"
|
||||
// PhaseWaitingYes is a module of policy record whose composed move the node agent judged to recreate a container or
|
||||
// move its data (novox/hq ADR 0547 §3): nothing of it is applied, Judgement says the judgement and Waits the digest
|
||||
// of the composition waiting, and the assigner asks a person; the node applies it once its want's Yes names Waits.
|
||||
PhaseWaitingYes Phase = "waiting-yes"
|
||||
)
|
||||
|
||||
// Health is what the module's own check says.
|
||||
@@ -213,12 +219,15 @@ func parseModuleKeyPart(part string) (string, error) {
|
||||
return module, nil
|
||||
}
|
||||
|
||||
// HaveNodeWord is the last token of a node's own facts' key in HaveBucket.
|
||||
const HaveNodeWord = nodeWord
|
||||
|
||||
// HaveNodeKey is the key, in HaveBucket, of a node's own facts: `<node>.node`.
|
||||
func HaveNodeKey(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return node + ".node", nil
|
||||
return node + "." + HaveNodeWord, nil
|
||||
}
|
||||
|
||||
// ParseHaveKey is the inverse of HaveKey and HaveNodeKey: isNode is true for `<node>.node`, with module empty.
|
||||
@@ -337,6 +346,15 @@ type Have struct {
|
||||
// UserList is what the node-engine of the node holding mesh-broker wrote of the bus's user list, in the have of the
|
||||
// bus module (novox/hq ADR 0547 §7, §8). Nil on every other have.
|
||||
UserList *UserListWritten `json:"user-list,omitempty"`
|
||||
// Judgement and Waits are, in phase waiting-yes, the node agent's judgement of the move and the digest of the
|
||||
// composition that waits for a person's yes (ADR 0547 §3); empty otherwise.
|
||||
Judgement Judgement `json:"judgement,omitempty"`
|
||||
Waits string `json:"waits,omitempty"`
|
||||
// Sealed is, for a module the node composes itself, each value its running composition carries sealed to the node's
|
||||
// key (a secret, a credential, a vault box), as applied (ADR 0547 §3, issue 1126): what the node's composer reuses
|
||||
// after a restart while it opens to the vault's value, so a value sealed again moves no composition. Openable by the
|
||||
// node alone, as the vault hands them over the bus. Empty on every other have.
|
||||
Sealed []string `json:"sealed,omitempty"`
|
||||
}
|
||||
|
||||
// UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied
|
||||
@@ -381,6 +399,9 @@ type HaveNode struct {
|
||||
Strays json.RawMessage `json:"strays,omitempty"`
|
||||
Filters json.RawMessage `json:"filters,omitempty"`
|
||||
Profile json.RawMessage `json:"profile,omitempty"`
|
||||
// Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126,
|
||||
// ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own.
|
||||
Record *NodeRecord `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
|
||||
@@ -429,8 +450,18 @@ func decode(data []byte, into any) error {
|
||||
func (h Have) check() error {
|
||||
switch h.Phase {
|
||||
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld:
|
||||
if h.Judgement != "" || h.Waits != "" {
|
||||
return fmt.Errorf("delivery: only a have waiting for a yes names a judgement and the composition waiting")
|
||||
}
|
||||
case PhaseWaitingYes:
|
||||
if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot {
|
||||
return fmt.Errorf("delivery: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement)
|
||||
}
|
||||
if err := checkDigest(h.Waits, "the composition a have waits a yes for"); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed or held", h.Phase)
|
||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase)
|
||||
}
|
||||
if h.Composed != "" {
|
||||
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
|
||||
@@ -509,7 +540,11 @@ func DecodePulse(data []byte) (Pulse, error) {
|
||||
func SameHave(a, b Have) bool {
|
||||
ua, ub := a.UserList, b.UserList
|
||||
a.UserList, b.UserList = nil, nil
|
||||
if a != b || (ua == nil) != (ub == nil) {
|
||||
if !slices.Equal(a.Sealed, b.Sealed) {
|
||||
return false
|
||||
}
|
||||
a.Sealed, b.Sealed = nil, nil
|
||||
if !reflect.DeepEqual(a, b) || (ua == nil) != (ub == nil) {
|
||||
return false
|
||||
}
|
||||
if ua == nil {
|
||||
@@ -524,7 +559,8 @@ func SameHave(a, b Have) bool {
|
||||
// SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing.
|
||||
func SameHaveNode(a, b HaveNode) bool {
|
||||
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile)
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) &&
|
||||
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record))
|
||||
}
|
||||
|
||||
// SamePresence says whether two presences are the same value.
|
||||
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
|
||||
// 1131/AC1: the have key forms and parses back; bad names are refused, naming them.
|
||||
func TestHaveKey(t *testing.T) {
|
||||
t.Run("presence", testPresenceKey)
|
||||
t.Run("pulse", testPulseKey)
|
||||
t.Run("pulse ttl", testPulseTTL)
|
||||
if HaveBucket != "node-engine_have" {
|
||||
t.Fatalf("HaveBucket = %q", HaveBucket)
|
||||
}
|
||||
@@ -58,7 +61,7 @@ func TestHaveKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1131/AC2: the presence keys form and parse back; a node named part is refused.
|
||||
func TestPresenceKey(t *testing.T) {
|
||||
func testPresenceKey(t *testing.T) {
|
||||
if PresenceBucket != "mesh-delivery-watchman_presence" {
|
||||
t.Fatalf("PresenceBucket = %q", PresenceBucket)
|
||||
}
|
||||
@@ -98,7 +101,7 @@ func TestPresenceKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1131/AC3: the pulse key splits at the first dot, the key holding dots.
|
||||
func TestPulseKey(t *testing.T) {
|
||||
func testPulseKey(t *testing.T) {
|
||||
if PulseBucket != "mesh-delivery-watchman_pulse" {
|
||||
t.Fatalf("PulseBucket = %q", PulseBucket)
|
||||
}
|
||||
@@ -124,6 +127,7 @@ func TestPulseKey(t *testing.T) {
|
||||
|
||||
// 1131/AC4: each value encodes, decodes equal, and carries "v":1.
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
t.Run("phase and health refused", testPhaseHealthRefused)
|
||||
hasV1 := func(t *testing.T, b []byte) {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
@@ -149,7 +153,7 @@ func TestRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.V = Version
|
||||
if out != in {
|
||||
if !SameHave(out, in) {
|
||||
t.Fatalf("got %+v want %+v", out, in)
|
||||
}
|
||||
})
|
||||
@@ -340,7 +344,7 @@ func TestUnknownVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1131/AC6: a phase or health outside the lists is refused on encode and decode.
|
||||
func TestPhaseHealthRefused(t *testing.T) {
|
||||
func testPhaseHealthRefused(t *testing.T) {
|
||||
for _, p := range []Phase{PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed} {
|
||||
for _, h := range []Health{HealthOK, HealthFailed, HealthWaits, HealthUnknown} {
|
||||
if _, err := EncodeHave(Have{Phase: p, Health: h}); err != nil {
|
||||
@@ -363,10 +367,27 @@ func TestPhaseHealthRefused(t *testing.T) {
|
||||
if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"fine"}`)); err == nil {
|
||||
t.Error("bad health decoded")
|
||||
}
|
||||
// ADR 0547 §3 (issue 1126): a have waits for a yes with its judgement and the composition waiting, and no other have
|
||||
// names either.
|
||||
waits := Have{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: "sha256:" + strings.Repeat("a", 64)}
|
||||
if raw, err := EncodeHave(waits); err != nil {
|
||||
t.Errorf("a have waiting for a yes: %v", err)
|
||||
} else if back, err := DecodeHave(raw); err != nil || back.Judgement != JudgedRecreates || back.Waits != waits.Waits {
|
||||
t.Errorf("a have waiting for a yes came back %+v, %v", back, err)
|
||||
}
|
||||
for name, h := range map[string]Have{
|
||||
"neither judged": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedNeither, Waits: waits.Waits},
|
||||
"no waiting digest": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedMovesData},
|
||||
"running judged": {Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedRecreates},
|
||||
} {
|
||||
if _, err := EncodeHave(h); err == nil {
|
||||
t.Errorf("%s: encoded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1131/AC7: the pulse's time to live is 120 seconds minus the move's age; at the bound the move is stalled.
|
||||
func TestPulseTTL(t *testing.T) {
|
||||
func testPulseTTL(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
age time.Duration
|
||||
ttl time.Duration
|
||||
@@ -390,6 +411,7 @@ func TestPulseTTL(t *testing.T) {
|
||||
|
||||
// 1131/AC8: equal values say same, different ones do not.
|
||||
func TestSame(t *testing.T) {
|
||||
t.Run("have removed", testHaveRemoved)
|
||||
a := Have{V: 1, Build: "b", Phase: PhaseRunning, Health: HealthOK}
|
||||
b := a
|
||||
if !SameHave(a, b) {
|
||||
@@ -399,6 +421,17 @@ func TestSame(t *testing.T) {
|
||||
if SameHave(a, b) {
|
||||
t.Error("different haves same")
|
||||
}
|
||||
// The sealed values a self-composing node runs are part of its have (issue 1126).
|
||||
sa, sb := a, a
|
||||
sa.Sealed, sb.Sealed = []string{`{"box":1}`}, []string{`{"box":1}`}
|
||||
if !SameHave(sa, sb) || SameHave(sa, a) {
|
||||
t.Error("equal sealed values differ, or one said and one not are the same")
|
||||
}
|
||||
if raw, err := EncodeHave(sa); err != nil {
|
||||
t.Error(err)
|
||||
} else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) {
|
||||
t.Errorf("sealed values do not travel: %+v, %v", back, err)
|
||||
}
|
||||
// The user list is compared by what it says, never by where it is held (ADR 0547 §8).
|
||||
at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)
|
||||
ua, ub := a, a
|
||||
@@ -443,7 +476,7 @@ func TestSame(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHaveRemoved(t *testing.T) { // 1370/AC3
|
||||
func testHaveRemoved(t *testing.T) { // 1370/AC3
|
||||
in := Have{Build: "b1", Phase: PhaseRemoved, Health: HealthUnknown, Detail: "the declaration no longer holds it"}
|
||||
b, err := EncodeHave(in)
|
||||
if err != nil {
|
||||
@@ -454,7 +487,7 @@ func TestHaveRemoved(t *testing.T) { // 1370/AC3
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.V = Version
|
||||
if out != in || out.Phase != "removed" {
|
||||
if !SameHave(out, in) || out.Phase != "removed" {
|
||||
t.Errorf("round trip: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ 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)}}}
|
||||
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}
|
||||
}
|
||||
|
||||
// 1442/AC1
|
||||
@@ -21,7 +22,7 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`} {
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`, `"placements":{"ace":`, `"placements-read":true`} {
|
||||
if !strings.Contains(string(data), frag) {
|
||||
t.Errorf("the encoding lacks %s: %s", frag, data)
|
||||
}
|
||||
@@ -47,6 +48,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"},
|
||||
} {
|
||||
f := mergedWithInputs()
|
||||
tc.mut(&f)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -87,6 +88,30 @@ func MergeAskKey(group string, heads []Head) (string, error) {
|
||||
return AskKindMerge + "." + part + "." + hash, nil
|
||||
}
|
||||
|
||||
// SettleGroupPrefix begins the group of a settle ask (novox/hq ADR 0556 clause 5): a person's ask to settle deliveries held
|
||||
// because their merge reached the trunk without a passing check. It is kept in the gate's asks under MergeAskKey like a merge
|
||||
// ask, so the teller carries it unchanged; its options bind the act AskVerb names for it. Git forbids a colon in a branch
|
||||
// name, so no pull request's group (its branch) ever begins with it.
|
||||
const SettleGroupPrefix = "settle:"
|
||||
|
||||
// SettleGroup is the group of the settle ask of these delivery ids: the prefix and twelve hex digits of the sha256 over the
|
||||
// ids, sorted and joined by a newline, so the same deliveries asked again keep one key.
|
||||
func SettleGroup(ids []string) string {
|
||||
sorted := append([]string(nil), ids...)
|
||||
sort.Strings(sorted)
|
||||
sum := sha256.Sum256([]byte(strings.Join(sorted, "\n")))
|
||||
return SettleGroupPrefix + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// AskVerb is the act the options of an ask of the gate bind, by its group: a settle ask's `mesh-delivery.settle`, any other
|
||||
// `mesh-delivery.merge`. The gate composes the act with it and the teller checks a warrant against it.
|
||||
func AskVerb(group string) string {
|
||||
if strings.HasPrefix(group, SettleGroupPrefix) {
|
||||
return "mesh-delivery.settle"
|
||||
}
|
||||
return "mesh-delivery.merge"
|
||||
}
|
||||
|
||||
// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash.
|
||||
func ParseAskKey(key string) (kind, group, hash string, err error) {
|
||||
parts := strings.Split(key, ".")
|
||||
|
||||
@@ -77,6 +77,21 @@ func TestAskID(t *testing.T) { // 1201/AC2
|
||||
if AskID(k1) != AskID(k1) {
|
||||
t.Error("the id is not stable")
|
||||
}
|
||||
// 1280 (ADR 0556 clause 5): a settle ask's group is the same for the same ids in any order, keys like any group, and its
|
||||
// options bind mesh-delivery.settle; every other ask binds mesh-delivery.merge.
|
||||
g := SettleGroup([]string{"novox/b@222222222222", "novox/a@111111111111"})
|
||||
if g != SettleGroup([]string{"novox/a@111111111111", "novox/b@222222222222"}) || !strings.HasPrefix(g, SettleGroupPrefix) || len(g) != len(SettleGroupPrefix)+12 {
|
||||
t.Errorf("SettleGroup = %q", g)
|
||||
}
|
||||
if k, err := MergeAskKey(g, gateHeads()); err != nil || !asks.UsableID(AskID(k)) {
|
||||
t.Errorf("the settle ask's key %q: %v", k, err)
|
||||
}
|
||||
for group, want := range map[string]string{g: "mesh-delivery.settle", "feat/x": "mesh-delivery.merge",
|
||||
"settle/foo": "mesh-delivery.merge", "settle": "mesh-delivery.merge"} { // a branch named settle/... is a merge's group
|
||||
if got := AskVerb(group); got != want {
|
||||
t.Errorf("AskVerb(%q) = %q, want %q", group, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedKey(t *testing.T) { // 1201/AC3
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that
|
||||
// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from
|
||||
// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until
|
||||
// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code.
|
||||
//
|
||||
// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or
|
||||
// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed
|
||||
// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its
|
||||
// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running
|
||||
// processes changes; **neither** otherwise, and for a module the node has never run.
|
||||
|
||||
// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7:
|
||||
// a file change and a reload of the bus, no move of it, never asked).
|
||||
type Judged struct {
|
||||
Judgement Judgement
|
||||
Words string
|
||||
UserListOnly bool
|
||||
}
|
||||
|
||||
// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the
|
||||
// node runs no composition of the module. A composition equal to the one running (resource for resource, each
|
||||
// canonical) is unchanged: no move, and no ask (ADR 0547 §3).
|
||||
func JudgeMove(running *Composed, composed Composed) Judged {
|
||||
if composed.Module == "" {
|
||||
return Judged{Judgement: JudgedNeither, Words: "the node's own composition"}
|
||||
}
|
||||
if running == nil {
|
||||
return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module}
|
||||
}
|
||||
if running.Data == composed.Data && SameComposedResources(*running, composed) {
|
||||
return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"}
|
||||
}
|
||||
if onlyTheUserList(*running, composed) {
|
||||
return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true}
|
||||
}
|
||||
j, words := judged(*running, composed)
|
||||
return Judged{Judgement: j, Words: words}
|
||||
}
|
||||
|
||||
// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `<module>.bus-users`.
|
||||
const busUsersSuffix = ".bus-users"
|
||||
|
||||
// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list.
|
||||
func onlyTheUserList(from, to Composed) bool {
|
||||
if from.Data != to.Data {
|
||||
return false
|
||||
}
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
|
||||
if len(before) != len(after) {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for id, now := range after {
|
||||
was, ok := before[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if bytes.Equal(was.raw, now.raw) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) {
|
||||
return false
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// judged applies ADR 0377 §3 to two compositions of one module.
|
||||
func judged(from, to Composed) (Judgement, string) {
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
|
||||
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
|
||||
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
|
||||
}
|
||||
if from.Data != to.Data {
|
||||
return JudgedMovesData, "its data declaration changes"
|
||||
}
|
||||
places := dataPlaces(before, after)
|
||||
held := func(kind string) bool {
|
||||
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
|
||||
}
|
||||
var moved []string
|
||||
for _, id := range changedOf(before, after, held) {
|
||||
r := before[id]
|
||||
if r == nil {
|
||||
r = after[id]
|
||||
}
|
||||
if withinPlaces(r.path, places) {
|
||||
moved = append(moved, id)
|
||||
}
|
||||
}
|
||||
if to.Data || from.Data {
|
||||
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
|
||||
}
|
||||
if len(moved) > 0 {
|
||||
sort.Strings(moved)
|
||||
return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ")
|
||||
}
|
||||
return JudgedNeither, "it recreates no container and moves no data"
|
||||
}
|
||||
|
||||
// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount,
|
||||
// and its canonical bytes.
|
||||
type judgedResource struct {
|
||||
kind string
|
||||
path string
|
||||
mounts []string
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func judgedResourcesByID(c Composed) map[string]*judgedResource {
|
||||
out := map[string]*judgedResource{}
|
||||
for _, raw := range c.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
// A container's volumes, `<path on the machine>:<path inside>[:<options>]`.
|
||||
Volumes []string `json:"volumes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
continue
|
||||
}
|
||||
r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)}
|
||||
for _, v := range head.Volumes {
|
||||
source, _, _ := strings.Cut(v, ":")
|
||||
r.mounts = append(r.mounts, source)
|
||||
}
|
||||
out[head.ID] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two
|
||||
// compositions that say the same compare equal however each was written.
|
||||
func compactResource(raw []byte) []byte {
|
||||
var v any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted.
|
||||
func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string {
|
||||
var out []string
|
||||
for id, now := range after {
|
||||
if !counted(now.kind) {
|
||||
continue
|
||||
}
|
||||
if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
for id, was := range before {
|
||||
if _, ok := after[id]; !ok && counted(was.kind) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its
|
||||
// directories, before and after.
|
||||
func dataPlaces(before, after map[string]*judgedResource) []string {
|
||||
var out []string
|
||||
for _, rs := range []map[string]*judgedResource{before, after} {
|
||||
for _, r := range rs {
|
||||
if r.kind == string(declaration.TypeContainer) {
|
||||
out = append(out, r.mounts...)
|
||||
}
|
||||
if r.kind == string(declaration.TypeDirectory) && r.path != "" {
|
||||
out = append(out, r.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withinPlaces says p is one of places, or lies within one, or holds one.
|
||||
func withinPlaces(p string, places []string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
p = path.Clean(p)
|
||||
for _, place := range places {
|
||||
if place == "" || !strings.HasPrefix(place, "/") {
|
||||
continue
|
||||
}
|
||||
place = path.Clean(place)
|
||||
if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared
|
||||
// canonical.
|
||||
func SameComposedResources(a, b Composed) bool {
|
||||
if len(a.Resources) != len(b.Resources) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Resources {
|
||||
if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func composedOf(module string, c byte, data bool, resources ...string) Composed {
|
||||
out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data}
|
||||
for _, r := range resources {
|
||||
out.Resources = append(out.Resources, json.RawMessage(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the
|
||||
// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8).
|
||||
func TestJudgeMove(t *testing.T) {
|
||||
const (
|
||||
container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
|
||||
container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
|
||||
spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`
|
||||
spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}`
|
||||
tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}`
|
||||
tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}`
|
||||
config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}`
|
||||
config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}`
|
||||
process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}`
|
||||
process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}`
|
||||
users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}`
|
||||
users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}`
|
||||
natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}`
|
||||
natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}`
|
||||
)
|
||||
mail := func(c byte, data bool, rs ...string) *Composed {
|
||||
m := composedOf("mesh-mail", c, data, rs...)
|
||||
return &m
|
||||
}
|
||||
bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m }
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
from, to *Composed
|
||||
want Judgement
|
||||
userList bool
|
||||
}{
|
||||
{"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false},
|
||||
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
|
||||
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
|
||||
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
|
||||
{"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false},
|
||||
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
|
||||
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
|
||||
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
|
||||
{"never ran", nil, mail('b', true, container), JudgedNeither, false},
|
||||
{"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false},
|
||||
{"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true},
|
||||
{"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false},
|
||||
} {
|
||||
got := JudgeMove(c.from, *c.to)
|
||||
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
|
||||
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
|
||||
}
|
||||
}
|
||||
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
|
||||
t.Errorf("the node's own composition: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// A node's record is what the machine observes of itself (issue 1126, ADR 0555 §3), written by its own agent in its
|
||||
// own have key, `<node>.node` of the node-engine's have bucket (HaveNode.Record), whenever an observed fact changes and
|
||||
// only when the value differs. The key's change is the event its readers hear: every node and the parts that compose
|
||||
// watch it. Where a module runs, and every fact a person decides of the node (its endpoint, site, hub, address on the
|
||||
// private network, public domain, accounts, the tunnel found at adoption), are the catalogue's placement
|
||||
// (placement.go). Nothing in it is secret: its keys are public halves.
|
||||
|
||||
// RecordTunnel is a tunnel found on a node when it was adopted, as compose.Tunnel holds it: public keys and addresses
|
||||
// only. After the node took it over the tunnel is no longer there to observe, so it is kept as what was decided at
|
||||
// adoption, in the node's placement (NodeDecided.Tunnel).
|
||||
type RecordTunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Config string `json:"config,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Range string `json:"range,omitempty"`
|
||||
PublicKey string `json:"public-key,omitempty"`
|
||||
Peers []RecordTunnelPeer `json:"peers,omitempty"`
|
||||
}
|
||||
|
||||
// RecordTunnelPeer is one peer of a found tunnel.
|
||||
type RecordTunnelPeer struct {
|
||||
PublicKey string `json:"public-key"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRecord is one node's record: what the machine observes of itself (issue 1126, ADR 0555: what a person decides
|
||||
// lives in the catalogue's placement file, Placement.Decided; what the machine observes lives here). TunnelKey is the
|
||||
// public key of its private-network tunnel; Certificate the one the mesh issued for its internal name, public, as the
|
||||
// node holds it. The tunnel found at adoption and the names given to its peers are decisions kept in the placement;
|
||||
// whether the hub took its found tunnel over, and which node enrolled under a carried peer's key, are read from both.
|
||||
type NodeRecord struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
TunnelKey string `json:"tunnel-key,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
SealingKey string `json:"sealing-key,omitempty"`
|
||||
ServingKey string `json:"serving-key,omitempty"`
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
OutwardLinks []string `json:"outward-links,omitempty"`
|
||||
Networks []catalogue.AttachedNetwork `json:"networks,omitempty"`
|
||||
NetworksSaid bool `json:"networks-said,omitempty"`
|
||||
Contract int `json:"contract,omitempty"`
|
||||
// Authority is the mesh's own certificate as this machine holds it (public): the one its declaration last wrote where
|
||||
// a module asked for it. A node composing its own composes a certificate with it (compose.Input.Authority).
|
||||
Authority string `json:"authority,omitempty"`
|
||||
}
|
||||
|
||||
func (r NodeRecord) check() error {
|
||||
if err := checkName("node", r.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range r.Capabilities {
|
||||
if c == "" {
|
||||
return fmt.Errorf("delivery: the record of %s names an empty capability", r.Node)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeNodeRecord is r as JSON at Version; it refuses what DecodeNodeRecord refuses.
|
||||
func EncodeNodeRecord(r NodeRecord) ([]byte, error) {
|
||||
if err := r.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.V = Version
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// DecodeNodeRecord reads a node record; it refuses an unknown version (ErrUnknownVersion), a node that is not a name, and
|
||||
// an empty capability.
|
||||
func DecodeNodeRecord(data []byte) (NodeRecord, error) {
|
||||
var r NodeRecord
|
||||
if err := decode(data, &r); err != nil {
|
||||
return NodeRecord{}, err
|
||||
}
|
||||
if err := r.check(); err != nil {
|
||||
return NodeRecord{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// SameNodeRecord says whether two records are the same value, so the agent writes only when they differ.
|
||||
func SameNodeRecord(a, b NodeRecord) bool {
|
||||
a.V, b.V = 0, 0
|
||||
return jsonEqual(a, b)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// secretNamed is facts.secretKey's pattern: a field so named would read as a secret, and the runtime's credential-field
|
||||
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
||||
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
||||
|
||||
// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// neither it nor the placement file names a field like a secret.
|
||||
func TestNodeRecord(t *testing.T) {
|
||||
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
||||
Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
|
||||
NetworksSaid: true, Contract: 3}
|
||||
raw, err := EncodeNodeRecord(hub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeNodeRecord(raw)
|
||||
if err != nil || !SameNodeRecord(back, hub) || back.V != Version {
|
||||
t.Fatalf("round trip: %+v, %v", back, err)
|
||||
}
|
||||
// It rides in the node's own have key: a have that differs only in its record is another value.
|
||||
with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version}
|
||||
if raw, err := EncodeHaveNode(with); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) {
|
||||
t.Fatalf("the have's record: %+v, %v", got, err)
|
||||
}
|
||||
changed := hub
|
||||
changed.Contract = 4
|
||||
if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) {
|
||||
t.Error("a changed record reads as the same have")
|
||||
}
|
||||
|
||||
for name, r := range map[string]NodeRecord{
|
||||
"empty capability": {Node: "ace", Capabilities: []string{""}},
|
||||
"node that is not a name": {Node: "a.b"},
|
||||
} {
|
||||
if _, err := EncodeNodeRecord(r); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Every JSON field name of the record and the types it carries, walked: none reads as a secret.
|
||||
var walk func(reflect.Type, string)
|
||||
walk = func(ty reflect.Type, at string) {
|
||||
for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice {
|
||||
ty = ty.Elem()
|
||||
}
|
||||
if ty.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
for i := 0; i < ty.NumField(); i++ {
|
||||
f := ty.Field(i)
|
||||
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
||||
if secretNamed.MatchString(name) || secretNamed.MatchString(f.Name) {
|
||||
t.Errorf("%s.%s (%q) is named like a secret", at, f.Name, name)
|
||||
}
|
||||
walk(f.Type, at+"."+f.Name)
|
||||
}
|
||||
}
|
||||
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
||||
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
||||
walk(reflect.TypeOf(Placement{}), "Placement")
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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.
|
||||
const (
|
||||
// PlacementState is the indexer's state of placements, and PlacementBucket its bucket.
|
||||
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"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return "", err
|
||||
}
|
||||
return placementWord + "." + node, nil
|
||||
}
|
||||
|
||||
// ParsePlacementKey is the inverse of PlacementKey; it refuses any other key, naming it.
|
||||
func ParsePlacementKey(key string) (node string, err error) {
|
||||
node, ok := strings.CutPrefix(key, placementWord+".")
|
||||
if !ok || checkName("node", node) != nil {
|
||||
return "", fmt.Errorf("delivery: %q is not a placement key, placement.<node>", key)
|
||||
}
|
||||
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"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// Chosen is p as the composition names a chosen provider.
|
||||
func (p Provider) Chosen() catalogue.Chosen { return catalogue.Chosen{Node: p.Node, Module: p.Module} }
|
||||
|
||||
// PlacedModule is one module placed on the node: the host port each port it declares is published on (by the port the
|
||||
// software listens on), and, by provision, the provider each of its requirements that keeps data is bound to.
|
||||
//
|
||||
// MeshWide and Layer are where the module's things are, as a settings layer's placement holds them (catalogue.Layer's
|
||||
// Placement: `places`, `accesses`, `networks`, `endpoints`, `expose`, `reach`, `ports`; ADR 0348 §11): the whole mesh's
|
||||
// layer and this node's, which the controller keeps in its table `placement` (ADR 0549 §6 names it) and composes as two
|
||||
// layers, the mesh's first. Each node's file repeats the mesh-wide layer of the modules placed on it, so a node reads
|
||||
// its own key alone. HasLayer says the node has a layer of its own even when it holds no placement, since a layer
|
||||
// that is there and empty still carries the module's config values (Layers).
|
||||
//
|
||||
// MeshValues and Values are the config values of a module in the settings form (its manifest declares no config, so
|
||||
// mesh-config holds none for it): what a person set in the whole mesh's layer and this node's, beside the placement
|
||||
// (issue 1126; on 2026-10-26 the media modules on one node, plex's puid and pgid among them). A module in the config
|
||||
// form takes its values from mesh-config and has none here.
|
||||
type PlacedModule struct {
|
||||
Module string `json:"module"`
|
||||
Ports map[string]int `json:"ports,omitempty"`
|
||||
Bindings map[string]Provider `json:"bindings,omitempty"`
|
||||
MeshWide map[string]any `json:"mesh-wide,omitempty"`
|
||||
HasMesh bool `json:"has-mesh-wide,omitempty"`
|
||||
Layer map[string]any `json:"layer,omitempty"`
|
||||
HasLayer bool `json:"has-layer,omitempty"`
|
||||
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// Holding is one seat a module placed on the node holds (catalogue.Held without its node, which is the file's).
|
||||
type Holding struct {
|
||||
Claim string `json:"claim"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Module string `json:"module"`
|
||||
Site string `json:"site,omitempty"`
|
||||
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
|
||||
// provision, the provider chosen for the node where more than one could answer.
|
||||
type Placement struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Revision string `json:"revision,omitempty"`
|
||||
Decided NodeDecided `json:"decided"`
|
||||
Modules []PlacedModule `json:"modules"`
|
||||
Pins map[string]Provider `json:"pins,omitempty"`
|
||||
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
|
||||
// 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.
|
||||
// No secret and no key material: accounts are names, never credentials.
|
||||
type NodeDecided struct {
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Site string `json:"site,omitempty"`
|
||||
Hub bool `json:"hub,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
PublicDomain string `json:"public-domain,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
AccountHome string `json:"account-home,omitempty"`
|
||||
AgentAccount string `json:"agent-account,omitempty"`
|
||||
AgentHome string `json:"agent-account-home,omitempty"`
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
Taken []string `json:"taken,omitempty"`
|
||||
// Tunnel is the tunnel found on the node when it was adopted, kept as decided then: once the node takes it over it is
|
||||
// no longer there to observe. PeerNames is, on the hub, the name a person gave a peer of that tunnel, by its public key.
|
||||
Tunnel *RecordTunnel `json:"tunnel,omitempty"`
|
||||
PeerNames map[string]string `json:"peer-names,omitempty"`
|
||||
}
|
||||
|
||||
func (p Placement) check() error {
|
||||
if err := checkName("node", p.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range p.Modules {
|
||||
if err := checkName("module", m.Module); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[m.Module] {
|
||||
return fmt.Errorf("delivery: the placement of %s names %s twice", p.Node, m.Module)
|
||||
}
|
||||
seen[m.Module] = true
|
||||
for port, at := range m.Ports {
|
||||
if n, err := strconv.Atoi(port); err != nil || n <= 0 || n > 65535 {
|
||||
return fmt.Errorf("delivery: %s's port %q on %s is not a port number", m.Module, port, p.Node)
|
||||
}
|
||||
if at <= 0 || at > 65535 {
|
||||
return fmt.Errorf("delivery: %s's port %s on %s is published on %d, which is not a port number", m.Module, port, p.Node, at)
|
||||
}
|
||||
}
|
||||
for provision, to := range m.Bindings {
|
||||
if provision == "" || to.Node == "" || to.Module == "" {
|
||||
return fmt.Errorf("delivery: %s on %s has a binding with no provision, node or module", m.Module, p.Node)
|
||||
}
|
||||
}
|
||||
}
|
||||
for provision, to := range p.Pins {
|
||||
if provision == "" || to.Node == "" || to.Module == "" {
|
||||
return fmt.Errorf("delivery: the placement of %s has a pin with no provision, node or module", p.Node)
|
||||
}
|
||||
}
|
||||
for _, h := range p.Holds {
|
||||
if h.Claim == "" || !seen[h.Module] {
|
||||
return fmt.Errorf("delivery: the placement of %s has %s hold %q, which is not a module placed there or names no seat", p.Node, h.Module, h.Claim)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePlacement is p as JSON at Version; it refuses what DecodePlacement refuses.
|
||||
func EncodePlacement(p Placement) ([]byte, error) {
|
||||
if err := p.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.V = Version
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// DecodePlacement reads a placement file or key value; it refuses an unknown version (ErrUnknownVersion), a node or
|
||||
// module that is not a name, a module named twice, a port that is not a number, a binding or pin naming no provider,
|
||||
// and a holding by a module not placed on the node.
|
||||
func DecodePlacement(data []byte) (Placement, error) {
|
||||
var p Placement
|
||||
if err := decode(data, &p); err != nil {
|
||||
return Placement{}, err
|
||||
}
|
||||
if err := p.check(); err != nil {
|
||||
return Placement{}, err
|
||||
}
|
||||
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).
|
||||
func SamePlacement(a, b Placement) bool {
|
||||
a.Revision, b.Revision = "", ""
|
||||
ja, errA := EncodePlacement(a)
|
||||
jb, errB := EncodePlacement(b)
|
||||
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.
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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}
|
||||
if c.Node == "" {
|
||||
c.Node = before.Node
|
||||
}
|
||||
was := map[string]PlacedModule{}
|
||||
for _, m := range before.Modules {
|
||||
was[m.Module] = m
|
||||
}
|
||||
nodeWide := !jsonEqual(before.Pins, after.Pins) || !jsonEqual(before.Holds, after.Holds)
|
||||
now := map[string]bool{}
|
||||
for _, m := range after.Modules {
|
||||
now[m.Module] = true
|
||||
old, ok := was[m.Module]
|
||||
switch {
|
||||
case !ok:
|
||||
c.Added = append(c.Added, m.Module)
|
||||
case nodeWide || !jsonEqual(old, m):
|
||||
c.Changed = append(c.Changed, m.Module)
|
||||
}
|
||||
}
|
||||
for _, m := range before.Modules {
|
||||
if !now[m.Module] {
|
||||
c.Removed = append(c.Removed, m.Module)
|
||||
}
|
||||
}
|
||||
sort.Strings(c.Added)
|
||||
sort.Strings(c.Removed)
|
||||
sort.Strings(c.Changed)
|
||||
return c
|
||||
}
|
||||
|
||||
func jsonEqual(a, b any) bool {
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// Assigned is the modules placed on the node, in the file's order (compose.Node's Assigned).
|
||||
func (p Placement) Assigned() []string {
|
||||
out := make([]string, 0, len(p.Modules))
|
||||
for _, m := range p.Modules {
|
||||
out = append(out, m.Module)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChosenPins is the node's pins as the composition reads them (compose.Node's Pins); nil when it has none.
|
||||
func (p Placement) ChosenPins() map[string]catalogue.Chosen {
|
||||
if len(p.Pins) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]catalogue.Chosen, len(p.Pins))
|
||||
for provision, to := range p.Pins {
|
||||
out[provision] = to.Chosen()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Bound is, by consumer module and provision, where each binding sends it (compose.Node's Bound); nil when none.
|
||||
func (p Placement) Bound() map[string]map[string]catalogue.Chosen {
|
||||
var out map[string]map[string]catalogue.Chosen
|
||||
for _, m := range p.Modules {
|
||||
if len(m.Bindings) == 0 {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]map[string]catalogue.Chosen{}
|
||||
}
|
||||
out[m.Module] = make(map[string]catalogue.Chosen, len(m.Bindings))
|
||||
for provision, to := range m.Bindings {
|
||||
out[m.Module][provision] = to.Chosen()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HostPorts is, by module and the port it declares, the host port it is published on (compose.Node's Ports); nil when
|
||||
// none. The check has refused a port that is not a number, so none is dropped here.
|
||||
func (p Placement) HostPorts() map[string]map[int]int {
|
||||
var out map[string]map[int]int
|
||||
for _, m := range p.Modules {
|
||||
if len(m.Ports) == 0 {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]map[int]int{}
|
||||
}
|
||||
out[m.Module] = make(map[int]int, len(m.Ports))
|
||||
for port, at := range m.Ports {
|
||||
n, _ := strconv.Atoi(port)
|
||||
out[m.Module][n] = at
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Layers is one module's settings layers on the node, as the composition reads them (compose.Node's Settings): the
|
||||
// whole mesh's layer when it has one, then the node's, which carries the module's config values as mesh-config resolved
|
||||
// them for this node (values, nil when none) beside its placement; a module with no layer and no value has none. This is
|
||||
// the controller's SettingsFor for a module that takes its config values from mesh-config (issue 664). A module in the
|
||||
// settings form carries its values in the file (MeshValues, Values), which then stand for mesh-config's.
|
||||
func (m PlacedModule) Layers(node string, values map[string]any) []catalogue.Layer {
|
||||
var out []catalogue.Layer
|
||||
if m.HasMesh || len(m.MeshWide) > 0 || len(m.MeshValues) > 0 {
|
||||
mesh := map[string]any{}
|
||||
for k, v := range m.MeshValues {
|
||||
mesh[k] = v
|
||||
}
|
||||
out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh, Placement: m.MeshWide})
|
||||
}
|
||||
if len(m.Values) > 0 {
|
||||
values = make(map[string]any, len(m.Values))
|
||||
for k, v := range m.Values {
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
out = append(out, catalogue.Layer{From: node, Values: values, Placement: m.Layer})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Held is the node's holdings as the composition reads them (compose.Input's Holdings rows for this node).
|
||||
func (p Placement) Held() []catalogue.Held {
|
||||
out := make([]catalogue.Held, 0, len(p.Holds))
|
||||
for _, h := range p.Holds {
|
||||
out = append(out, catalogue.Held{Claim: h.Claim, Scope: h.Scope, Node: p.Node, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
}
|
||||
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
|
||||
// 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;
|
||||
// two nodes with one address on the private network; more than one hub.
|
||||
// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its
|
||||
// ForeseenPorts and Unbound), which the catalogue's composition check reads.
|
||||
func CheckPlacements(placements map[string]Placement, held func(module string) bool, enrolled func(node string) bool) []string {
|
||||
var refusals []string
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for n := range placements {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
placed := func(p Provider) bool {
|
||||
other, ok := placements[p.Node]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, m := range other.Modules {
|
||||
if m.Module == p.Module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
meshSeats := map[string]string{}
|
||||
addresses := map[string]string{}
|
||||
var hubs []string
|
||||
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))
|
||||
}
|
||||
if a := strings.TrimSpace(p.Decided.Address); a != "" {
|
||||
if other, twice := addresses[a]; twice {
|
||||
refusals = append(refusals, fmt.Sprintf("%s and %s both have the address %s on the private network", other, node, a))
|
||||
}
|
||||
addresses[a] = node
|
||||
}
|
||||
if p.Decided.Hub {
|
||||
hubs = append(hubs, node)
|
||||
}
|
||||
if !enrolled(node) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s is not an enrolled node", 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))
|
||||
}
|
||||
for _, port := range sortedKeys(m.Ports) {
|
||||
at := m.Ports[port]
|
||||
if other, clash := hostPorts[at]; clash && other != m.Module {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: %s and %s are both published on host port %d", node, other, m.Module, at))
|
||||
}
|
||||
hostPorts[at] = m.Module
|
||||
}
|
||||
for _, provision := range sortedKeys(m.Bindings) {
|
||||
if to := m.Bindings[provision]; !placed(to) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: %s's %s is bound to %s/%s, which is not placed there", node, m.Module, provision, to.Node, to.Module))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, provision := range sortedKeys(p.Pins) {
|
||||
if to := p.Pins[provision]; !placed(to) {
|
||||
refusals = append(refusals, fmt.Sprintf("%s: the pin of %s names %s/%s, which is not placed there", node, provision, to.Node, to.Module))
|
||||
}
|
||||
}
|
||||
for _, h := range p.Holds {
|
||||
if h.Scope != "mesh" {
|
||||
continue
|
||||
}
|
||||
seat := h.Claim + "/" + h.Kind
|
||||
if other, twice := meshSeats[seat]; twice {
|
||||
refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node))
|
||||
}
|
||||
meshSeats[seat] = node
|
||||
}
|
||||
}
|
||||
if len(hubs) > 1 {
|
||||
refusals = append(refusals, fmt.Sprintf("the private network has one hub, and %s are each placed as it", strings.Join(hubs, ", ")))
|
||||
}
|
||||
return refusals
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// ADR 0549 §1, §2: the placement key and file, the value's refusals, its reading as the composition reads a node, what
|
||||
// a change says, and the catalogue's check of every file.
|
||||
func TestPlacement(t *testing.T) {
|
||||
t.Run("key and file", func(t *testing.T) {
|
||||
key, err := PlacementKey("g14")
|
||||
if err != nil || key != "placement.g14" {
|
||||
t.Fatalf("PlacementKey = %q, %v", key, err)
|
||||
}
|
||||
if n, err := ParsePlacementKey(key); err != nil || n != "g14" {
|
||||
t.Fatalf("ParsePlacementKey = %q, %v", n, err)
|
||||
}
|
||||
for _, bad := range []string{"want.g14.x", "placement.", "placement.a.b"} {
|
||||
if _, err := ParsePlacementKey(bad); err == nil {
|
||||
t.Errorf("ParsePlacementKey(%q) took it", bad)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
g14 := Placement{Node: "g14", Revision: "abc",
|
||||
Decided: NodeDecided{Endpoint: "g14.example:51820", Site: "home", Address: "10.77.0.2", Account: "op", Taken: []string{"zsh"}},
|
||||
Modules: []PlacedModule{
|
||||
{Module: "zsh"},
|
||||
{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "novox", Module: "postgres"}}},
|
||||
},
|
||||
Pins: map[string]Provider{"shell": {Node: "g14", Module: "zsh"}},
|
||||
Holds: []Holding{{Claim: "node-shell", Scope: "node", Module: "zsh"}},
|
||||
}
|
||||
novox := Placement{Node: "novox", Decided: NodeDecided{Hub: true, Address: "10.77.0.1"}, Modules: []PlacedModule{{Module: "postgres", Ports: map[string]int{"5432": 5432}}},
|
||||
Holds: []Holding{{Claim: "mesh-store", Scope: "mesh", Module: "postgres"}}}
|
||||
|
||||
t.Run("value", func(t *testing.T) {
|
||||
raw, err := EncodePlacement(g14)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodePlacement(raw)
|
||||
if err != nil || back.V != Version || !SamePlacement(back, g14) {
|
||||
t.Fatalf("round trip: %+v, %v", back, err)
|
||||
}
|
||||
moved := g14
|
||||
moved.Revision = "def"
|
||||
if !SamePlacement(g14, moved) {
|
||||
t.Error("a new revision alone counts as a change")
|
||||
}
|
||||
if _, err := DecodePlacement([]byte(`{"v":9,"node":"g14","modules":[]}`)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("version 9: %v", err)
|
||||
}
|
||||
for name, bad := range map[string]string{
|
||||
"module twice": `{"v":1,"node":"g14","modules":[{"module":"a"},{"module":"a"}]}`,
|
||||
"port not number": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"http":80}}]}`,
|
||||
"host port zero": `{"v":1,"node":"g14","modules":[{"module":"a","ports":{"80":0}}]}`,
|
||||
"binding no node": `{"v":1,"node":"g14","modules":[{"module":"a","bindings":{"pg":{"module":"pg"}}}]}`,
|
||||
"pin no module": `{"v":1,"node":"g14","modules":[],"pins":{"pg":{"node":"x"}}}`,
|
||||
"hold not placed": `{"v":1,"node":"g14","modules":[],"holds":[{"claim":"s","module":"a"}]}`,
|
||||
"node not a name": `{"v":1,"node":"g.14","modules":[]}`,
|
||||
"value not shaped": `{"v":1,"node":"g14","modules":{}}`,
|
||||
} {
|
||||
if _, err := DecodePlacement([]byte(bad)); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("as the composition reads it", func(t *testing.T) {
|
||||
if got := g14.Assigned(); !reflect.DeepEqual(got, []string{"zsh", "app"}) {
|
||||
t.Errorf("Assigned = %v, want the file's order", got)
|
||||
}
|
||||
if got := g14.ChosenPins(); !reflect.DeepEqual(got, map[string]catalogue.Chosen{"shell": {Node: "g14", Module: "zsh"}}) {
|
||||
t.Errorf("ChosenPins = %v", got)
|
||||
}
|
||||
if got := g14.Bound(); !reflect.DeepEqual(got, map[string]map[string]catalogue.Chosen{"app": {"postgres": {Node: "novox", Module: "postgres"}}}) {
|
||||
t.Errorf("Bound = %v", got)
|
||||
}
|
||||
if got := g14.HostPorts(); !reflect.DeepEqual(got, map[string]map[int]int{"app": {8080: 18080}}) {
|
||||
t.Errorf("HostPorts = %v", got)
|
||||
}
|
||||
if got := g14.Held(); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) {
|
||||
t.Errorf("Held = %v", got)
|
||||
}
|
||||
layered := PlacedModule{Module: "app", HasMesh: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}}
|
||||
want := []catalogue.Layer{
|
||||
{From: catalogue.MeshWideLayer, Values: map[string]any{}},
|
||||
{From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}},
|
||||
}
|
||||
if got := layered.Layers("g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Layers = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a"}).Layers("g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" {
|
||||
t.Errorf("values alone make the node's layer: %#v", got)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a", HasLayer: true}).Layers("g14", nil); len(got) != 1 || got[0].Values == nil {
|
||||
t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a"}).Layers("g14", nil); got != nil {
|
||||
t.Errorf("no layer and no value is no layer: %#v", got)
|
||||
}
|
||||
empty := Placement{Node: "x"}
|
||||
if empty.ChosenPins() != nil || empty.Bound() != nil || empty.HostPorts() != nil || len(empty.Held()) != 0 {
|
||||
t.Error("an empty placement reads as something")
|
||||
}
|
||||
})
|
||||
|
||||
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"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ChangeOf = %+v, want %+v", got, want)
|
||||
}
|
||||
pinned := g14
|
||||
pinned.Pins = nil
|
||||
if got := ChangeOf(g14, pinned); !reflect.DeepEqual(got.Changed, []string{"app", "zsh"}) {
|
||||
t.Errorf("a pin change changes every module on the node: %+v", got)
|
||||
}
|
||||
if got := ChangeOf(Placement{}, novox); !reflect.DeepEqual(got.Added, []string{"postgres"}) || got.Node != "novox" {
|
||||
t.Errorf("a new node: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the catalogue's check", func(t *testing.T) {
|
||||
held := func(m string) bool { return m != "ghost" }
|
||||
enrolled := func(n string) bool { return n != "stranger" }
|
||||
if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 {
|
||||
t.Fatalf("a sound placement is refused: %v", r)
|
||||
}
|
||||
clash := g14
|
||||
clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...)
|
||||
clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}}
|
||||
clash.Pins = map[string]Provider{"shell": {Node: "g14", Module: "bash"}}
|
||||
twice := novox
|
||||
twice.Node = "stranger"
|
||||
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",
|
||||
"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
|
||||
for _, line := range r {
|
||||
found = found || strings.Contains(line, want)
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no refusal says %q; got %v", want, r)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How far a repository reached (novox/hq ADR 0556, issue 1280). The indexer says, per repository, the last merge it took in
|
||||
// (TakenBucket); the delivery view keeps, per repository, how far each of its modules reached on the nodes that want it, and
|
||||
// derives from it the repository's reached head (ReachBucket): the newest head of the repository at which every wanted
|
||||
// module runs, on every node, a build at or after its last change. A merge at or before the reached head is on the
|
||||
// machines. Nothing records a merge as delivered (ADR 0429 §1): the gate and the tracker derive it from this state.
|
||||
|
||||
// TakenBucket is the indexer's state `taken`: one key per repository (RepositoryKey), the last merge it took in.
|
||||
const TakenBucket = IndexerModule + "_taken"
|
||||
|
||||
// ReachBucket is the delivery view's state `reach`: one key per repository (RepositoryKey), how far its modules reached.
|
||||
const ReachBucket = ViewModule + "_reach"
|
||||
|
||||
// RepositoryKey is a repository's key in TakenBucket and ReachBucket: its forge name `owner/name` (RepositoryName), in
|
||||
// lower case as the forge compares it, escaped as a key part (EscapeName). An empty spelling, or one with no owner, is
|
||||
// refused.
|
||||
func RepositoryKey(repository string) (string, error) {
|
||||
name := strings.ToLower(RepositoryName(repository))
|
||||
if owner, repo, ok := strings.Cut(name, "/"); !ok || owner == "" || repo == "" {
|
||||
return "", fmt.Errorf("delivery: %q names no repository as owner/name", repository)
|
||||
}
|
||||
return EscapeName(name), nil
|
||||
}
|
||||
|
||||
// Taken is the value of a taken key: the repository, the merge commit the indexer last took in (applied its message
|
||||
// whole), and when.
|
||||
type Taken struct {
|
||||
V int `json:"v"`
|
||||
Repository string `json:"repository"`
|
||||
Merge string `json:"merge"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// EncodeTaken is t as JSON at Version; a value with no repository or merge is refused.
|
||||
func EncodeTaken(t Taken) ([]byte, error) {
|
||||
if t.Repository == "" || t.Merge == "" {
|
||||
return nil, fmt.Errorf("delivery: a taken value names its repository and merge")
|
||||
}
|
||||
t.V = Version
|
||||
return json.Marshal(t)
|
||||
}
|
||||
|
||||
// DecodeTaken reads a taken value; it refuses an unknown version (ErrUnknownVersion) and one with no merge.
|
||||
func DecodeTaken(data []byte) (Taken, error) {
|
||||
var t Taken
|
||||
if err := decode(data, &t); err != nil {
|
||||
return t, err
|
||||
}
|
||||
if t.Repository == "" || t.Merge == "" {
|
||||
return t, fmt.Errorf("delivery: a taken value names no repository or merge")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Reach is the value of a reach key: how far the modules of one repository reached on the nodes. Indexed is the last
|
||||
// merge the indexer took in for it (Taken.Merge), empty while it took in none. A repository the indexer holds no module
|
||||
// of (a library) has no Modules. Reached is the reached head: the newest head of the repository in the source's history,
|
||||
// not past Indexed, at which every module with a node that wants it has its floor at or after its last change at or before
|
||||
// that head; ReachedSrc is the source revision that named it. Holds says, in words, what keeps the next head of the
|
||||
// repository from being reached (a module on a node, the indexer's lag); empty when the repository's newest head is reached.
|
||||
type Reach struct {
|
||||
V int `json:"v"`
|
||||
Repository string `json:"repository"`
|
||||
Indexed string `json:"indexed,omitempty"`
|
||||
Modules map[string]ModuleReach `json:"modules,omitempty"`
|
||||
Reached string `json:"reached,omitempty"`
|
||||
ReachedSrc uint64 `json:"reached_src,omitempty"`
|
||||
Holds []string `json:"holds,omitempty"`
|
||||
}
|
||||
|
||||
// ModuleReach is how far one module reached. FP is its fingerprint at the indexer now; Nodes how many nodes want it
|
||||
// (a want not removed); Changes its fingerprints the view knows of, each with the merge commit it first appeared at,
|
||||
// newest first. Floor is the oldest first-appearance commit among the fingerprints its nodes' haves run, a running have
|
||||
// counting whether its health check passes or not; it is empty when a node that wants the module has no running have, or
|
||||
// runs a fingerprint not among Changes. Waiting names, in words, every node where the module is not settled (its have
|
||||
// different from its want, not running, or not healthy), with or without a floor: health is said here and never moves the
|
||||
// floor (ADR 0556 clause 2). A module whose floor is empty while a node wants it holds no head of the repository back: a
|
||||
// reader takes a merge among its Changes as not yet on the machines, with these words. A module no node wants has neither.
|
||||
type ModuleReach struct {
|
||||
FP string `json:"fp,omitempty"`
|
||||
Nodes int `json:"nodes"`
|
||||
Changes []Change `json:"changes,omitempty"`
|
||||
Floor string `json:"floor,omitempty"`
|
||||
Waiting string `json:"waiting,omitempty"`
|
||||
}
|
||||
|
||||
// Change is one fingerprint of a module, the merge commit it first appeared at (Module.Commit), and the source revision
|
||||
// that named that commit as the repository's head (Module.Src, or the source's history), 0 when not known.
|
||||
type Change struct {
|
||||
Commit string `json:"commit"`
|
||||
FP string `json:"fp"`
|
||||
Src uint64 `json:"src,omitempty"`
|
||||
}
|
||||
|
||||
// MostChanges is how many changes of a module a reach value keeps: as many revisions as the indexer's modules bucket keeps.
|
||||
const MostChanges = ModulesHistory
|
||||
|
||||
func (r Reach) check() error {
|
||||
if r.Repository == "" {
|
||||
return fmt.Errorf("delivery: a reach value names no repository")
|
||||
}
|
||||
if (r.Reached == "") != (r.ReachedSrc == 0) {
|
||||
return fmt.Errorf("delivery: the reach of %s names its reached head and its source revision together", r.Repository)
|
||||
}
|
||||
for name, m := range r.Modules {
|
||||
if name == "" {
|
||||
return fmt.Errorf("delivery: a reach value names a module with no name")
|
||||
}
|
||||
if m.Nodes < 0 {
|
||||
return fmt.Errorf("delivery: the reach of %s counts %d nodes", name, m.Nodes)
|
||||
}
|
||||
if len(m.Changes) > MostChanges {
|
||||
return fmt.Errorf("delivery: the reach of %s keeps %d changes, more than %d", name, len(m.Changes), MostChanges)
|
||||
}
|
||||
for _, c := range m.Changes {
|
||||
if c.Commit == "" || c.FP == "" {
|
||||
return fmt.Errorf("delivery: a change of %s names no commit or fingerprint", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeReach is r as JSON at Version; a value that does not check is refused.
|
||||
func EncodeReach(r Reach) ([]byte, error) {
|
||||
if err := r.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.V = Version
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// DecodeReach reads a reach value; it refuses an unknown version (ErrUnknownVersion) and one that does not check.
|
||||
func DecodeReach(data []byte) (Reach, error) {
|
||||
var r Reach
|
||||
if err := decode(data, &r); err != nil {
|
||||
return r, err
|
||||
}
|
||||
return r, r.check()
|
||||
}
|
||||
|
||||
// SameReach says whether two reach values are the same, so the view writes only when they differ.
|
||||
func SameReach(a, b Reach) bool {
|
||||
if a.Repository != b.Repository || a.Indexed != b.Indexed || len(a.Modules) != len(b.Modules) ||
|
||||
a.Reached != b.Reached || a.ReachedSrc != b.ReachedSrc || !slices.Equal(a.Holds, b.Holds) {
|
||||
return false
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(a.Modules)) {
|
||||
x, ok := b.Modules[name]
|
||||
y := a.Modules[name]
|
||||
if !ok || x.FP != y.FP || x.Nodes != y.Nodes || x.Floor != y.Floor || x.Waiting != y.Waiting ||
|
||||
!slices.Equal(x.Changes, y.Changes) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -537,6 +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).
|
||||
Placements map[string]string `json:"placements,omitempty"`
|
||||
PlacementsRead bool `json:"placements-read,omitempty"`
|
||||
}
|
||||
|
||||
func (f Forge) check() error {
|
||||
@@ -549,6 +554,17 @@ func (f Forge) check() error {
|
||||
if len(f.Open) > 0 {
|
||||
return fmt.Errorf("delivery: only an open message names open pull requests")
|
||||
}
|
||||
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged {
|
||||
return fmt.Errorf("delivery: only a merged message 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 err := checkRepository(f.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSourceKeys(t *testing.T) { // 1200/AC1
|
||||
@@ -196,7 +197,23 @@ func TestSourceValuesRoundTrip(t *testing.T) { // 1200/AC3
|
||||
t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err)
|
||||
}
|
||||
}
|
||||
// 1280: the taken and reach values (ADR 0556).
|
||||
tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)}
|
||||
data, err = EncodeTaken(tk)
|
||||
check("taken", data, err)
|
||||
tk.V = Version
|
||||
if got, err := DecodeTaken(data); err != nil || got != tk {
|
||||
t.Errorf("taken round trip: %+v %v", got, err)
|
||||
}
|
||||
r := sampleReach()
|
||||
data, err = EncodeReach(r)
|
||||
check("reach", data, err)
|
||||
if got, err := DecodeReach(data); err != nil || !SameReach(got, r) {
|
||||
t.Errorf("reach round trip: %+v %v", got, err)
|
||||
}
|
||||
for name, dec := range map[string]func([]byte) error{
|
||||
"taken": func(b []byte) error { _, err := DecodeTaken(b); return err },
|
||||
"reach": func(b []byte) error { _, err := DecodeReach(b); return err },
|
||||
"source": func(b []byte) error { _, err := DecodeSource(b); return err },
|
||||
"candidate": func(b []byte) error { _, err := DecodeCandidate(b); return err },
|
||||
"module": func(b []byte) error { _, err := DecodeModule(b); return err },
|
||||
@@ -282,6 +299,38 @@ func TestSourceValuesRefused(t *testing.T) { // 1200/AC4
|
||||
wantErr("unknown kind", err, "closed")
|
||||
_, err = EncodeForge(Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"})
|
||||
wantErr("bad repository", err, "nope")
|
||||
|
||||
// 1280: taken and reach values that do not hold (ADR 0556).
|
||||
_, err = EncodeTaken(Taken{Repository: "a/b"})
|
||||
wantErr("taken without merge", err, "merge")
|
||||
r := sampleReach()
|
||||
r.Repository = ""
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach without repository", err, "repository")
|
||||
r = sampleReach()
|
||||
m := r.Modules["gitea"]
|
||||
m.Changes = append(m.Changes, Change{Commit: "dddd"})
|
||||
r.Modules["gitea"] = m
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach with a change naming no fingerprint", err, "gitea")
|
||||
r = sampleReach()
|
||||
m = r.Modules["gitea"]
|
||||
m.Changes = make([]Change, MostChanges+1)
|
||||
r.Modules["gitea"] = m
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach keeping too many changes", err, "more than")
|
||||
r = sampleReach()
|
||||
r.ReachedSrc = 0
|
||||
_, err = EncodeReach(r)
|
||||
wantErr("reach with a head and no revision", err, "reached head")
|
||||
}
|
||||
|
||||
// sampleReach is a reach value of one repository with a module at its floor and a library-like empty one.
|
||||
func sampleReach() Reach {
|
||||
return Reach{V: Version, Repository: "novox/mesh-catalog", Indexed: "cccc", Reached: "cccc", ReachedSrc: 9, Modules: map[string]ModuleReach{
|
||||
"gitea": {FP: "src2:a", Nodes: 1, Floor: "cccc", Waiting: "gitea on ace runs src2:a and its health check failed", Changes: []Change{{Commit: "cccc", FP: "src2:a", Src: 9}, {Commit: "bbbb", FP: "src2:b", Src: 7}}},
|
||||
"zsh": {FP: "src2:z", Changes: []Change{{Commit: "aaaa", FP: "src2:z"}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestSourceSame(t *testing.T) { // 1200/AC5
|
||||
@@ -314,14 +363,40 @@ func TestSourceSame(t *testing.T) { // 1200/AC5
|
||||
if SameForge(f, g) {
|
||||
t.Error("different forge messages are the same")
|
||||
}
|
||||
if !SameReach(sampleReach(), sampleReach()) {
|
||||
t.Error("equal reach values differ")
|
||||
}
|
||||
for name, change := range map[string]func(*Reach){
|
||||
"indexed": func(r *Reach) { r.Indexed = "dddd" },
|
||||
"floor": func(r *Reach) { m := r.Modules["gitea"]; m.Floor = "bbbb"; r.Modules["gitea"] = m },
|
||||
"changes": func(r *Reach) { m := r.Modules["zsh"]; m.Changes = nil; r.Modules["zsh"] = m },
|
||||
"modules": func(r *Reach) { delete(r.Modules, "zsh") },
|
||||
"reached": func(r *Reach) { r.Reached, r.ReachedSrc = "bbbb", 7 },
|
||||
"holds": func(r *Reach) { r.Holds = []string{"gitea on ace does not yet run src2:a"} },
|
||||
} {
|
||||
r := sampleReach()
|
||||
change(&r)
|
||||
if SameReach(r, sampleReach()) {
|
||||
t.Errorf("reach values differing in %s are the same", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceBuckets(t *testing.T) {
|
||||
if SourceBucket != "mesh-delivery-gate_source" || CandidatesBucket != "mesh-delivery-indexer_candidates" ||
|
||||
ModulesBucket != "mesh-delivery-indexer_modules" || SourceMainKey != "main" ||
|
||||
SourceHistory != 64 || CandidatesHistory != 10 || ModulesHistory != 20 {
|
||||
SourceHistory != 64 || CandidatesHistory != 10 || ModulesHistory != 20 ||
|
||||
TakenBucket != "mesh-delivery-indexer_taken" || ReachBucket != "mesh-delivery-view_reach" {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
// 1280: a repository keys taken and reach by its forge name, in lower case, from either spelling (ADR 0556).
|
||||
for in, want := range map[string]string{"novox/Mesh-SDK": "novox/mesh-sdk", "http://h:3000/novox/mesh-sdk.git": "novox/mesh-sdk",
|
||||
"a/b.c": "a/b=2ec", "": "", "nope": "", "/x": ""} {
|
||||
got, err := RepositoryKey(in)
|
||||
if (want == "") != (err != nil) || got != want {
|
||||
t.Errorf("RepositoryKey(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgeDirs(t *testing.T) {
|
||||
|
||||
@@ -693,3 +693,60 @@ func DecodePause(data []byte) (Pause, error) {
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CapacityKey is the key, in a build module's PausesState, where the module on node says the capacity it judges by
|
||||
// now and why: `capacity.<node>` (issue 1806's follow-up). The pauses state has no age, so the value stands until the
|
||||
// module writes another; a reader such as the delivery view's build_usage says it in place of the configured capacity.
|
||||
func CapacityKey(node string) (string, error) {
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "capacity." + node, nil
|
||||
}
|
||||
|
||||
// Capacity is a build seat's capacity on one node as its holder judges by it: the configured capacity (the module's
|
||||
// setting `capacity`), bounded by the cores its node's build slice gives (its CPU quota, read live) over the cores one
|
||||
// step needs (the module's setting `cores-per-step`). Effective is min(Configured, ByCPU) when a quota is known, else
|
||||
// Configured; one step at least while Configured is above zero, so a node whose quota is under one step's need still
|
||||
// builds, slowly.
|
||||
type Capacity struct {
|
||||
V int `json:"v"`
|
||||
Effective int `json:"effective"`
|
||||
Configured int `json:"configured"`
|
||||
// ConfiguredFrom says where Configured was read: mesh-config's value as the config watch handed it, or the settings
|
||||
// file.
|
||||
ConfiguredFrom string `json:"configured-from,omitempty"`
|
||||
// QuotaCores is the build slice's CPU quota in processors (cpu.max: 1200% is 12), 0 when none is known; QuotaFrom
|
||||
// says where it was read (the cgroup, or the share the composer gave) or why none is known.
|
||||
QuotaCores float64 `json:"quota-cores,omitempty"`
|
||||
QuotaFrom string `json:"quota-from"`
|
||||
// CoresPerStep is what one step of this module needs; ByCPU is floor(QuotaCores / CoresPerStep), at least 1, and 0
|
||||
// when no quota is known.
|
||||
CoresPerStep float64 `json:"cores-per-step"`
|
||||
ByCPU int `json:"by-cpu,omitempty"`
|
||||
// BuildHours is the node's build window (HH:MM-HH:MM, its local time and zone), absent when it builds at any hour;
|
||||
// OutsideHours says the time judged fell outside it, so Effective is 0 and the next step waits for the window to open.
|
||||
BuildHours string `json:"build-hours,omitempty"`
|
||||
OutsideHours bool `json:"outside-hours,omitempty"`
|
||||
// BuildHoursFault is why the node's build-hours value cannot be read, naming the value: it then builds at any hour.
|
||||
BuildHoursFault string `json:"build-hours-fault,omitempty"`
|
||||
// Why is the capacity in words: what bounds it.
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at,omitzero"`
|
||||
}
|
||||
|
||||
// EncodeCapacity is c as JSON at Version.
|
||||
func EncodeCapacity(c Capacity) ([]byte, error) {
|
||||
c.V = Version
|
||||
c.At = c.At.UTC()
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodeCapacity reads a capacity value; it refuses an unknown version (ErrUnknownVersion).
|
||||
func DecodeCapacity(data []byte) (Capacity, error) {
|
||||
var c Capacity
|
||||
if err := decode(data, &c); err != nil {
|
||||
return Capacity{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -73,6 +73,11 @@ type Step struct {
|
||||
// What says what the step is, for a reader of the steps state: an ask's kind, repository, head and levels or
|
||||
// module (issue 1890). Empty for a step a tool started by hand.
|
||||
What string `json:"what,omitempty"`
|
||||
// Phase is the part of its job a running ask's step is in (fetch, a level's digest, lint or tests), and PhaseAt
|
||||
// when it entered it: the step key is written again at each phase, so a slow step says where it stands (issue 1890).
|
||||
// A reader waiting for the end treats such a write as any running one.
|
||||
Phase string `json:"phase,omitempty"`
|
||||
PhaseAt time.Time `json:"phase-at,omitzero"`
|
||||
}
|
||||
|
||||
func (s Step) check() error {
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
func hashOf(c byte) string { return "sha256:" + digestOf(c) }
|
||||
|
||||
// 1943/AC3: a want naming its fingerprint and the digest of the composition it allows round-trips, and so does one
|
||||
// carrying the answer to a failed backup; a want naming a fingerprint without a digest, a digest not of the sha256
|
||||
// carrying the answer to a failed backup, and from ADR 0547's second milestone one naming the build alone or the yes
|
||||
// a person gave; a digest not of the sha256
|
||||
// form, a removed want naming a digest, an unknown backup answer and a rule past the seventh are refused.
|
||||
func TestAWantNamesItsComposition(t *testing.T) {
|
||||
for _, w := range []Want{
|
||||
@@ -17,6 +18,9 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Backup: BackupSkip, Words: "the operator: go on without a backup"},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleComposed, Words: "no composition of b for ace yet"},
|
||||
{Removed: true},
|
||||
// ADR 0547 §2, §3 (issue 1126): from the second milestone a want names the build alone, and the yes a person gave.
|
||||
{FP: fp2of('a'), Self: true},
|
||||
{FP: fp2of('a'), Self: true, Yes: hashOf('3')},
|
||||
} {
|
||||
raw, err := EncodeWant(w)
|
||||
if err != nil {
|
||||
@@ -33,6 +37,9 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
why string
|
||||
}{
|
||||
{Want{FP: fp2of('a')}, "names no composition"},
|
||||
{Want{FP: fp2of('a'), Self: true, Composed: hashOf('1')}, "names a composition"},
|
||||
{Want{FP: fp2of('a'), Self: true, Yes: "sha256:12"}, "a want's yes allows"},
|
||||
{Want{Removed: true, Self: true}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: "sha256:12"}, "not sha256"},
|
||||
{Want{Removed: true, Composed: hashOf('1')}, "removed want names no"},
|
||||
{Want{FP: fp2of('a'), Composed: hashOf('1'), Backup: "later"}, `backup answer "later"`},
|
||||
@@ -45,7 +52,7 @@ func TestAWantNamesItsComposition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if _, err := DecodeWant([]byte(`{"v":1,"fp":"` + fp2of('a') + `"}`)); err == nil || !strings.Contains(err.Error(), "names no composition") {
|
||||
t.Errorf("a want of release 5, naming no composition, was read: %v", err)
|
||||
t.Errorf("a want of release 5, naming no composition and not the node's own, was read: %v", err)
|
||||
}
|
||||
if RuleComposed != 7 {
|
||||
t.Errorf("RuleComposed = %d, want the seventh rule", RuleComposed)
|
||||
@@ -86,11 +93,17 @@ func TestTheNodesOwnWant(t *testing.T) {
|
||||
if err != nil || back != w {
|
||||
t.Errorf("node want came back %+v, %v", back, err)
|
||||
}
|
||||
for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}} {
|
||||
for _, bad := range []NodeWant{{}, {Composed: "sha256:1"}, {Self: true, Composed: hashOf('2')}} {
|
||||
if _, err := EncodeNodeWant(bad); err == nil {
|
||||
t.Errorf("EncodeNodeWant(%+v) accepted", bad)
|
||||
}
|
||||
}
|
||||
// ADR 0547 §2 (issue 1126): from the second milestone the node composes its own composition, and its want names none.
|
||||
if raw, err := EncodeNodeWant(NodeWant{Self: true, Words: "the node composes its own"}); err != nil {
|
||||
t.Errorf("a node want naming no composition: %v", err)
|
||||
} else if back, err := DecodeNodeWant(raw); err != nil || back.Composed != "" {
|
||||
t.Errorf("a node want naming no composition came back %+v, %v", back, err)
|
||||
}
|
||||
if _, err := DecodeNodeWant([]byte(`{"v":1,"composed":"` + hashOf('2') + `","fp":"` + fp2of('a') + `"}`)); err == nil {
|
||||
t.Errorf("a node want naming a fingerprint was read")
|
||||
}
|
||||
|
||||
+11
-6
@@ -38,7 +38,7 @@ interface World {
|
||||
|
||||
async function world(
|
||||
names: string[],
|
||||
opts: { retire?: boolean; held?: string[]; del?: boolean; reenable?: boolean; stableForMs?: number } = {},
|
||||
opts: { retire?: boolean; held?: string[]; del?: boolean; reenable?: boolean; stableForMs?: number; stablePasses?: number } = {},
|
||||
): Promise<World> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "retire-"));
|
||||
const receives = join(dir, "postgres-database.json");
|
||||
@@ -64,6 +64,7 @@ async function world(
|
||||
};
|
||||
w.p = runProviderLoop("postgres-database", adapter, {
|
||||
receives, everyMs: EVERY, node: "anchor", stableForMs: opts.stableForMs ?? 0,
|
||||
...(opts.stablePasses ? { stablePasses: opts.stablePasses } : {}),
|
||||
// Only retirement is these tests'; the standing (provisioner.failing, .recovered) is standing.test.ts's.
|
||||
announce: (event, body) => { if (event === RETIREMENT_EVENT) w.events.push(body); },
|
||||
});
|
||||
@@ -109,8 +110,10 @@ test("the fifth pass and not the fourth retires", async () => {
|
||||
// Driven by the clock: five passes 10 ms apart take at least 40 ms after the first sees the set.
|
||||
const w = await world(["a", "b", "c"]);
|
||||
try {
|
||||
await w.ask(["b", "c"]);
|
||||
// The clock starts before the file is written: a pass may read it the moment it lands, before any line after the
|
||||
// write runs, and a start taken after would then count fewer than four intervals.
|
||||
const start = Date.now();
|
||||
await w.ask(["b", "c"]);
|
||||
await until(() => w.retired.length === 1);
|
||||
assert.ok(Date.now() - start >= 4 * EVERY - 2, `retired after ${Date.now() - start} ms, before five passes`);
|
||||
} finally {
|
||||
@@ -155,10 +158,10 @@ test("five passes are not enough: the same set must also hold for stableForMs",
|
||||
const HOLD = 400;
|
||||
const w = await world(["a", "b", "c"], { stableForMs: HOLD });
|
||||
try {
|
||||
const start = Date.now(); // before the write, as in the fifth-pass test
|
||||
await w.ask(["b", "c"]);
|
||||
const start = Date.now();
|
||||
await sleep(EVERY * 10); // well past five passes, well short of the hold
|
||||
assert.deepEqual(w.retired, [], "retired after five passes, before the set held long enough");
|
||||
if (Date.now() - start < HOLD) assert.deepEqual(w.retired, [], "retired after five passes, before the set held long enough");
|
||||
await until(() => w.retired.length === 1, 3000);
|
||||
assert.ok(Date.now() - start >= HOLD - 5, `retired after ${Date.now() - start} ms, before ${HOLD} ms`);
|
||||
assert.equal((await w.p.retirement()).stable_for_seconds, 0); // 400 ms rounds to 0 s
|
||||
@@ -300,11 +303,13 @@ test("delete refuses an active consumer and deletes only the named retired one",
|
||||
});
|
||||
|
||||
test("an addition acts on the first pass", async () => {
|
||||
const w = await world(["a"]);
|
||||
// Not gated by the stability a retirement waits for: with a hold of a minute and a thousand passes, only a pass that
|
||||
// acts at once creates b. The deadline is a few passes, not three intervals of 10 ms: a scheduler stall must not fail it.
|
||||
const w = await world(["a"], { stableForMs: 60_000, stablePasses: 1000 });
|
||||
try {
|
||||
const before = w.created.length;
|
||||
await w.ask(["a", "b"]);
|
||||
await until(() => w.created.length > before, EVERY * 3);
|
||||
await until(() => w.created.length > before, 1000);
|
||||
assert.equal(w.created.at(-1), "b");
|
||||
} finally {
|
||||
w.done();
|
||||
|
||||
+37
-21
@@ -27,14 +27,18 @@ async function until(cond: () => boolean, ms = 4000): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
interface Said { event: string; body: Record<string, unknown> }
|
||||
interface Said { event: string; body: Record<string, unknown>; at: number }
|
||||
|
||||
interface World {
|
||||
dir: string;
|
||||
ask(list: { as: string; node?: string; values?: Record<string, unknown> }[]): Promise<void>;
|
||||
failing: Error | null;
|
||||
holdsErr: Error | null;
|
||||
/** When set, holds fails only for these consumers (holdsErr's error). */
|
||||
holdsErrFor: Set<string> | null;
|
||||
created: string[];
|
||||
/** How many times create was asked, failed or not. */
|
||||
attempts: number;
|
||||
/** Every event but the first success since start, which has its own test. */
|
||||
said: Said[];
|
||||
all: Said[];
|
||||
@@ -44,7 +48,7 @@ interface World {
|
||||
async function world(opts: { classify?: boolean; announce?: false; stableForMs?: number } = {}): Promise<World> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "standing-"));
|
||||
const receives = join(dir, "postgres-database.json");
|
||||
const w = { dir, failing: null, holdsErr: null, created: [], said: [], all: [] } as unknown as World;
|
||||
const w = { dir, failing: null, holdsErr: null, holdsErrFor: null, created: [], attempts: 0, said: [], all: [] } as unknown as World;
|
||||
w.ask = async (list) => {
|
||||
const given = [];
|
||||
for (const g of list) {
|
||||
@@ -55,13 +59,14 @@ async function world(opts: { classify?: boolean; announce?: false; stableForMs?:
|
||||
};
|
||||
const adapter: Adapter = {
|
||||
async create(p) {
|
||||
w.attempts++;
|
||||
if (w.failing) throw w.failing;
|
||||
w.created.push(p.as);
|
||||
},
|
||||
async remove() {},
|
||||
async retire() {},
|
||||
async holds() {
|
||||
if (w.holdsErr) throw w.holdsErr;
|
||||
async holds(p) {
|
||||
if (w.holdsErr && (!w.holdsErrFor || w.holdsErrFor.has(p.as))) throw w.holdsErr;
|
||||
return true;
|
||||
},
|
||||
...(opts.classify ? { classify: () => "its-own" } : {}),
|
||||
@@ -73,8 +78,9 @@ async function world(opts: { classify?: boolean; announce?: false; stableForMs?:
|
||||
failingAfterMs: FAILING_AFTER, sayAgainEveryMs: SAY_AGAIN, stableForMs: opts.stableForMs ?? 0,
|
||||
...(opts.announce === false ? {} : {
|
||||
announce: (event: string, body: Record<string, unknown>) => {
|
||||
w.all.push({ event, body });
|
||||
if (body.why !== "first-success") w.said.push({ event, body });
|
||||
const said = { event, body, at: Date.now() };
|
||||
w.all.push(said);
|
||||
if (body.why !== "first-success") w.said.push(said);
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -92,10 +98,13 @@ test("a consumer failed for minutes is announced, naming it, its machine and the
|
||||
const w = await world();
|
||||
try {
|
||||
w.failing = new Error(`token request failed: 401 {"error":"invalid_grant"} for pw-a`);
|
||||
// The loop stamps its own clock before it calls announce, so the test's stamp may read a few ms short: 10 ms of slack.
|
||||
// "Not before failingAfterMs" and "not again too soon" are read from the clock at the moment each is said, not
|
||||
// from a sleep: a sleep overruns under load and lets the very announcement it checks the absence of fall due.
|
||||
const asked = Date.now();
|
||||
await w.ask([{ as: "a", node: "anchor" }]);
|
||||
await sleep(FAILING_AFTER / 2);
|
||||
assert.equal(events(w.said), "", "announced before failingAfterMs");
|
||||
await until(() => w.said.length === 1);
|
||||
assert.ok(w.said[0].at - asked >= FAILING_AFTER - 10, `announced after ${w.said[0].at - asked} ms, before failingAfterMs`);
|
||||
const b = w.said[0].body;
|
||||
assert.equal(w.said[0].event, FAILING_EVENT);
|
||||
assert.equal(b.consumer, "a");
|
||||
@@ -103,15 +112,14 @@ test("a consumer failed for minutes is announced, naming it, its machine and the
|
||||
assert.equal(b.class, CLASS_CREDENTIALS);
|
||||
assert.equal(b.provider, "postgres-database");
|
||||
assert.equal(b["provider-node"], "home-server");
|
||||
assert.ok((b.attempts as number) > 3, `attempts ${b.attempts}`);
|
||||
assert.ok((b.attempts as number) > 1, `attempts ${b.attempts}`);
|
||||
assert.match(String(b.since), /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.doesNotMatch(String(b.error), /pw-a/, "the consumer's password travelled");
|
||||
|
||||
// Said again while it lasts, not every pass.
|
||||
await sleep(SAY_AGAIN / 2);
|
||||
assert.equal(events(w.said), FAILING_EVENT, "repeated too soon");
|
||||
await until(() => w.said.length === 2);
|
||||
assert.equal(events(w.said), `${FAILING_EVENT},${FAILING_EVENT}`);
|
||||
assert.ok(w.said[1].at - w.said[0].at >= SAY_AGAIN - 10, `repeated after ${w.said[1].at - w.said[0].at} ms, before sayAgainEveryMs`);
|
||||
|
||||
// The first success takes it back.
|
||||
w.failing = null;
|
||||
@@ -127,15 +135,21 @@ test("a consumer failed for minutes is announced, naming it, its machine and the
|
||||
test("one success between failures starts the run again", async () => {
|
||||
const w = await world();
|
||||
try {
|
||||
w.failing = new Error("connection refused");
|
||||
await w.ask([{ as: "a" }]);
|
||||
await sleep(FAILING_AFTER * 0.6);
|
||||
w.failing = null;
|
||||
// Each failing run lasts two counted failed attempts and at least 0.6 of failingAfterMs of clock (long enough that a success which did not reset the run would be announced at the second run). A run the load
|
||||
// stretched to failingAfterMs is a long run and may be announced, so only runs that stayed short are asserted.
|
||||
const run = async (values?: Record<string, unknown>): Promise<number> => {
|
||||
const start = Date.now();
|
||||
const base = w.attempts;
|
||||
w.failing = new Error("connection refused");
|
||||
await w.ask([{ as: "a", values }]);
|
||||
await until(() => w.attempts >= base + 2 && Date.now() - start >= FAILING_AFTER * 0.6);
|
||||
w.failing = null;
|
||||
return Date.now() - start;
|
||||
};
|
||||
const first = await run();
|
||||
await until(() => w.created.length === 1);
|
||||
w.failing = new Error("connection refused");
|
||||
await w.ask([{ as: "a", values: { name: "changed" } }]);
|
||||
await sleep(FAILING_AFTER * 0.6);
|
||||
assert.equal(events(w.said), "", "two short runs are not one long one");
|
||||
const second = await run({ name: "changed" });
|
||||
if (first < FAILING_AFTER && second < FAILING_AFTER) assert.equal(events(w.said), "", "two short runs are not one long one");
|
||||
} finally {
|
||||
w.stop();
|
||||
}
|
||||
@@ -197,9 +211,11 @@ test("a failing consumer the mesh stopped asking for is said recovered when it i
|
||||
await until(() => w.created.length === 3);
|
||||
w.holdsErr = new Error("boom");
|
||||
await until(() => w.said.filter((s) => s.event === FAILING_EVENT).length >= 3);
|
||||
// c is no longer asked for while it is still failing; the others then recover.
|
||||
// c is no longer asked for while it is still failing; the others then recover. c keeps failing, so its only way
|
||||
// to be said recovered is its retirement: clearing every failure at once raced the loop's read of the new list
|
||||
// under load (a pass before that read recovered c as an ordinary recovery, with no why).
|
||||
await w.ask([{ as: "a" }, { as: "b" }]);
|
||||
w.holdsErr = null;
|
||||
w.holdsErrFor = new Set(["c"]);
|
||||
await until(() => w.said.some((s) => s.event === RECOVERED_EVENT && s.body.consumer === "c"));
|
||||
const c = w.said.find((s) => s.event === RECOVERED_EVENT && s.body.consumer === "c")!;
|
||||
assert.equal(c.body.why, "retired");
|
||||
|
||||
Reference in New Issue
Block a user