Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a073aeae5e | ||
|
|
99407c37df | ||
|
|
9b408db24a | ||
|
|
571ac521f0 | ||
|
|
4a12ccb78d | ||
|
|
cb548a6a58 | ||
|
|
bc16740feb |
@@ -46,10 +46,6 @@ func Key(module string) (string, error) {
|
||||
type State struct {
|
||||
Name string `json:"name"`
|
||||
Readable bool `json:"readable,omitempty"`
|
||||
// Readers, when named, are the only modules that may read the state (novox/hq ADR 0547 clause 9, issue 1945): a
|
||||
// state readable by some and not all, such as the vault's bus account hashes. Only ReadableBy grants them; Readable,
|
||||
// which a composer that knows no readers asks, stays false for it, so such a composer grants nobody.
|
||||
Readers []string `json:"readers,omitempty"`
|
||||
}
|
||||
|
||||
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
|
||||
@@ -71,26 +67,6 @@ func (s States) Keeps(state string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadableBy says whether the module announced the state readable by reader: readable, and reader among the readers
|
||||
// when it names any.
|
||||
func (s States) ReadableBy(state, reader string) bool {
|
||||
for _, k := range s.States {
|
||||
if k.Name != state {
|
||||
continue
|
||||
}
|
||||
if len(k.Readers) > 0 {
|
||||
for _, r := range k.Readers {
|
||||
if r == reader {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return k.Readable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Readable says whether the module announced the state readable by others.
|
||||
func (s States) Readable(state string) bool {
|
||||
for _, k := range s.States {
|
||||
@@ -117,11 +93,6 @@ func (s *States) check() error {
|
||||
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
|
||||
}
|
||||
seen[k.Name] = true
|
||||
for _, r := range k.Readers {
|
||||
if !name.MatchString(r) {
|
||||
return fmt.Errorf("announce: %s names %q as a reader of %s, and a reader is a module: lower-case letters, digits and hyphens", s.Module, r, k.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
// own event subject, in a versioned value the controller keeps by module.
|
||||
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
||||
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
|
||||
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"},
|
||||
{Name: "hashes", Readers: []string{"mesh-grants"}}}})
|
||||
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -25,17 +24,12 @@ func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
|
||||
for _, s := range back.States {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if strings.Join(names, ",") != "assignments,hashes,own,plans-building" {
|
||||
if strings.Join(names, ",") != "assignments,own,plans-building" {
|
||||
t.Fatalf("the states are not sorted: %v", names)
|
||||
}
|
||||
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
|
||||
t.Fatal("Readable does not say what was announced readable")
|
||||
}
|
||||
// Named readers (ADR 0547 clause 9): readable by them alone, and not readable to a composer that knows no readers.
|
||||
if back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
|
||||
!back.ReadableBy("plans-building", "spy") || back.ReadableBy("own", "spy") {
|
||||
t.Fatal("ReadableBy does not say who was named")
|
||||
}
|
||||
if !back.Keeps("own") || back.Keeps("absent") {
|
||||
t.Fatal("Keeps does not say what was announced")
|
||||
}
|
||||
@@ -63,7 +57,6 @@ func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
|
||||
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
|
||||
{States{Module: "a", States: []State{{Name: ""}}}, `""`},
|
||||
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
|
||||
{States{Module: "a", States: []State{{Name: "x", Readers: []string{"b.c"}}}}, `"b.c"`},
|
||||
} {
|
||||
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else
|
||||
// there (novox/hq ADR 0553, issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu
|
||||
// usage never impacts the normal workings of our nodes ... We have 3 build agents so they should share the cpu power").
|
||||
//
|
||||
// Three parts, each with one owner:
|
||||
//
|
||||
// - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest priority there is: the composer
|
||||
// gives its runtime process declaration.Yields, and the node-engine places every yielding process of the node in
|
||||
// one slice (declaration.YieldSlice) whose CPU weight is idle, with Nice=19 for every process it starts.
|
||||
// Interactive and service work always comes first; builds take the cycles nothing else wants.
|
||||
// - **One cap per node, shared.** The node's share of its processors is one value, the node-engine's own config
|
||||
// `build-cpu-share` (ShareSetting), set per node through the node's settings layer. The node-engine writes it as the
|
||||
// slice's CPUQuota: one bound the kernel holds for all the node's build modules together, never one per module.
|
||||
// - **Threads.** The quota bounds the total; threads only size each step. A step gets the share divided by its own
|
||||
// module's capacity (Threads), from the share the composer hands as a word (ShareWord): GOMAXPROCS sets every go
|
||||
// command's -p, the compiler's concurrency, a test binary's processors and -parallel. A module given no word sets
|
||||
// no thread limit at all: without the slice there is nothing to size a step to, and a limit then would only slow
|
||||
// builds.
|
||||
package buildcpu
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ShareSetting is the node-engine's config value of the node's processor share for its builds, in percent of the
|
||||
// machine's processors, 1 to 100: one value per node, set through the node's settings layer (novox/hq ADR 0553 §3).
|
||||
const ShareSetting = "build-cpu-share"
|
||||
|
||||
// DefaultPercent is the share where nothing sets one: half the machine, so a desktop keeps half its processors for
|
||||
// itself whatever builds.
|
||||
const DefaultPercent = 50
|
||||
|
||||
// ShareWord is the word the composer gives every yielding module (novox/hq ADR 0553 §4): the node's share, in percent.
|
||||
const ShareWord = "MESH_BUILD_CPU_SHARE"
|
||||
|
||||
// Quota is a CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of one
|
||||
// processor, so half of 24 is 1200%. At least 100%, one processor, so a share on a small machine never stops a step.
|
||||
func Quota(percent, cores int) int {
|
||||
q := percent * cores
|
||||
if q < 100 {
|
||||
q = 100
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// Threads is how many threads one step may run at once: its part of the share when its own module runs slots steps at
|
||||
// once, at least one. The slice's quota bounds the steps of all the node's build modules together; this only sizes one.
|
||||
func Threads(percent, cores, slots int) int {
|
||||
if slots < 1 {
|
||||
slots = 1
|
||||
}
|
||||
if percent < 1 || percent > 100 {
|
||||
percent = DefaultPercent
|
||||
}
|
||||
t := cores * percent / 100 / slots
|
||||
if t < 1 {
|
||||
t = 1
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Limits is what one step is held to on this node, as a status tool says it.
|
||||
type Limits struct {
|
||||
// Percent is the node's share in percent of its processors, and From where it was read: ShareWord, or "none" when
|
||||
// the composer gave no share and a step runs without a thread limit.
|
||||
Percent int `json:"share-percent,omitempty"`
|
||||
From string `json:"share-from"`
|
||||
// Cores is the machine's processor count.
|
||||
Cores int `json:"cores"`
|
||||
// Slots is this module's own capacity on the node: steps at once.
|
||||
Slots int `json:"slots"`
|
||||
// Threads is what each step is given (Threads); 0 is no limit.
|
||||
Threads int `json:"threads-per-step"`
|
||||
}
|
||||
|
||||
// FromWords is the limits of a step from the share the composer gave this process (ShareWord) and the module's own
|
||||
// capacity. Without a usable share there is no limit: Threads is 0.
|
||||
func FromWords(getenv func(string) string, own int) Limits {
|
||||
if own < 1 {
|
||||
own = 1
|
||||
}
|
||||
l := Limits{From: "none", Cores: runtime.NumCPU(), Slots: own}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(getenv(ShareWord))); err == nil && n >= 1 && n <= 100 {
|
||||
l.Percent, l.From = n, ShareWord
|
||||
l.Threads = Threads(n, l.Cores, own)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every
|
||||
// Go program (the compiler, a test binary, esbuild, buildah), UV_THREADPOOL_SIZE for Node's worker pool. Returned as
|
||||
// KEY=value words to append to a command's environment; nothing for 0 threads, no limit.
|
||||
func Env(threads int) []string {
|
||||
if threads < 1 {
|
||||
return nil
|
||||
}
|
||||
t := strconv.Itoa(threads)
|
||||
return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t}
|
||||
}
|
||||
|
||||
// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice
|
||||
// level, its cgroup (the unit) and the cgroup's parent (the slice the node-engine placed it in), each one's CPU weight,
|
||||
// idle mark and quota. A field the machine does not answer is left empty and Unread says which.
|
||||
//
|
||||
// The IO weight is read but binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under
|
||||
// the scheduler `none`, the usual one for NVMe, it does nothing, and the CPU bound is what holds.
|
||||
type InForce struct {
|
||||
Nice int `json:"nice"`
|
||||
Cgroup string `json:"cgroup,omitempty"`
|
||||
Unit Group `json:"unit"`
|
||||
Slice Group `json:"slice"`
|
||||
// Quota is the tighter of the two groups' cpu.max as a percent of one processor, 0 with none.
|
||||
Quota int `json:"quota-percent,omitempty"`
|
||||
Unread []string `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// Group is one cgroup's controls.
|
||||
type Group struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
CPUIdle string `json:"cpu-idle,omitempty"`
|
||||
CPUWeight string `json:"cpu-weight,omitempty"`
|
||||
// CPUMax is the cgroup's cpu.max: "<quota µs> <period µs>", or "max" with no quota.
|
||||
CPUMax string `json:"cpu-max,omitempty"`
|
||||
IOWeight string `json:"io-weight,omitempty"`
|
||||
}
|
||||
|
||||
// cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere.
|
||||
var cgroupRoot = "/sys/fs/cgroup"
|
||||
|
||||
// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere.
|
||||
var procSelfCgroup = "/proc/self/cgroup"
|
||||
|
||||
// procSelfStat is this process's stat file. A variable so a test can point it elsewhere.
|
||||
var procSelfStat = "/proc/self/stat"
|
||||
|
||||
// ReadInForce reads what the kernel holds this process to.
|
||||
func ReadInForce() InForce {
|
||||
var f InForce
|
||||
f.Nice = nice()
|
||||
raw, err := os.ReadFile(procSelfCgroup)
|
||||
if err != nil {
|
||||
f.Unread = append(f.Unread, "cgroup: "+err.Error())
|
||||
return f
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
if strings.HasPrefix(line, "0::") {
|
||||
f.Cgroup = strings.TrimPrefix(line, "0::")
|
||||
}
|
||||
}
|
||||
if f.Cgroup == "" {
|
||||
f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy")
|
||||
return f
|
||||
}
|
||||
read := func(path string) Group {
|
||||
g := Group{Path: path}
|
||||
value := func(name string) string {
|
||||
b, err := os.ReadFile(cgroupRoot + path + "/" + name)
|
||||
if err != nil {
|
||||
f.Unread = append(f.Unread, path+"/"+name+": "+err.Error())
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
g.CPUIdle, g.CPUWeight, g.CPUMax, g.IOWeight = value("cpu.idle"), value("cpu.weight"), value("cpu.max"), value("io.weight")
|
||||
return g
|
||||
}
|
||||
f.Unit = read(f.Cgroup)
|
||||
if i := strings.LastIndex(f.Cgroup, "/"); i > 0 {
|
||||
f.Slice = read(f.Cgroup[:i])
|
||||
}
|
||||
for _, q := range []int{quotaOf(f.Unit.CPUMax), quotaOf(f.Slice.CPUMax)} {
|
||||
if q > 0 && (f.Quota == 0 || q < f.Quota) {
|
||||
f.Quota = q
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// quotaOf is a cpu.max value as a percent of one processor: "1200000 100000" is 1200; "max ..." or nothing is 0.
|
||||
func quotaOf(cpuMax string) int {
|
||||
fields := strings.Fields(cpuMax)
|
||||
if len(fields) != 2 || fields[0] == "max" {
|
||||
return 0
|
||||
}
|
||||
q, err1 := strconv.Atoi(fields[0])
|
||||
p, err2 := strconv.Atoi(fields[1])
|
||||
if err1 != nil || err2 != nil || p <= 0 {
|
||||
return 0
|
||||
}
|
||||
return q * 100 / p
|
||||
}
|
||||
|
||||
// nice is this process's nice level from its stat file (the 19th field, counted after the command's closing
|
||||
// parenthesis, which may itself hold spaces); 0 when it cannot be read.
|
||||
func nice() int {
|
||||
raw, err := os.ReadFile(procSelfStat)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
s := string(raw)
|
||||
i := strings.LastIndex(s, ")")
|
||||
if i < 0 {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(s[i+1:])
|
||||
// After the command: state is field 3, so nice (field 19) is index 16.
|
||||
if len(fields) < 17 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(fields[16])
|
||||
return n
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package buildcpu
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The node's share as a quota, the threads one step is given for its own module's capacity, and the word the composer
|
||||
// hands, in one table (ADR 0553, issue 1806).
|
||||
func TestShareQuotaAndThreads(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
percent, cores, slots int
|
||||
quota, threads int
|
||||
}{
|
||||
{"shanks: half of 24, three slots", 50, 24, 3, 1200, 4},
|
||||
{"ace: three quarters of 24, two slots", 75, 24, 2, 1800, 9},
|
||||
{"g14: half of 16, one slot", 50, 16, 1, 800, 8},
|
||||
{"one processor at least", 10, 2, 4, 100, 1},
|
||||
{"no slots counts as one", 100, 4, 0, 400, 4},
|
||||
{"a share out of range is the default", 0, 8, 1, 0, 4},
|
||||
} {
|
||||
if c.quota != 0 {
|
||||
if q := Quota(c.percent, c.cores); q != c.quota {
|
||||
t.Errorf("%s: Quota = %d, want %d", c.name, q, c.quota)
|
||||
}
|
||||
}
|
||||
if th := Threads(c.percent, c.cores, c.slots); th != c.threads {
|
||||
t.Errorf("%s: Threads = %d, want %d", c.name, th, c.threads)
|
||||
}
|
||||
}
|
||||
l := FromWords(func(k string) string { return map[string]string{ShareWord: "75"}[k] }, 2)
|
||||
if l.Percent != 75 || l.From != ShareWord || l.Slots != 2 || l.Threads != Threads(75, runtime.NumCPU(), 2) {
|
||||
t.Errorf("from the word: %+v", l)
|
||||
}
|
||||
// Told nothing usable, a step has no thread limit: nothing in its environment.
|
||||
for _, word := range []string{"", "250", "lots"} {
|
||||
l := FromWords(func(string) string { return word }, 3)
|
||||
if l.Threads != 0 || l.From != "none" || Env(l.Threads) != nil {
|
||||
t.Errorf("share %q: %+v, env %v", word, l, Env(l.Threads))
|
||||
}
|
||||
}
|
||||
if got := Env(3); !reflect.DeepEqual(got, []string{"GOMAXPROCS=3", "UV_THREADPOOL_SIZE=3"}) {
|
||||
t.Errorf("Env = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// What the kernel holds a process to is read from its unit's and its slice's cgroup and its stat file, not assumed.
|
||||
func TestReadInForce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cg := filepath.Join(dir, "cg")
|
||||
slice := filepath.Join(cg, "meshyield.slice")
|
||||
unit := filepath.Join(slice, "mesh-build-go-runtime.service")
|
||||
if err := os.MkdirAll(unit, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for at, files := range map[string]map[string]string{
|
||||
slice: {"cpu.idle": "1\n", "cpu.weight": "0\n", "cpu.max": "1200000 100000\n", "io.weight": "default 1\n"},
|
||||
unit: {"cpu.idle": "0\n", "cpu.weight": "100\n", "cpu.max": "max 100000\n", "io.weight": "default 100\n"},
|
||||
} {
|
||||
for name, v := range files {
|
||||
if err := os.WriteFile(filepath.Join(at, name), []byte(v), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
self := filepath.Join(dir, "cgroup")
|
||||
stat := filepath.Join(dir, "stat")
|
||||
_ = os.WriteFile(self, []byte("0::/meshyield.slice/mesh-build-go-runtime.service\n"), 0o644)
|
||||
// A command with a space and a parenthesis in it, then nice 19 in field 19.
|
||||
_ = os.WriteFile(stat, []byte("42 (mesh (build) go) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 39 19 8 0 1 2 3\n"), 0o644)
|
||||
cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat
|
||||
t.Cleanup(func() {
|
||||
cgroupRoot, procSelfCgroup, procSelfStat = "/sys/fs/cgroup", "/proc/self/cgroup", "/proc/self/stat"
|
||||
})
|
||||
|
||||
got := ReadInForce()
|
||||
want := InForce{Nice: 19, Cgroup: "/meshyield.slice/mesh-build-go-runtime.service",
|
||||
Unit: Group{Path: "/meshyield.slice/mesh-build-go-runtime.service", CPUIdle: "0", CPUWeight: "100",
|
||||
CPUMax: "max 100000", IOWeight: "default 100"},
|
||||
Slice: Group{Path: "/meshyield.slice", CPUIdle: "1", CPUWeight: "0", CPUMax: "1200000 100000",
|
||||
IOWeight: "default 1"},
|
||||
Quota: 1200}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ReadInForce = %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
+30
-46
@@ -8,6 +8,7 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -34,9 +35,6 @@ type AskSteps struct {
|
||||
// the module's sweep.
|
||||
Release func(workspace string) error
|
||||
Logf func(format string, args ...any)
|
||||
// Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing
|
||||
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
|
||||
Has func(need string) bool
|
||||
}
|
||||
|
||||
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
||||
@@ -70,11 +68,6 @@ 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
|
||||
@@ -131,7 +124,6 @@ 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 {
|
||||
@@ -141,7 +133,6 @@ 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 {
|
||||
@@ -180,16 +171,36 @@ func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error)
|
||||
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
|
||||
runner = job.Runner
|
||||
}
|
||||
for _, level := range job.Levels {
|
||||
la := delivery.LevelAnswer{Level: level}
|
||||
passed, err := s.level(ctx, job, level, f, runner, &la)
|
||||
answer.Levels = append(answer.Levels, la)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !passed {
|
||||
return answer, nil // the first level that did not pass ends the check (ADR 0484 clause 13)
|
||||
// The levels run side by side (issue 1921, try speed S10; the operator's yes to D1, ADR 0541): the integration level
|
||||
// does not wait for the unit level. They are answered in their order, so the first level that did not pass is still
|
||||
// the first failure the asker reads (ADR 0484 clause 13); the levels after it are kept in the answer for the log.
|
||||
type ran struct {
|
||||
la delivery.LevelAnswer
|
||||
passed bool
|
||||
err error
|
||||
}
|
||||
runs := make([]ran, len(job.Levels))
|
||||
var wg sync.WaitGroup
|
||||
for i, level := range job.Levels {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
runs[i].la = delivery.LevelAnswer{Level: level}
|
||||
runs[i].passed, runs[i].err = s.level(ctx, job, level, f, runner, &runs[i].la)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
failed := false
|
||||
for _, r := range runs {
|
||||
if r.err != nil {
|
||||
if failed {
|
||||
continue // a level after the first failure that could not run: the failure is the answer
|
||||
}
|
||||
answer.Levels = append(answer.Levels, r.la)
|
||||
return nil, r.err
|
||||
}
|
||||
answer.Levels = append(answer.Levels, r.la)
|
||||
failed = failed || !r.passed
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
@@ -203,13 +214,6 @@ 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
|
||||
@@ -248,9 +252,6 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
|
||||
if !keepRunner {
|
||||
runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2)
|
||||
}
|
||||
if whole && len(job.Packages) > 0 {
|
||||
todo, whole = job.Packages, false // a placed ask tests its packages alone, whatever the digest said (ADR 0552 §4)
|
||||
}
|
||||
for _, priority := range []bool{true, false} {
|
||||
args := withRunner(map[string]any{"workspace": f.Workspace, "level": level})
|
||||
if job.Run != "" {
|
||||
@@ -301,7 +302,6 @@ 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 {
|
||||
@@ -328,16 +328,9 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string,
|
||||
return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole
|
||||
}
|
||||
la.Digest = &d
|
||||
only := map[string]bool{}
|
||||
for _, p := range job.Packages {
|
||||
only[p] = true
|
||||
}
|
||||
var todo []string
|
||||
for _, pd := range d.Packages {
|
||||
p := pd.Package
|
||||
if len(only) > 0 && !only[p] {
|
||||
continue // a placed ask's other packages were judged by the first ask (ADR 0552 §4)
|
||||
}
|
||||
if job.Kept && s.Kept != nil {
|
||||
tests, found, err := s.Kept(ctx, level, pd.Digest)
|
||||
if err != nil && s.Logf != nil {
|
||||
@@ -348,15 +341,6 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string,
|
||||
continue
|
||||
}
|
||||
}
|
||||
if needs := d.NeedsOf(p); len(delivery.Lacks(needs, s.Has)) > 0 {
|
||||
// Its tests would only skip here (issue 890): it is left for a node that has what they need.
|
||||
la.Unmet = append(la.Unmet, delivery.PackageNeeds{Package: p, Needs: needs})
|
||||
if s.Logf != nil {
|
||||
s.Logf("the %s level of %s is left to a node with %s: this node lacks %s", level, p,
|
||||
strings.Join(needs, ", "), strings.Join(delivery.Lacks(needs, s.Has), ", "))
|
||||
}
|
||||
continue
|
||||
}
|
||||
todo = append(todo, p)
|
||||
}
|
||||
return todo, false, false
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 1921/S10 (the operator's yes to D1, ADR 0541): a check's levels run side by side: the integration level has started
|
||||
// before the unit level ends. The answer holds the levels in their order, so with the unit level failing it still names
|
||||
// the unit level first; the integration level's answer is kept after it for the log.
|
||||
func TestACheckRunsItsLevelsSideBySideAndAnswersThemInOrder(t *testing.T) {
|
||||
integrationStarted := make(chan struct{})
|
||||
var once sync.Once
|
||||
var mu sync.Mutex
|
||||
unitWaited := false
|
||||
steps := AskSteps{
|
||||
Fetch: func(context.Context, map[string]any) (any, error) {
|
||||
return map[string]any{"workspace": "/w", "modules": []string{"."}}, nil
|
||||
},
|
||||
Test: func(ctx context.Context, args map[string]any) (any, error) {
|
||||
switch args["level"] {
|
||||
case delivery.LevelUnit:
|
||||
select {
|
||||
case <-integrationStarted:
|
||||
mu.Lock()
|
||||
unitWaited = true
|
||||
mu.Unlock()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
return map[string]any{"passed": false, "level": "unit"}, nil
|
||||
default:
|
||||
once.Do(func() { close(integrationStarted) })
|
||||
return map[string]any{"passed": true, "level": "integration"}, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !unitWaited {
|
||||
t.Error("the integration level did not start while the unit level ran")
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 2 || a.Levels[0].Level != delivery.LevelUnit || a.Levels[1].Level != delivery.LevelIntegration {
|
||||
t.Fatalf("levels %+v, want unit then integration", a.Levels)
|
||||
}
|
||||
var first struct{ Passed bool }
|
||||
if err := json.Unmarshal(a.Levels[0].Tests[0], &first); err != nil || first.Passed {
|
||||
t.Errorf("the first level's result %s, want the unit level's failure", a.Levels[0].Tests[0])
|
||||
}
|
||||
}
|
||||
+21
-51
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -16,18 +17,20 @@ const runCommit = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
// recorder is a build module's step tools that record each call.
|
||||
type recorder struct {
|
||||
mu sync.Mutex // a check's levels run side by side (issue 1921, S10)
|
||||
calls []string
|
||||
fetch map[string]any
|
||||
tests map[string]bool // package or module directory -> passed
|
||||
digests map[string]string
|
||||
needs []delivery.PackageNeeds
|
||||
}
|
||||
|
||||
func (r *recorder) steps() AskSteps {
|
||||
call := func(name string, answer func(args map[string]any) any) StepFunc {
|
||||
return func(ctx context.Context, args map[string]any) (any, error) {
|
||||
b, _ := json.Marshal(args)
|
||||
r.mu.Lock()
|
||||
r.calls = append(r.calls, name+" "+string(b))
|
||||
r.mu.Unlock()
|
||||
return answer(args), nil
|
||||
}
|
||||
}
|
||||
@@ -36,10 +39,8 @@ func (r *recorder) steps() AskSteps {
|
||||
Build: call("build", func(args map[string]any) any {
|
||||
return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)}
|
||||
}),
|
||||
Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }),
|
||||
Digest: call("digest", func(map[string]any) any {
|
||||
return map[string]any{"level": "unit", "packages": r.digests, "needs": r.needs}
|
||||
}),
|
||||
Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }),
|
||||
Digest: call("digest", func(map[string]any) any { return map[string]any{"level": "unit", "packages": r.digests} }),
|
||||
Test: call("test", func(args map[string]any) any {
|
||||
passed := true
|
||||
if pkgs, ok := args["packages"].([]string); ok {
|
||||
@@ -113,8 +114,8 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
||||
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921), and runs no
|
||||
// level after one that did not pass (ADR 0484 clause 13).
|
||||
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921); the levels run
|
||||
// side by side and are answered in order, the first that did not pass first (ADR 0484 clause 13; issue 1921 S10).
|
||||
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
r := &recorder{
|
||||
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
|
||||
@@ -126,7 +127,13 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(r.calls, "\n")
|
||||
var unit []string
|
||||
for _, c := range r.calls {
|
||||
if !strings.Contains(c, `"level":"integration"`) {
|
||||
unit = append(unit, c)
|
||||
}
|
||||
}
|
||||
got := strings.Join(unit, "\n")
|
||||
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
|
||||
digest {"level":"unit","workspace":"/w"}
|
||||
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
|
||||
@@ -135,50 +142,13 @@ test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 1 {
|
||||
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
|
||||
if len(a.Levels) != 2 || a.Levels[1].Level != delivery.LevelIntegration {
|
||||
t.Fatalf("levels %+v, want the unit level first, the integration level after it", a.Levels)
|
||||
}
|
||||
u := a.Levels[0]
|
||||
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
|
||||
t.Fatalf("the unit level %+v", u)
|
||||
}
|
||||
|
||||
// Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is
|
||||
// tested on a node that has it.
|
||||
for _, has := range []bool{false, true} {
|
||||
r.calls, r.tests["x/pkg/b"] = nil, true
|
||||
r.needs = []delivery.PackageNeeds{{Package: "x/pkg/a", Needs: []string{delivery.NeedNodeBuildNode}}}
|
||||
steps := r.steps()
|
||||
if has {
|
||||
steps.Has = func(n string) bool { return n == delivery.NeedNodeBuildNode }
|
||||
}
|
||||
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := out.(delivery.CheckAnswer).Levels[0]
|
||||
tested := strings.Contains(strings.Join(r.calls, "\n"), `"x/pkg/a"`)
|
||||
if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || delivery.UnmetNeeds([]delivery.LevelAnswer{u})[0] != delivery.NeedNodeBuildNode)) {
|
||||
t.Fatalf("has %v: tested x/pkg/a %v, unmet %+v, calls %v", has, tested, u.Unmet, r.calls)
|
||||
}
|
||||
}
|
||||
// A placed ask naming its packages tests those alone and takes no other's kept verdict, also when the level would
|
||||
// run whole (ADR 0552 §4).
|
||||
for _, digests := range []map[string]string{r.digests, nil} {
|
||||
r.calls, r.digests = nil, digests
|
||||
steps := r.steps()
|
||||
steps.Has = func(string) bool { return true }
|
||||
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"},
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := out.(delivery.CheckAnswer).Levels[0]
|
||||
if last := r.calls[len(r.calls)-1]; !strings.HasSuffix(last, `"packages":["x/pkg/a"],"workspace":"/w"}`) || len(u.Kept) != 0 || len(u.Tests) != 1 {
|
||||
t.Fatalf("digests %v: calls %v, kept %+v: want x/pkg/a alone tested, nothing taken kept", digests != nil, r.calls, u.Kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says
|
||||
@@ -260,8 +230,8 @@ func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := out.(delivery.CheckAnswer)
|
||||
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
|
||||
if len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 || a.Levels[0].Level != delivery.LevelIntegration {
|
||||
t.Fatalf("levels %+v, want the first level with both packages' results, in the order asked", a.Levels)
|
||||
}
|
||||
for i, raw := range a.Levels[0].Tests {
|
||||
var res struct{ Passed bool }
|
||||
@@ -279,7 +249,7 @@ func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
|
||||
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
|
||||
if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 || a.Levels[0].Level != delivery.LevelUnit {
|
||||
t.Fatalf("calls %v, levels %+v: want both directories of each level, the unit level first", r.calls, a.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
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, ""
|
||||
}
|
||||
}
|
||||
@@ -42,20 +42,11 @@ func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func()
|
||||
}
|
||||
setting, _ = s[CapacitySetting].(string)
|
||||
}
|
||||
n, err := capacityOf(setting, node)
|
||||
caps, err := buildrun.ParseCapacity(setting)
|
||||
if err != nil {
|
||||
say("%v, so %s pulls nothing", err, node)
|
||||
say("%v, so %s pulls nothing", fmt.Errorf("the build capacity: %w", err), node)
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
return (&buildrun.Places{Capacity: caps}).CapacityOf(node)
|
||||
}
|
||||
}
|
||||
|
||||
// capacityOf is node's capacity in a setting's `<node>=<asks>` pairs: DefaultCapacity for a node it does not name.
|
||||
func capacityOf(setting, node string) (int, error) {
|
||||
caps, err := buildrun.ParseCapacity(setting)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("the build capacity: %w", err)
|
||||
}
|
||||
return (&buildrun.Places{Capacity: caps}).CapacityOf(node), nil
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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,9 +3,7 @@ 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
|
||||
@@ -38,100 +36,3 @@ func TestNodeCapacity(t *testing.T) {
|
||||
t.Errorf("a missing file = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 TestNodeLimitsAndTheCPUBound(t *testing.T) {
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "")
|
||||
if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 {
|
||||
t.Errorf("without the word: %+v", l)
|
||||
}
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "75")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||
)
|
||||
|
||||
// NodeLimits is what one step of a build module is held to (novox/hq ADR 0553 §4): the node's processor share from the
|
||||
// word the composer gives the module (buildcpu.ShareWord), divided by the module's own capacity, own, over the machine's
|
||||
// processors. Without the word, no thread limit: Threads is 0.
|
||||
func NodeLimits(own int) buildcpu.Limits {
|
||||
return buildcpu.FromWords(os.Getenv, own)
|
||||
}
|
||||
+11
-6
@@ -71,7 +71,7 @@ func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit,
|
||||
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
||||
r := run(ctx, env, "git", full...)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
return r.Stdout, nil
|
||||
}
|
||||
@@ -128,11 +128,7 @@ func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, comm
|
||||
git := func(args ...string) error {
|
||||
r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
verb := args[0]
|
||||
if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C
|
||||
verb = args[2]
|
||||
}
|
||||
return fmt.Errorf("git %s: %s", verb, forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
return fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -194,3 +190,12 @@ func checkFetch(o fetchOptions, dir, remote, commit, ref string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+114
-10
@@ -3,10 +3,13 @@ package buildstep
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
@@ -29,16 +32,25 @@ const Bubblewrap = "bwrap"
|
||||
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
|
||||
// downloaded) read-only;
|
||||
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
|
||||
// (WriteCaches), so a candidate's code never changes what a later build reads.
|
||||
// (WriteCaches), so a candidate's code never changes what a later build reads;
|
||||
// - with a CacheLayer, what one command writes into the caches kept for the later commands of the same ask (issue 1921,
|
||||
// the try speed-up): the vet, the unit tests and the integration compile of one ask compile each package once.
|
||||
type Isolation struct {
|
||||
Workspace string
|
||||
Writable []string
|
||||
ReadOnly []string
|
||||
Caches []string
|
||||
WriteCaches bool
|
||||
Env []string
|
||||
// CacheLayer is a directory of the ask's own, in its workspace and gone with it: each command writes the caches into
|
||||
// a layer of its own beneath it (overlayfs refuses an upper layer two mounts share, and an ask's commands run side by
|
||||
// side), which Isolated moves into the ask's kept layer when the command ends; every later command reads the kept
|
||||
// layer above the shared cache. The shared cache stays unchanged. Empty: what a command writes is thrown away.
|
||||
CacheLayer string
|
||||
Env []string
|
||||
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
|
||||
Hosts string
|
||||
// run is the directory of this command's own writable layers under CacheLayer; Isolated makes one per command.
|
||||
run string
|
||||
}
|
||||
|
||||
// etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the
|
||||
@@ -91,7 +103,7 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
|
||||
for _, group := range []struct {
|
||||
what string
|
||||
dirs []string
|
||||
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} {
|
||||
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}, {"cache layer", layerDirs(iso.CacheLayer)}} {
|
||||
for _, d := range group.dirs {
|
||||
if err := checkDir(group.what, d); err != nil {
|
||||
return nil, err
|
||||
@@ -113,7 +125,7 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
|
||||
for _, d := range iso.ReadOnly {
|
||||
argv = append(argv, "--ro-bind", d, d)
|
||||
}
|
||||
argv = append(argv, cacheArgs(iso.Caches, iso.WriteCaches)...)
|
||||
argv = append(argv, iso.cacheArgs()...)
|
||||
argv = append(argv, "--bind", iso.Workspace, iso.Workspace)
|
||||
for _, d := range iso.Writable {
|
||||
argv = append(argv, "--bind", d, d)
|
||||
@@ -134,19 +146,107 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
|
||||
|
||||
// cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other
|
||||
// read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and
|
||||
// nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace).
|
||||
func cacheArgs(caches []string, write bool) []string {
|
||||
// nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace). With a
|
||||
// CacheLayer the ask's kept layer lies above the shared cache and the command's writes go to its own layer on disk
|
||||
// (--overlay), which Isolated keeps for the ask when the command ends.
|
||||
func (iso Isolation) cacheArgs() []string {
|
||||
var argv []string
|
||||
for _, c := range caches {
|
||||
if write {
|
||||
for i, c := range iso.Caches {
|
||||
switch {
|
||||
case iso.WriteCaches:
|
||||
argv = append(argv, "--bind", c, c)
|
||||
continue
|
||||
case iso.CacheLayer != "":
|
||||
upper, work := iso.runLayer(i)
|
||||
argv = append(argv, "--overlay-src", c, "--overlay-src", keptLayer(iso.CacheLayer, i), "--overlay", upper, work, c)
|
||||
default:
|
||||
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
|
||||
}
|
||||
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
func layerDirs(layer string) []string {
|
||||
if layer == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{layer}
|
||||
}
|
||||
|
||||
// keptLayer is where the ask keeps what its commands wrote into the cache i.
|
||||
func keptLayer(layer string, i int) string { return filepath.Join(layer, "kept", strconv.Itoa(i)) }
|
||||
|
||||
// runLayer is the upper and work directory of this command's writes into the cache i. Command, which makes no
|
||||
// directory, names the layer "run"; Isolated gives each command one of its own.
|
||||
func (iso Isolation) runLayer(i int) (upper, work string) {
|
||||
run := iso.run
|
||||
if run == "" {
|
||||
run = filepath.Join(iso.CacheLayer, "run")
|
||||
}
|
||||
return filepath.Join(run, strconv.Itoa(i), "upper"), filepath.Join(run, strconv.Itoa(i), "work")
|
||||
}
|
||||
|
||||
// layerLocks hold one mutex per cache layer, so two commands ending at once keep their writes one after the other.
|
||||
var layerLocks sync.Map
|
||||
|
||||
// prepareLayer makes the kept layers and a run directory of the command's own under the cache layer.
|
||||
func (iso *Isolation) prepareLayer() error {
|
||||
if iso.CacheLayer == "" || iso.WriteCaches || len(iso.Caches) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(iso.CacheLayer, "runs"), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
run, err := os.MkdirTemp(filepath.Join(iso.CacheLayer, "runs"), "run-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iso.run = run
|
||||
for i := range iso.Caches {
|
||||
upper, work := iso.runLayer(i)
|
||||
for _, d := range []string{keptLayer(iso.CacheLayer, i), upper, work} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keepLayer moves what the command wrote into the caches into the ask's kept layers, and removes its run directory.
|
||||
// A file the command wrote replaces the kept one (a cache's newest write wins); a whiteout (a file the command
|
||||
// deleted) is not kept, since a cache entry deleted by one command is no fault of a later one. A write that cannot be
|
||||
// kept only means a later command builds it again.
|
||||
func (iso Isolation) keepLayer() {
|
||||
if iso.run == "" {
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(iso.run)
|
||||
mu, _ := layerLocks.LoadOrStore(iso.CacheLayer, &sync.Mutex{})
|
||||
mu.(*sync.Mutex).Lock()
|
||||
defer mu.(*sync.Mutex).Unlock()
|
||||
for i := range iso.Caches {
|
||||
upper, _ := iso.runLayer(i)
|
||||
kept := keptLayer(iso.CacheLayer, i)
|
||||
filepath.WalkDir(upper, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || p == upper {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(upper, p)
|
||||
to := filepath.Join(kept, rel)
|
||||
switch {
|
||||
case d.IsDir():
|
||||
if fi, err := os.Lstat(to); err == nil && !fi.IsDir() {
|
||||
os.Remove(to)
|
||||
}
|
||||
os.MkdirAll(to, 0o700)
|
||||
case d.Type().IsRegular():
|
||||
os.Rename(p, to)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loopbackHosts is the hosts file of a step: the loopback and nothing else.
|
||||
const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n"
|
||||
|
||||
@@ -166,6 +266,10 @@ func Isolated(run forgekey.Runner, iso Isolation) forgekey.Runner {
|
||||
}
|
||||
iso := iso
|
||||
iso.Hosts = hosts
|
||||
if err := iso.prepareLayer(); err != nil {
|
||||
return forgekey.Ran{Status: 1, Err: "the step's cache layer: " + err.Error()}
|
||||
}
|
||||
defer iso.keepLayer()
|
||||
argv, err := iso.command(env, name, args...)
|
||||
if err != nil {
|
||||
return forgekey.Ran{Status: 1, Err: err.Error()}
|
||||
|
||||
@@ -64,3 +64,39 @@ func TestARunStepReachesNothingAndLeavesTheCachesUnchanged(t *testing.T) {
|
||||
t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S2: in one ask (one cache layer), a second go build of the same package does not compile it again, and the
|
||||
// shared cache is unchanged afterwards.
|
||||
func TestASecondBuildOfTheAskFindsTheFirstsCompiledAndTheSharedCacheIsUnchanged(t *testing.T) {
|
||||
if _, err := exec.LookPath(Bubblewrap); err != nil {
|
||||
t.Fatalf("bubblewrap is not installed here (issue 1309): %v", err)
|
||||
}
|
||||
goBin, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
shared := filepath.Join(root, "gobuild")
|
||||
if err := os.MkdirAll(shared, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workspace := filepath.Join(root, "ws")
|
||||
tree := filepath.Join(workspace, "tree")
|
||||
writeFile(t, tree, "go.mod", "module probe\n\ngo 1.22\n")
|
||||
writeFile(t, tree, "probe.go", "package probe\n\nfunc Probe() int { return 42 }\n")
|
||||
run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{shared},
|
||||
CacheLayer: filepath.Join(workspace, "cachelayer"), ReadOnly: []string{filepath.Dir(filepath.Dir(goBin))}})
|
||||
env := []string{"PATH=" + filepath.Dir(goBin) + ":/usr/bin:/bin", "GOCACHE=" + shared, "GOTOOLCHAIN=local", "GOFLAGS=-mod=mod"}
|
||||
for i, want := range []bool{true, false} {
|
||||
r := run(context.Background(), env, "go", "-C", tree, "build", "-x", "./...")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
t.Fatalf("build %d: status %d %s\n%s", i+1, r.Status, r.Err, r.Stderr)
|
||||
}
|
||||
if compiled := strings.Contains(r.Stderr, "/compile "); compiled != want {
|
||||
t.Errorf("build %d compiled the package: %v, want %v\n%s", i+1, compiled, want, r.Stderr)
|
||||
}
|
||||
}
|
||||
if entries, _ := os.ReadDir(shared); len(entries) != 0 {
|
||||
t.Errorf("the shared cache was written: %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// overlayOf answers the upper directory a command line gives the cache c, "" when it gives none.
|
||||
func overlayOf(argv []string, c string) string {
|
||||
for i := 0; i+3 < len(argv); i++ {
|
||||
if argv[i] == "--overlay" && argv[i+3] == c {
|
||||
return argv[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 1921/S2: with a cache layer, a command reads the ask's kept layer above the shared cache and writes into a layer on
|
||||
// disk of its own, never into the shared cache and never into a throwaway tmpfs.
|
||||
func TestACommandWithACacheLayerReadsTheKeptLayerAndWritesItsOwn(t *testing.T) {
|
||||
fixedSystem(t)
|
||||
iso := Isolation{Workspace: "/w", Caches: []string{"/cache/gomod", "/cache/gobuild"}, CacheLayer: "/w/cachelayer"}
|
||||
argv, err := iso.Command("go", "vet")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, c := range iso.Caches {
|
||||
if !holds(argv, "--overlay-src", c, "--overlay-src", keptLayer(iso.CacheLayer, i), "--overlay") {
|
||||
t.Errorf("the cache %s is not read beneath the ask's kept layer: %v", c, argv)
|
||||
}
|
||||
if up := overlayOf(argv, c); up == "" || filepath.Dir(filepath.Dir(filepath.Dir(up))) != "/w/cachelayer" {
|
||||
t.Errorf("the cache %s is written into %q, not a layer of the ask's own: %v", c, up, argv)
|
||||
}
|
||||
if holds(argv, "--tmp-overlay", c) || holds(argv, "--bind", c, c) {
|
||||
t.Errorf("the cache %s is thrown away or written: %v", c, argv)
|
||||
}
|
||||
}
|
||||
if _, err := (Isolation{Workspace: "/w", CacheLayer: "cachelayer"}).Command("go"); err == nil {
|
||||
t.Error("a relative cache layer was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S2: what one command wrote into the caches is kept for the ask's later commands, the shared cache stays
|
||||
// unchanged, two commands at once write into layers of their own (overlayfs refuses an upper layer two mounts share),
|
||||
// and a command's own layer is gone when it ends.
|
||||
func TestWhatOneCommandCompiledIsReadByTheNextOfTheSameAsk(t *testing.T) {
|
||||
fixedSystem(t)
|
||||
root := t.TempDir()
|
||||
shared := filepath.Join(root, "gobuild")
|
||||
if err := os.MkdirAll(shared, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer := filepath.Join(root, "ws", "cachelayer")
|
||||
var mu sync.Mutex
|
||||
uppers := map[string]bool{}
|
||||
gate := make(chan struct{})
|
||||
var started sync.WaitGroup
|
||||
started.Add(2)
|
||||
inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
up := overlayOf(args, shared)
|
||||
mu.Lock()
|
||||
uppers[up] = true
|
||||
mu.Unlock()
|
||||
if holds(args, "--", "go", "build") {
|
||||
started.Done()
|
||||
<-gate // both commands are running before either ends
|
||||
os.MkdirAll(filepath.Join(up, "ab"), 0o700)
|
||||
os.WriteFile(filepath.Join(up, "ab", args[len(args)-1]+"-d"), []byte("compiled"), 0o600)
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}
|
||||
run := Isolated(inner, Isolation{Workspace: filepath.Join(root, "ws"), Caches: []string{shared}, CacheLayer: layer})
|
||||
var wg sync.WaitGroup
|
||||
for _, pkg := range []string{"one", "two"} {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); run(context.Background(), nil, "go", "build", pkg) }()
|
||||
}
|
||||
started.Wait()
|
||||
close(gate)
|
||||
wg.Wait()
|
||||
if len(uppers) != 2 {
|
||||
t.Fatalf("two commands at once wrote into %d layers, want 2 of their own: %v", len(uppers), uppers)
|
||||
}
|
||||
for _, pkg := range []string{"one", "two"} {
|
||||
if b, err := os.ReadFile(filepath.Join(keptLayer(layer, 0), "ab", pkg+"-d")); err != nil || string(b) != "compiled" {
|
||||
t.Errorf("what the build of %s compiled is not kept for the ask: %q %v", pkg, b, err)
|
||||
}
|
||||
}
|
||||
for up := range uppers {
|
||||
if _, err := os.Stat(up); !os.IsNotExist(err) {
|
||||
t.Errorf("the command's own layer %s outlived it: %v", up, err)
|
||||
}
|
||||
}
|
||||
var next []string
|
||||
Isolated(func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
|
||||
next = args
|
||||
return forgekey.Ran{}
|
||||
}, Isolation{Workspace: filepath.Join(root, "ws"), Caches: []string{shared}, CacheLayer: layer})(context.Background(), nil, "go", "test")
|
||||
if !holds(next, "--overlay-src", shared, "--overlay-src", keptLayer(layer, 0)) {
|
||||
t.Errorf("the next command does not read the kept layer: %v", next)
|
||||
}
|
||||
if entries, _ := os.ReadDir(shared); len(entries) != 0 {
|
||||
t.Errorf("the shared cache was written: %v", entries)
|
||||
}
|
||||
}
|
||||
+16
-20
@@ -15,9 +15,6 @@ 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
|
||||
@@ -27,7 +24,8 @@ type MergeResult struct {
|
||||
// signature, no hook, nothing of the machine's git configuration that changes a merge. Nothing is pushed.
|
||||
//
|
||||
// A merge needs both histories, so it makes the fetch whole (the shallow clone FetchCommit made is deepened) and
|
||||
// fetches base by its sha. A head that does not merge cleanly is an answer, not an error: the files in conflict, the
|
||||
// fetches base by its sha: from the mirror WithMirror names, when it holds base (UpdateMirror to base first), else
|
||||
// from remote. A head that does not merge cleanly is an answer, not an error: the files in conflict, the
|
||||
// merge undone and the workspace left at base. A head already in base answers a Merge equal to Base and no changed
|
||||
// paths. An error is that the merge could not be tried: a base or commit that is not a full sha, no base, a workspace
|
||||
// not at commit, or a fetch or git command that failed. remote and the options are those of FetchCommit; the runner's
|
||||
@@ -55,7 +53,7 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
|
||||
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
|
||||
r := run(ctx, env, "git", full...)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
return r.Stdout, nil
|
||||
}
|
||||
@@ -70,12 +68,20 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
fetch := []string{"fetch", "--no-tags", "origin", base}
|
||||
if strings.TrimSpace(shallow) == "true" {
|
||||
fetch = []string{"fetch", "--no-tags", "--unshallow", "origin", base}
|
||||
fetchFrom := func(source string) error {
|
||||
fetch := []string{"fetch", "--no-tags", source, base}
|
||||
if strings.TrimSpace(shallow) == "true" {
|
||||
fetch = []string{"fetch", "--no-tags", "--unshallow", source, base}
|
||||
}
|
||||
_, err := git(fetch...)
|
||||
return err
|
||||
}
|
||||
if _, err := git(fetch...); err != nil {
|
||||
return res, fmt.Errorf("the trunk commit %s: %w", base, err)
|
||||
// With a mirror brought to the trunk commit, the history is deepened from it, a local fetch (issue 1921, try speed
|
||||
// S6); a mirror that cannot answer leaves it to the forge, as before.
|
||||
if o.mirror == "" || fetchFrom(o.mirror) != nil {
|
||||
if err := fetchFrom("origin"); err != nil {
|
||||
return res, fmt.Errorf("the trunk commit %s: %w", base, err)
|
||||
}
|
||||
}
|
||||
if _, err := git("checkout", "-q", "--detach", base); err != nil {
|
||||
return res, fmt.Errorf("the trunk commit %s cannot be checked out: %w", base, err)
|
||||
@@ -101,16 +107,6 @@ 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,30 +79,6 @@ 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)
|
||||
@@ -126,7 +102,7 @@ func (r *mergeRig) fetch(head string) {
|
||||
// holds both sides' changes.
|
||||
func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) {
|
||||
r := newMergeRig(t)
|
||||
head, added := r.headThatAddsAndRemoves(map[string]string{"b.txt": "head\n", "prog": "\x7fELF\x02\x01"})
|
||||
head := r.head(map[string]string{"b.txt": "head\n"})
|
||||
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())
|
||||
@@ -136,11 +112,8 @@ 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", "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 !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 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)
|
||||
@@ -197,3 +170,33 @@ func TestMergeIntoAHeadAlreadyInTheTrunkChangesNothing(t *testing.T) {
|
||||
t.Errorf("result = %+v, %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S6: with a mirror brought to the head and to the trunk commit, the merge asks the forge for nothing: it succeeds
|
||||
// with the forge gone.
|
||||
func TestMergeIntoWithAMirrorAtTheTrunkNeedsNoForge(t *testing.T) {
|
||||
r := newMergeRig(t)
|
||||
head := r.head(map[string]string{"b.txt": "head\n"})
|
||||
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
|
||||
mirror := filepath.Join(t.TempDir(), "mirror.git")
|
||||
if err := os.MkdirAll(mirror, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []string{head, base} {
|
||||
if err := UpdateMirror(context.Background(), forgekey.ExecRunner, mirror, r.remote, c, "", AllowFileRemote()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := FetchCommit(context.Background(), forgekey.ExecRunner, r.ws, r.remote, head, "", AllowFileRemote(), WithMirror(mirror)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(r.bare); err != nil { // the forge is gone
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote(), WithMirror(mirror))
|
||||
if err != nil {
|
||||
t.Fatalf("the merge asked the forge: %v", err)
|
||||
}
|
||||
if len(res.Merge) != 40 || !reflect.DeepEqual(res.Changed, []string{"b.txt"}) {
|
||||
t.Errorf("result = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,3 +72,39 @@ func TestMergeIntoRunsTheCheckMergesCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1921/S6: with a mirror that holds the trunk commit, the merge deepens the workspace from the mirror, a local fetch,
|
||||
// and asks the forge nothing; a mirror that cannot answer falls back to the forge.
|
||||
func TestMergeIntoWithAMirrorFetchesTheTrunkFromTheMirror(t *testing.T) {
|
||||
base := strings.Repeat("b", 40)
|
||||
mirror := t.TempDir()
|
||||
answer := func(failMirror bool) *fakeGit {
|
||||
return &fakeGit{rule: func(l string) forgekey.Ran {
|
||||
switch {
|
||||
case strings.HasSuffix(l, "rev-parse HEAD"):
|
||||
return forgekey.Ran{Stdout: sha + "\n"}
|
||||
case strings.Contains(l, "--is-shallow-repository"):
|
||||
return forgekey.Ran{Stdout: "true\n"}
|
||||
case failMirror && strings.Contains(l, " fetch ") && strings.Contains(l, mirror):
|
||||
return forgekey.Ran{Status: 128, Stderr: "fatal: not our ref"}
|
||||
}
|
||||
return forgekey.Ran{}
|
||||
}}
|
||||
}
|
||||
f := answer(false)
|
||||
dir := t.TempDir()
|
||||
if _, err := MergeInto(context.Background(), f.run, dir, "ssh://mesh-forge/o/r.git", base, sha, WithMirror(mirror)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := strings.Join(f.calls, "\n")
|
||||
if !strings.Contains(all, "fetch --no-tags --unshallow "+mirror+" "+base) || strings.Contains(all, "--unshallow origin") {
|
||||
t.Errorf("the trunk was not fetched from the mirror alone:\n%s", all)
|
||||
}
|
||||
g := answer(true)
|
||||
if _, err := MergeInto(context.Background(), g.run, t.TempDir(), "ssh://mesh-forge/o/r.git", base, sha, WithMirror(mirror)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all := strings.Join(g.calls, "\n"); !strings.Contains(all, "fetch --no-tags --unshallow origin "+base) {
|
||||
t.Errorf("a mirror that could not answer did not fall back to the forge:\n%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,21 +104,3 @@ func TestUpdateMirrorRefusesBeforeRunning(t *testing.T) {
|
||||
t.Errorf("ran %v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's refusal reaches the reason in git's own words: the mirror of a repository its account cannot read said
|
||||
// only "git -C: error:" (2026-10-26), since git writes a bare "error:" line before the forge's message.
|
||||
func TestUpdateMirrorSaysTheForgesRefusal(t *testing.T) {
|
||||
f := &fakeGit{}
|
||||
f.rule = func(args string) (r forgekey.Ran) {
|
||||
if strings.Contains(args, "fetch") {
|
||||
r.Status = 128
|
||||
r.Stderr = "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read o/r.\nerror: \nfatal: Could not read from remote repository.\n"
|
||||
}
|
||||
return
|
||||
}
|
||||
err := UpdateMirror(context.Background(), f.run, filepath.Join(t.TempDir(), "m.git"), "ssh://mesh-forge/o/r.git", sha, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read o/r.") ||
|
||||
!strings.Contains(err.Error(), "Could not read from remote repository") || !strings.HasPrefix(err.Error(), "git fetch: ") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-351
@@ -6,11 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -66,31 +64,10 @@ type Puller struct {
|
||||
PullWait time.Duration
|
||||
Heartbeat time.Duration
|
||||
Logf func(format string, args ...any)
|
||||
// Load reads one key of the state the node's pause is kept in (nil and no error when it is absent). When set, Pull
|
||||
// reads the node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with PausePut, so a
|
||||
// pause outlives a restart.
|
||||
// Load reads one key of the module's steps state (nil and no error when it is absent). When set, Pull reads the
|
||||
// node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with Put, so a pause outlives
|
||||
// a restart.
|
||||
Load func(key string) ([]byte, error)
|
||||
// PausePut writes the node's pause key: the module's pauses state (delivery.PausesState), which has no age, so a
|
||||
// pause never ends by itself while the steps state drops its old keys. Put when nil.
|
||||
PausePut Put
|
||||
// WatchConfig, when set, follows the module's config value `capacity` (CapacitySetting) as mesh-config resolves it
|
||||
// for this node, and wakes the puller on each change (watchCapacity): WatchConfigServed in a module, a test's own
|
||||
// double. Until it hands a value the puller judges by Capacity.
|
||||
WatchConfig func(key string, handle func(stdio.ProvisionChange) error) error
|
||||
// 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
|
||||
// does not take the asker's next copy at once. Nil has nothing.
|
||||
Has func(need string) bool
|
||||
|
||||
mu sync.Mutex
|
||||
inHand int
|
||||
@@ -98,22 +75,6 @@ type Puller struct {
|
||||
paused bool
|
||||
wake chan struct{}
|
||||
once sync.Once
|
||||
// watched is the capacity the config watch handed last, used in place of Capacity once hasWatched.
|
||||
watched int
|
||||
hasWatched bool
|
||||
// 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
|
||||
}
|
||||
|
||||
func (p *Puller) init() {
|
||||
@@ -152,11 +113,7 @@ func (p *Puller) setPaused(paused bool, by string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
put := p.PausePut
|
||||
if put == nil {
|
||||
put = p.Put
|
||||
}
|
||||
if _, err := put(key, json.RawMessage(raw)); err != nil {
|
||||
if _, err := p.Put(key, json.RawMessage(raw)); err != nil {
|
||||
return fmt.Errorf("buildstep: writing the pause of %s: %w", p.Node, err)
|
||||
}
|
||||
}
|
||||
@@ -229,134 +186,13 @@ func (p *Puller) Running() []RunningAsk {
|
||||
|
||||
// room says whether the puller may take one more ask now.
|
||||
func (p *Puller) room() bool {
|
||||
ok, _, _ := p.roomNow()
|
||||
return ok
|
||||
}
|
||||
|
||||
// roomNow is room, with the capacity and the asks in hand it was judged on.
|
||||
func (p *Puller) roomNow() (ok bool, capacity, inHand int) {
|
||||
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 (CapacityDetail's Effective).
|
||||
func (p *Puller) CapacityNow() int {
|
||||
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))
|
||||
capacity := 0
|
||||
if p.Capacity != nil {
|
||||
capacity = p.Capacity()
|
||||
}
|
||||
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)
|
||||
}
|
||||
return !p.paused && p.inHand < capacity
|
||||
}
|
||||
|
||||
// Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream
|
||||
@@ -367,12 +203,8 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
return errors.New("buildstep: a puller needs its source, its steps' put and its run")
|
||||
}
|
||||
if err := p.restore(); err != nil {
|
||||
// An unreadable pause degrades to not paused, said once: a puller that stops silently is the worse failure, and a
|
||||
// node kept from building is held by its capacity (0), not by a pause.
|
||||
p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err)
|
||||
return err // pulling on a pause that cannot be read could build on a node paused on purpose
|
||||
}
|
||||
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
|
||||
@@ -381,33 +213,15 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, capacity, inHand := p.roomNow(); !ok {
|
||||
// Parked until something wakes it: a capacity change (the config watch), a resume, or a step's end. Every
|
||||
// park logs once, so a puller that sits idle says why (issue 1887).
|
||||
p.logf("the %s puller on %s parks: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
|
||||
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
|
||||
}
|
||||
if !p.room() {
|
||||
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
|
||||
}
|
||||
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)
|
||||
msg, err := p.next(ctx, wait)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
@@ -430,59 +244,17 @@ func (p *Puller) Pull(ctx context.Context) error {
|
||||
|
||||
// next is one ask from the workers in their order: one already waiting on delivery, check or recheck, and when none
|
||||
// waits, a pull of the delivery worker that waits for one.
|
||||
//
|
||||
// A kind whose worker handed an ask this node declined is left out until that wait ended (issue 890): the pull's own
|
||||
// wait, no timer of its own.
|
||||
func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) {
|
||||
p.mu.Lock()
|
||||
declined := p.declined
|
||||
p.mu.Unlock()
|
||||
for _, kind := range delivery.AskKinds {
|
||||
if declined[kind] {
|
||||
continue
|
||||
}
|
||||
msg, err := p.Source.Next(ctx, kind, 0)
|
||||
if err != nil || msg != nil {
|
||||
return withKind(msg, kind), err
|
||||
}
|
||||
}
|
||||
msg, err := p.Source.Next(ctx, delivery.AskDelivery, wait)
|
||||
p.mu.Lock()
|
||||
p.declined = nil
|
||||
p.mu.Unlock()
|
||||
return withKind(msg, delivery.AskDelivery), err
|
||||
}
|
||||
|
||||
// decline ends the step of an ask this node lacks a need of, refused as declined with what it lacks, and drops the
|
||||
// message: the asker puts the ask again under a new id (issue 890). A message whose decline cannot be written is handed
|
||||
// back, as one whose start cannot be.
|
||||
func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
|
||||
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, AskWhat(ask))
|
||||
if err == nil {
|
||||
why := Refuse("the ask %s needs %s, and %s lacks %s: it is left to a node that has it", ask.ID,
|
||||
strings.Join(ask.Needs, ", "), p.Node, strings.Join(lacks, ", "))
|
||||
step.Ended = now().UTC()
|
||||
step.State, step.Reason = delivery.StepRefused, why.Error()
|
||||
step.Result, err = json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: lacks})
|
||||
if err == nil {
|
||||
err = writeEnd(p.Put, key, step)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
|
||||
_ = msg.Nak()
|
||||
return
|
||||
}
|
||||
p.logf("the ask %s is declined on %s: it needs %s, and this node lacks %s", ask.ID, p.Node, strings.Join(ask.Needs, ", "), strings.Join(lacks, ", "))
|
||||
_ = msg.Term()
|
||||
p.mu.Lock()
|
||||
if p.declined == nil {
|
||||
p.declined = map[string]bool{}
|
||||
}
|
||||
p.declined[msg.Kind] = true
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func withKind(msg *Pulled, kind string) *Pulled {
|
||||
if msg != nil {
|
||||
msg.Kind = kind
|
||||
@@ -503,11 +275,7 @@ func (p *Puller) take(ctx context.Context, msg *Pulled) {
|
||||
_ = msg.Term()
|
||||
return
|
||||
}
|
||||
if lacks := delivery.Lacks(ask.Needs, p.Has); len(lacks) > 0 {
|
||||
p.decline(msg, ask, lacks)
|
||||
return
|
||||
}
|
||||
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, AskWhat(ask))
|
||||
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID)
|
||||
if err != nil {
|
||||
p.logf("the ask %s cannot start on %s: %v", ask.ID, p.Node, err)
|
||||
_ = msg.Nak()
|
||||
@@ -533,7 +301,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -564,11 +331,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
}
|
||||
}
|
||||
}()
|
||||
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
|
||||
result, err := protect(ctx, func(ctx context.Context) (any, error) { return p.Run(ctx, ask) })
|
||||
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.
|
||||
@@ -581,108 +344,7 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: g14's
|
||||
// puller started at capacity 0 sat parked with capacity 2 set, because nothing woke it to read its settings file
|
||||
// again). The value comes as mesh-config resolved it for this node, the event of the change itself: the runtime hands
|
||||
// the value now and then each change its machine's provisioner client says moved, so nothing polls, and the puller does
|
||||
// not wait for the node-engine to fill its settings file again. Each value handed is kept, used in place of Capacity,
|
||||
// and wakes the puller. A watch that cannot start leaves the puller on Capacity, said once.
|
||||
func (p *Puller) watchCapacity() {
|
||||
if p.WatchConfig == nil {
|
||||
return
|
||||
}
|
||||
err := p.WatchConfig(CapacitySetting, func(c stdio.ProvisionChange) error {
|
||||
n, why := p.capacityFrom(c)
|
||||
p.mu.Lock()
|
||||
before, had := p.watched, p.hasWatched
|
||||
p.watched, p.hasWatched = n, true
|
||||
p.mu.Unlock()
|
||||
switch {
|
||||
case why != "":
|
||||
p.logf("the %s puller on %s pulls nothing: %s", p.Language, p.Node, why)
|
||||
case !had || before != n:
|
||||
p.logf("the %s build capacity of %s is %d, as mesh-config resolved it", p.Language, p.Node, n)
|
||||
}
|
||||
p.Wake()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logf("the %s build capacity of %s cannot be followed on the bus, so it is read from the settings file when the "+
|
||||
"puller wakes: %v", p.Language, p.Node, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
<-stdio.Served()
|
||||
return stdio.WatchConfig(key, handle)
|
||||
}
|
||||
|
||||
// capacityFrom is the node's capacity in one value the watch handed, and why it is 0 when it cannot be read.
|
||||
func (p *Puller) capacityFrom(c stdio.ProvisionChange) (int, string) {
|
||||
if c.Refused != "" {
|
||||
return 0, "its capacity is no longer answered: " + c.Refused
|
||||
}
|
||||
setting := ""
|
||||
if len(c.Value) > 0 && string(c.Value) != "null" {
|
||||
if err := json.Unmarshal(c.Value, &setting); err != nil {
|
||||
return 0, fmt.Sprintf("its capacity %s is not a string", c.Value)
|
||||
}
|
||||
}
|
||||
n, err := capacityOf(setting, p.Node)
|
||||
if err != nil {
|
||||
return 0, err.Error()
|
||||
}
|
||||
return n, ""
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// configWatch is a double of stdio.WatchConfig: it hands the value now at the start, as the runtime does, and a test
|
||||
// hands each change after with set.
|
||||
type configWatch struct {
|
||||
mu sync.Mutex
|
||||
value string
|
||||
handle func(stdio.ProvisionChange) error
|
||||
asked string
|
||||
}
|
||||
|
||||
func (w *configWatch) watch(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
w.mu.Lock()
|
||||
w.asked, w.handle = key, handle
|
||||
value := w.value
|
||||
w.mu.Unlock()
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)})
|
||||
}
|
||||
|
||||
func (w *configWatch) set(t *testing.T, value string) {
|
||||
t.Helper()
|
||||
w.mu.Lock()
|
||||
handle, key := w.handle, w.asked
|
||||
w.mu.Unlock()
|
||||
if handle == nil {
|
||||
t.Fatal("the puller watches no config value")
|
||||
}
|
||||
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+31
-182
@@ -3,15 +3,11 @@ package buildstep
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -104,38 +100,20 @@ 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. 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.
|
||||
// one ends, with no timer.
|
||||
func TestPullHoldsAtMostItsCapacity(t *testing.T) {
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
asks := []delivery.StepAsk{}
|
||||
for i := 0; i < 3; i++ {
|
||||
a := testAsk(delivery.AskDelivery)
|
||||
asks = append(asks, a)
|
||||
_ = src.PublishAsk(a)
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
}
|
||||
release := make(chan struct{})
|
||||
var mu sync.Mutex
|
||||
started := 0
|
||||
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() },
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 2 },
|
||||
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
|
||||
}}
|
||||
@@ -147,87 +125,31 @@ 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
|
||||
// node-build-node is declined on a node that lacks it (its step refused as declined, naming what it lacks, the message
|
||||
// dropped for the asker to put again), and runs on a node that has it.
|
||||
// An ask that is not one, or not this seat's, is dropped and runs nowhere.
|
||||
func TestPullDropsWhatIsNoAsk(t *testing.T) {
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
src.Publish(delivery.AskDelivery, []byte(`{"v":1}`))
|
||||
other := testAsk(delivery.AskDelivery)
|
||||
other.Language = delivery.AskNode
|
||||
_ = src.PublishAsk(other)
|
||||
placed := testAsk(delivery.AskCheck)
|
||||
placed.Needs = []string{delivery.NeedNodeBuildNode}
|
||||
if err := src.PublishAsk(placed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ran := false
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 },
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
|
||||
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
waitFor(t, "three termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 3 })
|
||||
waitFor(t, "two termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 2 })
|
||||
if ran {
|
||||
t.Fatal("a wrong or a declined ask ran")
|
||||
t.Fatal("a wrong ask ran")
|
||||
}
|
||||
key, _ := delivery.AskStepKey("ace", placed.ID)
|
||||
s, _ := st.get(key)
|
||||
if d, ok := delivery.DeclinedOf(s); !ok || len(d.Lacks) != 1 || d.Lacks[0] != delivery.NeedNodeBuildNode || !strings.Contains(s.Reason, "lacks node-build-node") {
|
||||
t.Fatalf("the declined step = %+v", s)
|
||||
}
|
||||
has, hasSteps := NewFakeAsks(), newSteps()
|
||||
_ = has.PublishAsk(placed)
|
||||
q := &Puller{Source: has, Language: delivery.AskGo, Node: "g14", Put: hasSteps.put, Capacity: func() int { return 1 },
|
||||
PullWait: 10 * time.Millisecond, Has: func(n string) bool { return n == delivery.NeedNodeBuildNode },
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
go q.Pull(ctx)
|
||||
waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 })
|
||||
}
|
||||
|
||||
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
|
||||
@@ -253,85 +175,36 @@ func (k *kv) load(key string) ([]byte, error) {
|
||||
return k.vals[key], nil
|
||||
}
|
||||
|
||||
// 1887/A2b, 1887/AC-zero, 1887/AC-wake: at capacity 0 the puller takes nothing; a capacity raised takes effect at the
|
||||
// next pull, or, with the config watch (g14 idle at capacity 2, 2026-10-25), at once when the value moves, with no
|
||||
// pause, resume or step end to wake it; a refused value pulls nothing, and a watch that cannot start leaves the
|
||||
// settings file's capacity.
|
||||
// 1887/A2b, 1887/AC-zero: at capacity 0 the puller takes nothing; a capacity raised takes effect at the next pull.
|
||||
func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
|
||||
t.Run("settings file", func(t *testing.T) {
|
||||
src, st := NewFakeAsks(), &kv{}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
var mu sync.Mutex
|
||||
capacity := 0
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node at capacity 0 took an ask")
|
||||
}
|
||||
mu.Lock()
|
||||
capacity = 1
|
||||
mu.Unlock()
|
||||
p.Wake()
|
||||
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
})
|
||||
t.Run("config watch wakes", func(t *testing.T) {
|
||||
src, st := NewFakeAsks(), newSteps()
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
w := &configWatch{value: "shanks=3,g14=0"}
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { return 0 }, // the settings file, not refilled yet
|
||||
WatchConfig: w.watch,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
waitFor(t, "the watch of the capacity", func() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.handle != nil })
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node at capacity 0 took an ask")
|
||||
}
|
||||
if w.asked != CapacitySetting {
|
||||
t.Fatalf("the puller watches %q, want %q", w.asked, CapacitySetting)
|
||||
}
|
||||
w.set(t, "shanks=3,g14=2")
|
||||
waitFor(t, "the ask taken once the capacity moved to 2", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
watch func(string, func(stdio.ProvisionChange) error) error
|
||||
room bool
|
||||
}{
|
||||
{"refused value leaves no room", func(key string, handle func(stdio.ProvisionChange) error) error {
|
||||
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Refused: "the module is no longer on g14"})
|
||||
}, false},
|
||||
{"watch that cannot start keeps the settings file", func(string, func(stdio.ProvisionChange) error) error {
|
||||
return errors.New("no runtime")
|
||||
}, true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p := &Puller{Source: NewFakeAsks(), Language: delivery.AskGo, Node: "g14", Put: newSteps().put,
|
||||
Capacity: func() int { return 1 }, WatchConfig: c.watch,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
p.watchCapacity()
|
||||
if p.room() != c.room {
|
||||
t.Fatalf("room = %v, want %v", p.room(), c.room)
|
||||
}
|
||||
})
|
||||
src, st := NewFakeAsks(), &kv{}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
var mu sync.Mutex
|
||||
capacity := 0
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if src.Waiting(delivery.AskDelivery) != 1 {
|
||||
t.Fatal("a node at capacity 0 took an ask")
|
||||
}
|
||||
mu.Lock()
|
||||
capacity = 1
|
||||
mu.Unlock()
|
||||
p.Wake()
|
||||
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
}
|
||||
|
||||
// 1887/A2b: a paused puller takes nothing, its pause is kept on the pause key, and a puller started again on that state
|
||||
// stays paused until resumed.
|
||||
func TestPullPauseOutlivesARestart(t *testing.T) {
|
||||
src, st, steps := NewFakeAsks(), &kv{}, &kv{}
|
||||
src, st := NewFakeAsks(), &kv{}
|
||||
run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load,
|
||||
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run}
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { return 2 }, Run: run}
|
||||
if err := p.Pause("the operator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -340,12 +213,9 @@ func TestPullPauseOutlivesARestart(t *testing.T) {
|
||||
if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" {
|
||||
t.Fatalf("the pause key holds %s (%v)", raw, err)
|
||||
}
|
||||
if raw, _ := steps.load(key); raw != nil {
|
||||
t.Fatalf("the pause was written in the steps state, whose keys age out: %s", raw)
|
||||
}
|
||||
// The restart: a new puller on the same state.
|
||||
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load,
|
||||
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run}
|
||||
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
|
||||
Capacity: func() int { return 2 }, Run: run}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -358,27 +228,6 @@ func TestPullPauseOutlivesARestart(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, "the ask taken after the resume", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
|
||||
t.Run("a pause that cannot be read is said and pulled as not paused", func(t *testing.T) {
|
||||
src := NewFakeAsks()
|
||||
var said atomic.Value
|
||||
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put,
|
||||
Load: func(string) ([]byte, error) { return nil, errors.New("no runtime yet") },
|
||||
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run,
|
||||
Logf: func(f string, a ...any) {
|
||||
if m := fmt.Sprintf(f, a...); strings.Contains(m, "cannot be read") {
|
||||
said.Store(m)
|
||||
}
|
||||
}}
|
||||
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Pull(ctx)
|
||||
waitFor(t, "the ask taken", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
|
||||
if m, _ := said.Load().(string); !strings.Contains(m, "pulling as not paused") {
|
||||
t.Fatalf("the unreadable pause was not said: %q", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1887/A2c: a long step tells the bus it is still being worked; a holder that stops mid-step neither ends nor
|
||||
|
||||
@@ -14,13 +14,14 @@ func SeatTools(seat, what string, p *Puller) []stdio.Tool {
|
||||
Run: func(map[string]any) (any, error) { return run() }}
|
||||
}
|
||||
current := func() (any, error) {
|
||||
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
|
||||
capacity := 0
|
||||
if p.Capacity != nil {
|
||||
capacity = p.Capacity()
|
||||
}
|
||||
return map[string]any{"node": p.Node, "capacity": capacity, "running": p.Running(), "paused": p.Paused()}, nil
|
||||
}
|
||||
return []stdio.Tool{
|
||||
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, "+
|
||||
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity, "+
|
||||
"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) {
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **A step left running** (novox/hq issue 1890, note 5). A build module that stops while a step runs leaves its key in
|
||||
// state running for good: nothing else writes that key's end, and the stream's redelivery only rewrites it when the same
|
||||
// ask comes back. At its start a module ends the steps its node left running before the process began (EndStale), and
|
||||
// its step-end tool ends one by hand (EndOne).
|
||||
|
||||
// RestartReason is why a step a previous process left running is ended.
|
||||
const RestartReason = "the build module restarted while it ran"
|
||||
|
||||
// Boot is when this process began. A step started at or after it belongs to this process, which may still run it, so
|
||||
// EndOne refuses it.
|
||||
var Boot = time.Now()
|
||||
|
||||
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
|
||||
// none), and a write that lands only while the key is still at the revision read (KeptState.Update), failing with
|
||||
// stdio.ErrChanged when another writer was first.
|
||||
type Scan struct {
|
||||
Keys func() ([]string, error)
|
||||
Get func(key string) (json.RawMessage, uint64, error)
|
||||
Update func(key string, value any, revision uint64) (uint64, error)
|
||||
}
|
||||
|
||||
// ScanOf is the Scan of a state the process keeps.
|
||||
func ScanOf(s stdio.KeptState) Scan {
|
||||
return Scan{Keys: s.Keys, Update: s.Update, Get: func(key string) (json.RawMessage, uint64, error) {
|
||||
e, err := s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return e.Value, e.Revision, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
|
||||
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
|
||||
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
|
||||
// Only once the asker's bound has passed (delivery.StepBound) is the key nobody's, and then it is ended.
|
||||
func isAsk(key string, s delivery.Step) bool {
|
||||
_, _, err := delivery.ParseAskStepKey(key)
|
||||
return err == nil && strings.HasSuffix(s.Tool, " ask")
|
||||
}
|
||||
|
||||
// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and
|
||||
// fingerprint.
|
||||
func AskWhat(a delivery.StepAsk) string {
|
||||
short := func(s string) string {
|
||||
if len(s) > 12 {
|
||||
return s[:12]
|
||||
}
|
||||
return s
|
||||
}
|
||||
str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v }
|
||||
parts := []string{a.Kind + " ask"}
|
||||
switch {
|
||||
case a.Check != nil:
|
||||
parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ","))
|
||||
if a.Check.Run != "" {
|
||||
parts = append(parts, "run "+a.Check.Run)
|
||||
}
|
||||
case a.Build != nil:
|
||||
parts = append(parts, "module "+a.Build.Module)
|
||||
if r := str(a.Build.Fetch, "repository"); r != "" {
|
||||
parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit")))
|
||||
}
|
||||
if a.Build.FP != "" {
|
||||
parts = append(parts, "fp "+short(a.Build.FP))
|
||||
}
|
||||
case a.Compose != nil:
|
||||
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// endRunning writes step ended failed with why, only while its key is still at revision.
|
||||
func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error {
|
||||
step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil
|
||||
raw, err := delivery.EncodeStep(step)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = scan.Update(key, json.RawMessage(raw), revision)
|
||||
return err
|
||||
}
|
||||
|
||||
// EndStale ends this node's steps that are running and started before `before` (the start of this process) as failed
|
||||
// with RestartReason, and answers their ids. Only keys of node are read; a value that is no step is left alone. A step
|
||||
// of a pulled ask is left to the stream's redelivery (isAsk) until its start is older than delivery.StepBound: its asker
|
||||
// stopped waiting then, whether the stream gave it to another node or gave it up, and it is ended as failed, "past the
|
||||
// step bound: its asker stopped waiting at <time>". A key written by another writer since it was read
|
||||
// (a redelivered ask's start, a real end) is skipped.
|
||||
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
|
||||
keys, err := scan.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("buildstep: listing the steps of %s: %w", node, err)
|
||||
}
|
||||
var ended []string
|
||||
for _, k := range keys {
|
||||
n, _, perr := delivery.ParseStepKey(k)
|
||||
if perr != nil || n != node {
|
||||
continue
|
||||
}
|
||||
raw, rev, err := scan.Get(k)
|
||||
if err != nil {
|
||||
return ended, fmt.Errorf("buildstep: reading the step %s: %w", k, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
step, err := delivery.DecodeStep(raw)
|
||||
if err != nil || step.State != delivery.StepRunning {
|
||||
continue
|
||||
}
|
||||
why := RestartReason
|
||||
if isAsk(k, step) {
|
||||
if !step.Started.Add(delivery.StepBound).Before(now()) {
|
||||
continue
|
||||
}
|
||||
why = "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z")
|
||||
} else if !step.Started.Before(before) {
|
||||
continue
|
||||
}
|
||||
if err := endRunning(scan, k, step, rev, why); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
continue
|
||||
}
|
||||
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
|
||||
}
|
||||
if logf != nil {
|
||||
logf("the step %s (%s) was left running and is ended: %s", k, step.Tool, why)
|
||||
}
|
||||
ended = append(ended, step.ID)
|
||||
}
|
||||
return ended, nil
|
||||
}
|
||||
|
||||
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
|
||||
// refused, and so is one started since this process began (Boot: it may still run, whether a pulled ask or a step a
|
||||
// tool started), one live reports as held, and one whose key changed since it was read.
|
||||
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if n, rest, ok := strings.Cut(id, "."); ok {
|
||||
if n != node {
|
||||
return delivery.Step{}, fmt.Errorf("the step %q is of node %q, this module runs on %q", id, n, node)
|
||||
}
|
||||
id = rest
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return delivery.Step{}, fmt.Errorf("say why the step is ended (why)")
|
||||
}
|
||||
key, err := delivery.StepKey(node, id)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
raw, rev, err := scan.Get(key)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
|
||||
}
|
||||
step, err := delivery.DecodeStep(raw)
|
||||
if err != nil {
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
if step.State != delivery.StepRunning {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
|
||||
}
|
||||
if !step.Started.Before(Boot) || (live != nil && live(id)) {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
|
||||
}
|
||||
if err := endRunning(scan, key, step, rev, why); err != nil {
|
||||
if errors.Is(err, stdio.ErrChanged) {
|
||||
return delivery.Step{}, fmt.Errorf("the step %s changed while it was being ended; read it again", key)
|
||||
}
|
||||
return delivery.Step{}, err
|
||||
}
|
||||
step.State, step.Reason = delivery.StepFailed, why
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// StepEndTool is the module's step-end tool, named name (build_go_step_end): it ends one step left running, with a
|
||||
// reason, refusing one this process started. p may be nil.
|
||||
func StepEndTool(name string, node string, scan Scan, p *Puller) stdio.Tool {
|
||||
return stdio.Tool{Name: name,
|
||||
Description: "End one step of this machine that is stuck in state running (for instance left by a restart of the module) " +
|
||||
"as failed, with the reason given; its asker then sees it over, so ending an ask still in its stream fails its asker. " +
|
||||
"A step not running, or one this process started, is refused. " +
|
||||
"id is the step id or the key <node>.<id>.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"id", "why"}, "properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the step id, or the key <node>.<id>"},
|
||||
"why": map[string]any{"type": "string", "description": "why it is ended; it becomes the step's reason"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, _ := args["id"].(string)
|
||||
why, _ := args["why"].(string)
|
||||
var live func(string) bool
|
||||
if p != nil {
|
||||
live = func(id string) bool {
|
||||
for _, r := range p.Running() {
|
||||
if r.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return EndOne(scan, node, id, why, live)
|
||||
}}
|
||||
}
|
||||
|
||||
// StaleAttempts and StaleRetry bound EndStaleAtStart's tries: the steps state is readable once the runtime serves.
|
||||
var (
|
||||
StaleAttempts = 6
|
||||
StaleRetry = 5 * time.Second
|
||||
)
|
||||
|
||||
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
|
||||
// then ends this node's steps left running before `before` (EndStale), trying again when the state cannot be read yet.
|
||||
// A step this process starts is never one of them: it starts after `before`.
|
||||
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-stdio.Served():
|
||||
}
|
||||
for attempt := 1; ; attempt++ {
|
||||
ended, err := EndStale(scan, node, before, logf)
|
||||
if err == nil {
|
||||
if logf != nil {
|
||||
logf("%d steps of %s left running by a previous process ended", len(ended), node)
|
||||
}
|
||||
return
|
||||
}
|
||||
if logf != nil {
|
||||
logf("ending the steps left running (try %d of %d): %v", attempt, StaleAttempts, err)
|
||||
}
|
||||
if attempt >= StaleAttempts {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(StaleRetry):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EndStaleRound is EndStale as the module's periodic round runs it: at once, and only when the runtime serves (a round
|
||||
// before that is skipped, the start's EndStaleAtStart and the next round cover it). It ends the steps a previous
|
||||
// process left and the asks past delivery.StepBound.
|
||||
func EndStaleRound(scan Scan, node string, logf func(string, ...any)) {
|
||||
select {
|
||||
case <-stdio.Served():
|
||||
default:
|
||||
return
|
||||
}
|
||||
if _, err := EndStale(scan, node, Boot, logf); err != nil && logf != nil {
|
||||
logf("ending the steps left running: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package buildstep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// scanOf is the steps a keys double holds, last value per key, its revision the count of its puts; a write through
|
||||
// Update lands only at the revision read, as the state's does. race, when set, runs once before the next Update's check.
|
||||
func scanOf(k *keys, race func(key string)) Scan {
|
||||
return Scan{
|
||||
Keys: func() ([]string, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
var out []string
|
||||
for key := range k.puts {
|
||||
out = append(out, key)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, uint64, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
s, ok := k.puts[key]
|
||||
if !ok {
|
||||
return nil, 0, nil
|
||||
}
|
||||
raw, err := delivery.EncodeStep(s[len(s)-1])
|
||||
return raw, uint64(len(s)), err
|
||||
},
|
||||
Update: func(key string, value any, revision uint64) (uint64, error) {
|
||||
if race != nil {
|
||||
race(key)
|
||||
}
|
||||
k.mu.Lock()
|
||||
n := uint64(len(k.puts[key]))
|
||||
k.mu.Unlock()
|
||||
if n != revision {
|
||||
return 0, stdio.ErrChanged
|
||||
}
|
||||
return k.put(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 1890: EndStale ends only this node's hand-started steps left running before the process began and the asks past the
|
||||
// step bound (a younger ask is the stream's to redeliver), and never a key written meanwhile; EndOne ends one by hand, never one this process started.
|
||||
func TestEndStaleAndEndOne(t *testing.T) {
|
||||
k := newKeys()
|
||||
boot := time.Now()
|
||||
oldBoot := Boot
|
||||
Boot = boot
|
||||
defer func() { Boot = oldBoot }()
|
||||
put := func(node, id, tool, state string, started time.Time) {
|
||||
key, _ := delivery.StepKey(node, id)
|
||||
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started})))
|
||||
}
|
||||
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute))
|
||||
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second))
|
||||
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour))
|
||||
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
// raced1 is ended by its own run between the read and the write.
|
||||
raced := false
|
||||
scan := scanOf(k, func(key string) {
|
||||
if key == "ace.raced1" && !raced {
|
||||
raced = true
|
||||
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour))
|
||||
}
|
||||
})
|
||||
ended, err := EndStale(scan, "ace", boot, nil)
|
||||
if err != nil || len(ended) != 2 || ended[0] == ended[1] {
|
||||
t.Fatalf("ended %v, %v", ended, err)
|
||||
}
|
||||
if s := k.last("ace.old1"); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
|
||||
t.Errorf("old1 = %+v", s)
|
||||
}
|
||||
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
|
||||
t.Errorf("the old ask = %+v", s)
|
||||
}
|
||||
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.raced1": delivery.StepPassed, "ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
|
||||
if s := k.last(key); s.State != state {
|
||||
t.Errorf("%s = %s, want %s", key, s.State, state)
|
||||
}
|
||||
}
|
||||
// EndOne: refuses without why, a held step, one started by this process (a pulled ask or a step tool's), a step not
|
||||
// running and another node's; ends a step a previous process left.
|
||||
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
|
||||
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
|
||||
t.Errorf("ended %s with why %q", c.id, c.why)
|
||||
}
|
||||
}
|
||||
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
|
||||
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
|
||||
t.Error("ended a held step")
|
||||
}
|
||||
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" {
|
||||
t.Errorf("old2 = %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func mustStep(t *testing.T, s delivery.Step) []byte {
|
||||
t.Helper()
|
||||
raw, err := delivery.EncodeStep(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// 1890: AskWhat names the repository, head and levels of a check ask and the module of a build ask.
|
||||
func TestAskWhat(t *testing.T) {
|
||||
check := delivery.StepAsk{Kind: "check", Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": "0123456789abcdef0123"}, Levels: []string{"unit", "integration"}}}
|
||||
if got, want := AskWhat(check), "check ask, novox/mesh-sdk@0123456789ab, levels unit,integration"; got != want {
|
||||
t.Errorf("check = %q", got)
|
||||
}
|
||||
build := delivery.StepAsk{Kind: "delivery", Build: &delivery.BuildJob{Module: "mesh-x", FP: "abcdef0123456789"}}
|
||||
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
|
||||
t.Errorf("build = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func NewID() string {
|
||||
// the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and
|
||||
// the process goes on. If the running write fails, nothing was started and Start answers the error.
|
||||
func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) {
|
||||
step, key, err := begin(put, node, tool, NewID(), "")
|
||||
step, key, err := begin(put, node, tool, NewID())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -52,8 +52,8 @@ func Start(ctx context.Context, put Put, node, tool string, run func(ctx context
|
||||
}
|
||||
|
||||
// begin writes the step running under id.
|
||||
func begin(put Put, node, tool, id, what string) (delivery.Step, string, error) {
|
||||
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC(), What: what}
|
||||
func begin(put Put, node, tool, id string) (delivery.Step, string, error) {
|
||||
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC()}
|
||||
key, err := delivery.StepKey(node, step.ID)
|
||||
if err != nil {
|
||||
return delivery.Step{}, "", err
|
||||
|
||||
@@ -36,7 +36,7 @@ var lockFiles = []string{"go.sum", "package-lock.json"}
|
||||
func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) {
|
||||
r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
trees := map[string]ModuleTree{}
|
||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||
@@ -50,7 +50,7 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
|
||||
}
|
||||
t := run(ctx, env, "git", "-C", dir, "rev-parse", spec)
|
||||
if t.Status != 0 || t.Err != "" {
|
||||
return nil, fmt.Errorf("git rev-parse %s: %s", spec, forgekey.Said(t.Err+"\n"+t.Stderr))
|
||||
return nil, fmt.Errorf("git rev-parse %s: %s", spec, firstLine(t.Err+" "+t.Stderr))
|
||||
}
|
||||
mt := ModuleTree{Tree: strings.TrimSpace(t.Stdout)}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l)))
|
||||
@@ -93,7 +93,7 @@ func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit,
|
||||
}
|
||||
r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, firstLine(r.Err+" "+r.Stderr))
|
||||
}
|
||||
objects := map[string]string{}
|
||||
for _, l := range strings.Split(r.Stdout, "\x00") {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Do the emitters and the consumers of a catalogue agree?
|
||||
//
|
||||
// **The check that was missing** (novox/hq 04-ISSUES/127). Every manifest was individually
|
||||
// well-formed and every derivation individually correct, and no cross-module subscription in the
|
||||
// mesh matched anything: a consumer's declaration derived into a namespace nobody publishes to.
|
||||
// Nothing failed, because a subscription that matches nothing is not an error — it is silence.
|
||||
//
|
||||
// The comparison has to be over the whole catalogue, because the two halves live in different
|
||||
// manifests, and it cannot simply demand that every consumed event have a live emitter: a module
|
||||
// may be installed long before the one whose events it wants. So the rule is narrower and still
|
||||
// catches this: **where the emitter is present, it must emit what the consumer asked for.**
|
||||
|
||||
// AConsumer is one module's interest in another's events, as this check needs it.
|
||||
type AConsumer struct {
|
||||
Module string
|
||||
Consumes []string
|
||||
}
|
||||
|
||||
// AnEmitter is one module's events.
|
||||
type AnEmitter struct {
|
||||
Module string
|
||||
Emits []string
|
||||
}
|
||||
|
||||
// Disagreements are the consumed events whose emitter is in the catalogue and does not emit them.
|
||||
//
|
||||
// Returned as sentences rather than as structs: every one of them is read by a person deciding
|
||||
// whether a manifest or a catalogue is wrong, and a pair of names without the reason is a puzzle.
|
||||
func Disagreements(emitters []AnEmitter, consumers []AConsumer, seats []DeclaredSeat) []string {
|
||||
emits := map[string]map[string]bool{}
|
||||
for _, e := range emitters {
|
||||
if emits[e.Module] == nil {
|
||||
emits[e.Module] = map[string]bool{}
|
||||
}
|
||||
for _, name := range e.Emits {
|
||||
emits[e.Module][name] = true
|
||||
}
|
||||
}
|
||||
// A seat's events are published by its holder under the seat's name, so a consumer naming the
|
||||
// seat is naming something real even though no module declares it as its own.
|
||||
for _, s := range seats {
|
||||
if len(s.Emits) == 0 {
|
||||
continue
|
||||
}
|
||||
if emits[s.Name] == nil {
|
||||
emits[s.Name] = map[string]bool{}
|
||||
}
|
||||
for _, name := range s.Emits {
|
||||
emits[s.Name][name] = true
|
||||
}
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, c := range consumers {
|
||||
for _, pattern := range c.Consumes {
|
||||
emitter, event, named := strings.Cut(pattern, ".")
|
||||
// Every event from everyone, or every event from one module: both are deliberate and
|
||||
// neither names a particular event to check.
|
||||
if !named || emitter == "*" || emitter == catalogueTheRest || event == catalogueTheRest {
|
||||
continue
|
||||
}
|
||||
known, present := emits[emitter]
|
||||
if !present {
|
||||
// Not installed here, which is ordinary: a module lives in its own repository and
|
||||
// may be registered later. Nothing to compare, so nothing to say.
|
||||
continue
|
||||
}
|
||||
if matchesAny(event, known) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf(
|
||||
"%s consumes %q and %s emits %s — so that subscription would match nothing, and "+
|
||||
"nothing would report it",
|
||||
c.Module, pattern, emitter, listOf(known)))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// matchesAny says whether one of an emitter's event names satisfies a consumer's pattern.
|
||||
func matchesAny(pattern string, emitted map[string]bool) bool {
|
||||
want := strings.Split(pattern, ".")
|
||||
for name := range emitted {
|
||||
if matches(want, strings.Split(name, ".")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matches(pattern, name []string) bool {
|
||||
for i, part := range pattern {
|
||||
if part == catalogueTheRest {
|
||||
return i < len(name)
|
||||
}
|
||||
if i >= len(name) {
|
||||
return false
|
||||
}
|
||||
if part != "*" && part != name[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(pattern) == len(name)
|
||||
}
|
||||
|
||||
func listOf(names map[string]bool) string {
|
||||
if len(names) == 0 {
|
||||
return "nothing"
|
||||
}
|
||||
out := make([]string, 0, len(names))
|
||||
for n := range names {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
)
|
||||
|
||||
// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue
|
||||
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the controller
|
||||
// keeps the last word of each (announce.KeptBucket) and grants a reader's `reads: <owner>.<name>` only when the owner
|
||||
// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads`
|
||||
// (the transition, until every module announces), and is named as not yet announced.
|
||||
|
||||
// StatesAnnouncedBucket is the controller's bucket of the last announcement of each module (announce.KeptBucket).
|
||||
const StatesAnnouncedBucket = announce.KeptBucket
|
||||
|
||||
// controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares
|
||||
// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, and the grants issued and the
|
||||
// compositions' digests, which the assigner reads (ADR 0528 clause 2, ADR 0539 clause 6).
|
||||
var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts,
|
||||
MirrorDeclaredPackages, ControllerGrants, ControllerComposed}
|
||||
|
||||
// ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local
|
||||
// name, the ones others may read marked readable. It announces itself, as any module does from its code.
|
||||
func ControllerAnnouncement(node string) announce.States {
|
||||
s := announce.States{Module: ControllerSeat, Node: node}
|
||||
readable := map[string]bool{}
|
||||
for _, n := range controllerReadable {
|
||||
readable[n] = true
|
||||
}
|
||||
for _, bucket := range ControllerBuckets() {
|
||||
local, mine := strings.CutPrefix(bucket, ControllerSeat+"_")
|
||||
if !mine {
|
||||
continue
|
||||
}
|
||||
s.States = append(s.States, announce.State{Name: local, Readable: readable[local]})
|
||||
}
|
||||
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
|
||||
return s
|
||||
}
|
||||
|
||||
// ReadsAnnounced splits a reader's `reads` (`<owner>.<name>`) by what each owner announced: granted when the owner
|
||||
// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did
|
||||
// not announce that state readable.
|
||||
func ReadsAnnounced(reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
return ReadsAnnouncedBy("", reads, announced)
|
||||
}
|
||||
|
||||
// ReadsAnnouncedBy is ReadsAnnounced for one reader module, which also honours the readers an owner named for a state
|
||||
// (announce.State.Readers) and the composer's named readers (NamedReaders, ADR 0547 clause 9): a read named for other
|
||||
// modules is refused. An empty reader is judged by the readable mark alone.
|
||||
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
|
||||
for _, r := range reads {
|
||||
owner, name, ok := strings.Cut(r, ".")
|
||||
s, said := announced[owner]
|
||||
switch {
|
||||
case reader != "" && !ReaderMay(reader, r):
|
||||
refused = append(refused, r)
|
||||
case reader != "" && ok && said && s.Keeps(name):
|
||||
if s.ReadableBy(name, reader) {
|
||||
granted = append(granted, r)
|
||||
} else {
|
||||
refused = append(refused, r)
|
||||
}
|
||||
case !ok || !said || s.Readable(name):
|
||||
granted = append(granted, r)
|
||||
default:
|
||||
refused = append(refused, r)
|
||||
}
|
||||
}
|
||||
return granted, refused
|
||||
}
|
||||
|
||||
// NotAnnounced are the owners some module reads the state of and that have announced nothing yet, sorted: read from the
|
||||
// readers' `reads` as before, until they announce.
|
||||
func NotAnnounced(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, ds := range r.Assigned {
|
||||
for _, d := range ds {
|
||||
for _, read := range append(append([]string(nil), d.Reads...), d.ReadsRefused...) {
|
||||
owner, _, ok := strings.Cut(read, ".")
|
||||
if !ok || seen[owner] {
|
||||
continue
|
||||
}
|
||||
if _, said := r.Announced[owner]; !said {
|
||||
seen[owner] = true
|
||||
out = append(out, owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
// The view alone reads the build seats' queues (novox/hq ADR 0525 §2, issue 1887 A8a): the shared workers' counts and the
|
||||
// oldest waiting ask, to show the backlog. It may not publish an ask: only the builder and the checker ask, by their seat use.
|
||||
// The watchman is granted nothing here: a waiting ask has no pulse, a taken one is read from the build module's steps (issue
|
||||
// 1908). Nothing is a wildcard over a stream or a worker.
|
||||
const (
|
||||
// ViewModule is the module of the delivery view (mesh-delivery-view).
|
||||
ViewModule = "mesh-delivery-view"
|
||||
// BuilderModule is the module of the builder (mesh-delivery-builder).
|
||||
BuilderModule = "mesh-delivery-builder"
|
||||
)
|
||||
|
||||
// BuildAskReads are the API subjects the builder requests when it takes over, at its start, a key naming an ask (issue
|
||||
// 1910): each build seat's stream's information and its messages by sequence, to tell whether the ask is on its stream.
|
||||
// Reads only: no consumer, no pull, no acknowledgement; the builder puts its asks by its seat use, as before.
|
||||
func BuildAskReads() []string {
|
||||
var out []string
|
||||
for _, lang := range delivery.AskLanguages {
|
||||
if stream, err := delivery.AskStream(lang); err == nil {
|
||||
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildBacklogReads are the API subjects the view requests: the information of each build seat's stream and of each of its
|
||||
// shared workers (delivery.AskStream, delivery.AskWorker), and the message of the stream by sequence. Reads only: no
|
||||
// message is pulled, none acknowledged, none published.
|
||||
func BuildBacklogReads() []string {
|
||||
var out []string
|
||||
for _, lang := range delivery.AskLanguages {
|
||||
stream, err := delivery.AskStream(lang)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream)
|
||||
for _, kind := range delivery.AskKinds {
|
||||
if w, err := delivery.AskWorker(lang, kind); err == nil {
|
||||
out = append(out, "$JS.API.CONSUMER.INFO."+stream+"."+w)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
|
||||
// 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), 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want []struct {
|
||||
Name, List string
|
||||
Missing []string
|
||||
Err string
|
||||
Memberships json.RawMessage
|
||||
}
|
||||
if err := json.Unmarshal(raw, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtures := UserListFixtures()
|
||||
if len(want) != len(fixtures) {
|
||||
t.Fatalf("%d fixtures, %d lists the controller composed", len(fixtures), len(want))
|
||||
}
|
||||
for i, f := range fixtures {
|
||||
t.Run(f.Name, func(t *testing.T) {
|
||||
if want[i].Name != f.Name {
|
||||
t.Fatalf("fixture %q against the controller's %q", f.Name, want[i].Name)
|
||||
}
|
||||
list, missing, err := ComposedFor(f)
|
||||
got := ""
|
||||
if err != nil {
|
||||
got = err.Error()
|
||||
}
|
||||
if got != want[i].Err {
|
||||
t.Fatalf("error %q, the controller's %q", got, want[i].Err)
|
||||
}
|
||||
if strings.Join(missing, ",") != strings.Join(want[i].Missing, ",") {
|
||||
t.Errorf("left out %v, the controller %v", missing, want[i].Missing)
|
||||
}
|
||||
// The one row the copy composes beyond the controller's, on purpose (novox/hq issue 1790, 2026-10-26, after
|
||||
// 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.
|
||||
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))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotM, wasM any
|
||||
if err := json.Unmarshal(raw, &gotM); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotM, wasM) {
|
||||
t.Errorf("the memberships differ from the controller's:\n%s", raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
// An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters
|
||||
// (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever).
|
||||
t.Run("an asker reads its seat's dead letters", func(t *testing.T) {
|
||||
build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true}
|
||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for subject, want := range map[string]bool{
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true,
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true,
|
||||
"$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true,
|
||||
"$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true,
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false,
|
||||
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false,
|
||||
"$JS.API.STREAM.PURGE.DEAD_LETTERS": false,
|
||||
"$JS.FC.DEAD_LETTERS.x1": false,
|
||||
} {
|
||||
if got := MayPublish(perms, subject); got != want {
|
||||
t.Errorf("may publish %s: %v, want %v", subject, got, want)
|
||||
}
|
||||
}
|
||||
plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
|
||||
Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") {
|
||||
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(
|
||||
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
|
||||
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
|
||||
[]Principal{
|
||||
{Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"},
|
||||
{Kind: KindEnrolment, Node: "one", PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
|
||||
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
|
||||
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
|
||||
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
|
||||
{Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat},
|
||||
Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"},
|
||||
{Kind: KindModule, Node: "two", Module: "audit",
|
||||
Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"},
|
||||
{Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"},
|
||||
{Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true,
|
||||
Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := os.ReadFile(filepath.Join("testdata", "composed.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grantsAsTheControllers(t, got, string(want))
|
||||
})
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks).
|
||||
//
|
||||
// mesh.mod.<module>.call.<tool>[.<node>].<caller>
|
||||
// mesh.seat.<seat>.call.<verb>[.<node>].<caller>
|
||||
//
|
||||
// The last token is the bus user that published the call, and each user is granted these subjects with its own
|
||||
// name there and no other — the caller is a fact the server enforces, and the tool runtime hands it to the
|
||||
// module from the subject a call arrived on (mesh-tools node-tools internal/bus caller.go holds the same shape).
|
||||
//
|
||||
// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a
|
||||
// wildcard over the `tool` kind — `.tool.<t>.*` for the instance on any machine, `mesh.mod.*.tool.>` for every
|
||||
// tool — and either would match a `tool` subject with any caller appended, so a caller could name another.
|
||||
// Under `call` nothing is granted but the caller-named subjects. No stream's filter covers it: a tool call is
|
||||
// never persisted (design 25 §3).
|
||||
//
|
||||
// **Derived from the `tool` grants, in one place** (callerNamed): wherever a principal may call or answer a
|
||||
// tool, it may call it in its own name, or answer it naming any caller — so no kind of principal is left
|
||||
// unable to call in its own name, and a new grant to call is one in both shapes by construction.
|
||||
//
|
||||
// The `tool` grants stay beside these for one release, so every caller and every runtime moves without a gap:
|
||||
// their retirement is hq issue 464.
|
||||
const CallKind = "call"
|
||||
|
||||
var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
||||
|
||||
// CallerToken is a bus user's name as the last token of a call it publishes: each dot written `~`, which no part
|
||||
// of a user's name may hold (safeSubject), so the token names that user and no other. "" for a name that is not
|
||||
// a user's.
|
||||
func CallerToken(user string) string {
|
||||
if !userName.MatchString(user) {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(user, ".", "~")
|
||||
}
|
||||
|
||||
// toolGrant splits a grant on the `tool` kind — `mesh.mod.<m>.tool.<rest>` or `mesh.seat.<s>.tool.<rest>`, any
|
||||
// part possibly a wildcard — at the kind; ok is false for any other subject.
|
||||
func toolGrant(subject string) (head, rest string, ok bool) {
|
||||
parts := strings.SplitN(subject, ".", 5)
|
||||
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" ||
|
||||
parts[2] == "" || parts[4] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true
|
||||
}
|
||||
|
||||
// CalledSubject is where a call to a tool subject goes naming its caller; "" when the subject is not a tool's
|
||||
// or the user is not a bus user.
|
||||
func CalledSubject(subject, user string) string {
|
||||
head, rest, ok := toolGrant(subject)
|
||||
token := CallerToken(user)
|
||||
if !ok || token == "" || strings.ContainsAny(rest, "*>") {
|
||||
return ""
|
||||
}
|
||||
return head + "." + CallKind + "." + rest + "." + token
|
||||
}
|
||||
|
||||
// CalledPattern is what a holder answering a tool subject also subscribes, to hear the calls that name their
|
||||
// caller: the same address under the `call` kind, any caller last. "" for a subject that is not a tool's.
|
||||
func CalledPattern(subject string) string {
|
||||
head, rest, ok := toolGrant(subject)
|
||||
if !ok || strings.ContainsAny(rest, "*>") {
|
||||
return ""
|
||||
}
|
||||
return head + "." + CallKind + "." + rest + ".*"
|
||||
}
|
||||
|
||||
// calledPublish is a grant to call on the `tool` kind, as the same grant in the caller's own name: its last
|
||||
// token the caller's, and nothing that reaches past it. A `>` is every tail a call carries — the tool alone or
|
||||
// the tool and the machine — so it becomes both, each ending in the caller.
|
||||
func calledPublish(grant, token string) []string {
|
||||
head, rest, ok := toolGrant(grant)
|
||||
if !ok || token == "" {
|
||||
return nil
|
||||
}
|
||||
base := head + "." + CallKind + "."
|
||||
switch {
|
||||
case rest == ">":
|
||||
return []string{base + "*." + token, base + "*.*." + token}
|
||||
case strings.HasSuffix(rest, ".>"):
|
||||
return []string{base + strings.TrimSuffix(rest, ">") + "*." + token}
|
||||
}
|
||||
return []string{base + rest + "." + token}
|
||||
}
|
||||
|
||||
// calledSubscribe is a grant to answer on the `tool` kind, as the same grant for the calls naming any caller.
|
||||
func calledSubscribe(grant string) []string {
|
||||
head, rest, ok := toolGrant(grant)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
base := head + "." + CallKind + "."
|
||||
if strings.HasSuffix(rest, ">") {
|
||||
return []string{base + rest}
|
||||
}
|
||||
return []string{base + rest + ".*"}
|
||||
}
|
||||
|
||||
// callerNamed adds, beside a principal's grants on the `tool` kind, the same grants under `call`: to publish in
|
||||
// its own name, to subscribe naming anybody.
|
||||
func callerNamed(user string, pub, sub []string) ([]string, []string) {
|
||||
token := CallerToken(user)
|
||||
for _, g := range pub {
|
||||
pub = append(pub, calledPublish(g, token)...)
|
||||
}
|
||||
for _, g := range sub {
|
||||
sub = append(sub, calledSubscribe(g)...)
|
||||
}
|
||||
return unique(pub), unique(sub)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import "strings"
|
||||
|
||||
// A seat's cancelled set (novox/hq ADR 0219) is a bucket the mesh no longer raises or grants (R4g, ADR 0525 clause 6): the
|
||||
// controller asks no work queue, so nothing is cancelled. A bus that still holds one is not reported as state nothing declares.
|
||||
|
||||
// IsCancelledSet says a bucket is a seat's cancelled set rather than a module's state.
|
||||
func IsCancelledSet(bucket string) bool {
|
||||
return strings.HasPrefix(bucket, "SEAT_") && strings.HasSuffix(bucket, "_cancelled")
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-<node>`, holding that node's
|
||||
// compositions, which only that node's node-engine reads, since a composition can carry a sealed value (ADR 0488 §2's
|
||||
// reason); and `mesh-controller_composed`, holding each composition's digest and the judgement of its move, which the
|
||||
// assigner reads. The controller writes both, by compare-and-set while it holds the lease; each keeps five revisions
|
||||
// of a key. The names and layouts are mesh-sdk's (delivery.ComposedBucketFor, ControllerComposedBucket, ComposedKey).
|
||||
|
||||
// ControllerComposed is the local name of the compositions' digests, as a reader names it in `reads` after
|
||||
// `mesh-controller.`: delivery.ControllerComposedBucket.
|
||||
const ControllerComposed = "composed"
|
||||
|
||||
// ComposedValueBytes is the most a composition may hold: a file over 64 KiB travels by digest, so a module's
|
||||
// composition is far smaller; just under the server's largest message, which also carries the subject and headers.
|
||||
const ComposedValueBytes = 1<<20 - 8<<10
|
||||
|
||||
// ComposedBucketBytes is a node's composition bucket's size: five revisions of every module's compositions of the node.
|
||||
const ComposedBucketBytes = 256 << 20
|
||||
|
||||
// ComposedDigestsBytes is the size of the bucket of digests: a value of a few hundred bytes per node, module and
|
||||
// fingerprint kept.
|
||||
const ComposedDigestsBytes = 64 << 20
|
||||
|
||||
// ComposedConfig is a node's composition bucket as the controller asserts it.
|
||||
func ComposedConfig(node string) (jetstream.KeyValueConfig, error) {
|
||||
bucket, err := delivery.ComposedBucketFor(node)
|
||||
if err != nil {
|
||||
return jetstream.KeyValueConfig{}, err
|
||||
}
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: bucket,
|
||||
Description: "the compositions of " + node + " (novox/hq ADR 0539): each module's resources for this node at one " +
|
||||
"fingerprint, and the node's own, signed; written by the controller alone, read by this node's node-engine " +
|
||||
"alone; deleted when outside the module's five newest fingerprints and named by no want",
|
||||
History: delivery.ComposedHistory,
|
||||
MaxValueSize: ComposedValueBytes,
|
||||
MaxBytes: ComposedBucketBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ComposedDigestsConfig is the bucket of the compositions' digests as the controller asserts it.
|
||||
func ComposedDigestsConfig() jetstream.KeyValueConfig {
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: delivery.ControllerComposedBucket,
|
||||
Description: "each composition's digest and the judgement of its move from what the node runs (novox/hq ADR 0539 " +
|
||||
"clauses 2 and 14): written by the controller alone, read by the assigner",
|
||||
History: delivery.ControllerComposedHistory,
|
||||
MaxValueSize: 4 << 10,
|
||||
MaxBytes: ComposedDigestsBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureComposed creates the bucket of digests and each named node's composition bucket if absent and brings their
|
||||
// options to match. An update, never a delete.
|
||||
func EnsureComposed(ctx context.Context, js jetstream.JetStream, nodes ...string) error {
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, ComposedDigestsConfig()); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", delivery.ControllerComposedBucket, err)
|
||||
}
|
||||
for _, node := range nodes {
|
||||
cfg, err := ComposedConfig(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, cfg); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", cfg.Bucket, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComposedWrites is what the controller publishes to write a node's compositions: the bucket's own subjects.
|
||||
func ComposedWrites(node string) (string, error) {
|
||||
bucket, err := delivery.ComposedBucketFor(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "$KV." + bucket + ".>", nil
|
||||
}
|
||||
|
||||
// ComposedReads is what a node's node-engine publishes to follow its wants and compositions (ADR 0539 clauses 1, 2 and
|
||||
// 9): every key of its own composition bucket, and its own wants (`want.<node>.<module>` and `want.<node>.node`) in the
|
||||
// assigner's bucket; nothing of another node's, nor the bucket of digests.
|
||||
func ComposedReads(node string) ([]string, error) {
|
||||
bucket, err := delivery.ComposedBucketFor(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !safeSubject.MatchString(node) {
|
||||
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node)
|
||||
}
|
||||
return append(readGrants(bucket, ""), readGrants(delivery.AssignerWantsBucket, "want."+node+".>")...), nil
|
||||
}
|
||||
|
||||
// VerbsTheNodeAgentAsks are the seat verbs a node's node-engine calls of its own machine while it applies a want (ADR
|
||||
// 0539 clause 16, ADR 0429 §14): its backup holder backs a data module up before it moves, and says when it was. Asked
|
||||
// of its own machine only, `mesh.seat.<seat>.tool.<verb>.<node>`.
|
||||
var VerbsTheNodeAgentAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}, {Seat: "node-backup", Verb: "backed-up"}}
|
||||
|
||||
// NodeAgentAsks are the subjects of VerbsTheNodeAgentAsks for one node.
|
||||
func NodeAgentAsks(node string) ([]string, error) {
|
||||
if !safeSubject.MatchString(node) {
|
||||
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node)
|
||||
}
|
||||
var out []string
|
||||
for _, v := range VerbsTheNodeAgentAsks {
|
||||
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb+"."+node)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// NodeAgentHears are the events a node's node-engine subscribes while it applies a want: the backup holder's word that a
|
||||
// backup is taken (`backed-up`), from whichever module holds node-backup on its machine (restic today).
|
||||
var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-up"}
|
||||
|
||||
// BackupHolderModule is the module that holds node-backup on every node, restic: the one whose word a node-engine hears.
|
||||
// A second holder of the seat is a change of this grant.
|
||||
const BackupHolderModule = "restic"
|
||||
|
||||
// administers says a person may invoke every tool (`*`): the operator.
|
||||
func administers(invokes []string) bool {
|
||||
for _, i := range invokes {
|
||||
if i == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandPathWrites is what the operator's account publishes for release 6's hand path (ADR 0539 clauses 2 and 10): a want
|
||||
// of any node, written and read back, in the assigner's bucket; each named node's compositions; the digests; and each
|
||||
// node's object bucket for a file over 64 KiB, whose put reads an object's metadata and purges a replaced one's chunks.
|
||||
func HandPathWrites(nodes []string) ([]string, error) {
|
||||
out := append([]string{"$KV." + delivery.AssignerWantsBucket + ".want.>"}, readGrants(delivery.AssignerWantsBucket, "want.>")...)
|
||||
out = append(out, "$KV."+delivery.ControllerComposedBucket+".>")
|
||||
out = append(out, readGrants(delivery.ControllerComposedBucket, "")...)
|
||||
for _, node := range nodes {
|
||||
w, err := ComposedWrites(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket, _ := delivery.ComposedBucketFor(node)
|
||||
out = append(out, w)
|
||||
out = append(out, readGrants(bucket, "")...)
|
||||
files, err := DeclarationFilesWrites(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream := "OBJ_" + DeclarationFilesBucket + node
|
||||
out = append(out, files, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream+".>",
|
||||
"$JS.API.STREAM.MSG.GET."+stream, "$JS.API.STREAM.PURGE."+stream)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeclarationsStream is the stream of each node's last declaration, `mesh.node.<node>.declare` (issue 107). Release 6
|
||||
// sends none (ADR 0539 clauses 5 and 11): the stream is kept, with each node's consumer, only so a node away during the
|
||||
// switch finds its last declaration and with it the new node-engine; the controller deletes it once every node's have
|
||||
// names the new node-engine, and from then on neither asserts it nor any node's consumer on it; nor makes it again once
|
||||
// it is gone from the bus for any reason, since a stream made anew would hold no node's last declaration (issue 1945).
|
||||
const DeclarationsStream = "NODES"
|
||||
|
||||
// RaiseAfterDeclarations is Raise once the declarations' stream is retired: the mesh's streams and consumers but the
|
||||
// declarations' stream, and no node's consumer of a declaration.
|
||||
func RaiseAfterDeclarations(r Raiser) error {
|
||||
for _, s := range MeshStreams() {
|
||||
if s.Name == DeclarationsStream {
|
||||
continue
|
||||
}
|
||||
if err := r.EnsureStream(s); err != nil {
|
||||
return fmt.Errorf("asserting stream %s: %w", s.Name, err)
|
||||
}
|
||||
}
|
||||
return AssertMeshConsumers(r)
|
||||
}
|
||||
|
||||
// TakeInConsumer is the durable consumer the controller takes done keys in from, on the stream of each of TakeInBuckets
|
||||
// (novox/hq ADR 0544 §1).
|
||||
const TakeInConsumer = "mesh-controller-take-in"
|
||||
|
||||
// TakeInBuckets are the buckets the take-in consumes: the indexer's modules (each module's trunk value) and the builder's
|
||||
// builds (each build's key).
|
||||
var TakeInBuckets = []string{delivery.ModulesBucket, delivery.BuilderBuildsBucket}
|
||||
|
||||
// ConfigFollowConsumer is the durable consumer the controller follows mesh-config's resolved values with, on that
|
||||
// bucket's stream (novox/hq ADR 0545).
|
||||
const ConfigFollowConsumer = "mesh-controller-config-follow"
|
||||
@@ -1,247 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The controller's own key-value buckets (novox/hq to-be 45 §1, §6, §7).
|
||||
//
|
||||
// **What the controller must remember across its own restart, it keeps on the bus.** A call's
|
||||
// outcome lived in the memory of the process that served it (novox/hq issue 265), so a controller
|
||||
// replaced while a push ran answered "no such call" for the one thing its caller had been told to
|
||||
// ask about. The bus already outlives the controller and is the shape ADR 0201 gives a module's
|
||||
// current state: one value per key, written by one owner, read by anybody granted it. These are the
|
||||
// controller's, written by it alone — the writers table of to-be 45 §1 — and asserted on every start
|
||||
// like the streams, so a bus raised from nothing has them before the first call is served.
|
||||
|
||||
// CallsBucket keeps every call of the mesh's own verbs and what came of it; HandActsBucket every act
|
||||
// a person did by hand, with why; ConditionsBucket every condition open now (to-be 45 §2), one key
|
||||
// each, and ConditionHistoryBucket every transition of one — raised, changed, silenced, cleared —
|
||||
// for ninety days.
|
||||
//
|
||||
// **The history is a bucket of its own** because its keys expire and an open condition's must not:
|
||||
// a bucket has one age for every key, and a condition open longer than the history is kept would
|
||||
// otherwise vanish from the store while still true.
|
||||
var (
|
||||
CallsBucket = BucketName(ControllerSeat, "calls")
|
||||
HandActsBucket = BucketName(ControllerSeat, "hand-acts")
|
||||
ConditionsBucket = BucketName(ControllerSeat, "conditions")
|
||||
ConditionHistoryBucket = BucketName(ControllerSeat, "condition-history")
|
||||
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
|
||||
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
|
||||
LeaseBucket = BucketName(ControllerSeat, "lease")
|
||||
// AskedBucket keeps what the controller asked the operator about its conditions (novox/hq ADR 0259):
|
||||
// each ask by its id, its options and the actions they stand for, how it ended and whether the
|
||||
// controller acted on its warrant — so a restart neither asks twice nor acts twice.
|
||||
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||
// DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged
|
||||
// on (novox/hq ADR 0420 §7, issue 1025): per merge a key `<owner>/<repo>/<number>/<head sha>` holding the
|
||||
// announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new
|
||||
// repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of
|
||||
// those heads. Written by the controller alone, from the announcement, when it holds the lease.
|
||||
DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges")
|
||||
)
|
||||
|
||||
// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are.
|
||||
const DeliveryMergesKeptFor = 7 * 24 * time.Hour
|
||||
|
||||
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
|
||||
// record, one key per record, written from the store by the lease holder only when the record changed, with
|
||||
// one event on the controller's seat per write. Read by whatever names them in `reads` as
|
||||
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
|
||||
// assignment's value names its module and node; a module's declared config is what its manifest, which is
|
||||
// public, declares.
|
||||
//
|
||||
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
|
||||
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
|
||||
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4); and PredefinedBucket the
|
||||
// list of predefined keys the mesh published (the newest trunk build's of the catalogue, ADR 0348 §7), one key
|
||||
// `list`: what mesh-config publishes as mesh-config_predefined (design 57 K3, issue 662).
|
||||
var (
|
||||
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
|
||||
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
|
||||
PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined)
|
||||
// DeclaredPackagesBucket holds, keyed by the node, each package the modules assigned to it declare, present or absent,
|
||||
// with the declaring modules: what the pacman module's verbs read to say a package is declared (ADR 0507 §5).
|
||||
DeclaredPackagesBucket = BucketName(ControllerSeat, MirrorDeclaredPackages)
|
||||
)
|
||||
|
||||
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||
const (
|
||||
MirrorAssignments = "assignments"
|
||||
MirrorDeclaredConfig = "declared-config"
|
||||
MirrorPredefined = "predefined"
|
||||
// MirrorDeclaredPackages is the local name of the packages each node's modules declare (ADR 0507 §5, issue 1840/S3).
|
||||
MirrorDeclaredPackages = "declared-packages"
|
||||
)
|
||||
|
||||
// The chain's buckets (novox/hq ADR 0408, issue 1089): builds, rollouts and assignments are one chain kept on the bus
|
||||
// alone, one bucket each, one key per record, written by the lease holder by compare-and-set. The assignments bucket is
|
||||
// AssignmentsBucket above, which keeps its name and its readers (ADR 0408 §3). Nothing is removed from them (§4), they keep
|
||||
// ChainHistory earlier revisions of every key (§5), and a value is never cut (§6).
|
||||
var (
|
||||
BuildsBucket = BucketName(ControllerSeat, ChainBuilds)
|
||||
RolloutsBucket = BucketName(ControllerSeat, ChainRollouts)
|
||||
)
|
||||
|
||||
// **The assignments bucket keeps the marker age (MirrorMarkerTTL) it was made with as a mirror.** A stream that allows
|
||||
// messages an age cannot be made to disallow them (the server refuses the update: "message TTL status can not be
|
||||
// disabled", nats-server stream.go), so asserting the bucket without the age would fail the controller's start on a
|
||||
// mesh that has the bucket. The chain never purges or removes a key (ADR 0408 §4), so no marker is ever written and the
|
||||
// age ends nothing; it is kept only so the stream's configuration stays one the server accepts.
|
||||
//
|
||||
// The chain buckets' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||
const (
|
||||
ChainBuilds = "builds"
|
||||
ChainRollouts = "rollouts"
|
||||
)
|
||||
|
||||
// The chain buckets' bounds. ChainHistory is the bus's largest history (ADR 0408 §5). A value is at most ChainValueBytes,
|
||||
// just under the server's largest message of a mebibyte, which also carries the subject and the headers (ADR 0408 §6):
|
||||
// the controller writes none larger and the move refuses to. The buckets' size is the arithmetic of the survey of
|
||||
// issue 981: 8585 builds of 1 to 40 KB, 1049 rollouts of about 2 KB a revision and 64 revisions, so 512 MiB for the two
|
||||
// that grow, and the assignments, 285 of under a kilobyte, 64 MiB.
|
||||
const (
|
||||
ChainHistory = 64
|
||||
ChainValueBytes = 1<<20 - 8<<10
|
||||
ChainBuildsBytes = 512 << 20
|
||||
ChainRolloutsBytes = 512 << 20
|
||||
ChainAssignmentBytes = 64 << 20
|
||||
)
|
||||
|
||||
// GrantsBucket holds, keyed `<module>.<fingerprint hex>`, each fingerprint of a module whose bus credential the controller
|
||||
// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's
|
||||
// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner,
|
||||
// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's
|
||||
// (delivery.ControllerGrantsBucket, ControllerGrantKey).
|
||||
var GrantsBucket = BucketName(ControllerSeat, ControllerGrants)
|
||||
|
||||
// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`.
|
||||
const ControllerGrants = "grants"
|
||||
|
||||
// ChainBuckets are the three buckets of the chain: builds, rollouts and assignments.
|
||||
func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} }
|
||||
|
||||
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
|
||||
// that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no
|
||||
// other.
|
||||
var MirrorsWrittenByAnother = map[string]string{}
|
||||
|
||||
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
|
||||
const (
|
||||
MirrorValueBytes = 64 << 10
|
||||
MirrorBytes = 64 << 20
|
||||
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
|
||||
// per-message age for it, and the writer gives each marker a day.
|
||||
MirrorMarkerTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// PlanStateBuckets are the buckets of the plans' state that release 6 deletes (novox/hq ADR 0539 clause 13): the merge
|
||||
// window's clock, the silent sends' waits and the building plans' hold. The controller deletes each at its first start
|
||||
// and says it once (composed_start.go); none is asserted, granted or written any more.
|
||||
var PlanStateBuckets = []string{BucketName(ControllerSeat, "merge-window"), BucketName(ControllerSeat, "rollout-silent"),
|
||||
BucketName(ControllerSeat, "plans-building")}
|
||||
|
||||
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||
const AskedKeptFor = 30 * 24 * time.Hour
|
||||
|
||||
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
|
||||
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
|
||||
const LeaseTTL = 15 * time.Second
|
||||
|
||||
// The bounds to-be 45 §6 sets for calls: the last thousand, or fourteen days, whichever is fewer.
|
||||
// A call is two keys — its record, and its answer apart so a listing does not read every answer —
|
||||
// so the stream holds twice as many messages as it keeps calls.
|
||||
const (
|
||||
KeptCallsDurably = 1000
|
||||
CallsKeptFor = 14 * 24 * time.Hour
|
||||
// CallAnswerBytes is the most of one answer kept: a whole declaration is far smaller, and an
|
||||
// answer larger is cut and says so.
|
||||
CallAnswerBytes = 64 << 10
|
||||
// HandActsKeptFor is as long as a condition's history (to-be 45 §2): an act by hand is read
|
||||
// back beside what it addressed.
|
||||
HandActsKeptFor = 90 * 24 * time.Hour
|
||||
// ConditionHistoryKeptFor is how long a condition's transitions are kept (to-be 45 §2).
|
||||
ConditionHistoryKeptFor = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||
func IsControllerBucket(bucket string) bool {
|
||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
|
||||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
|
||||
bucket == DeclaredPackagesBucket ||
|
||||
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
|
||||
bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket ||
|
||||
bucket == GrantsBucket || bucket == delivery.ControllerComposedBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket,
|
||||
DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket,
|
||||
delivery.ControllerComposedBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
type ControllerBucketsAsserter interface {
|
||||
EnsureControllerBuckets() error
|
||||
}
|
||||
|
||||
// chainBucket is the options of a bucket of the chain: sixty-four revisions of each key, no age, a value up to
|
||||
// ChainValueBytes, on disk.
|
||||
func chainBucket(bucket, why string, bytes int64, markers time.Duration) jetstream.KeyValueConfig {
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: bucket,
|
||||
Description: why,
|
||||
History: ChainHistory,
|
||||
MaxValueSize: ChainValueBytes,
|
||||
MaxBytes: bytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
LimitMarkerTTL: markers,
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureLeaseBucket creates the lease's bucket if absent and brings its options to match (to-be 45
|
||||
// §6). **Before the lease is taken, by any candidate**: it is the lease's own precondition, and asserting
|
||||
// a bucket that exists changes nothing. File storage, so its revisions — the epochs — outlive a restart of
|
||||
// the bus; one raised again from nothing is moved past the highest epoch issued when the lease is taken.
|
||||
func EnsureLeaseBucket(ctx context.Context, js jetstream.JetStream) error {
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: LeaseBucket,
|
||||
Description: "the controller's lease (novox/hq to-be 45 §6): the key `holder`, written by compare-and-set " +
|
||||
"by the one controller instance that may act and renewed every five seconds; its revision when taken " +
|
||||
"is the epoch every declaration and plan write carries",
|
||||
History: 1,
|
||||
TTL: LeaseTTL,
|
||||
MaxValueSize: 4 << 10,
|
||||
MaxBytes: 1 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", LeaseBucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HaveBucketConfig is the options of the have bucket (novox/hq ADR 0429 §1): delivery.HaveHistory revisions of each
|
||||
// key, no age, 64 MiB on disk like the assignments. A module's have is JSON of about 500 bytes, but a node's own key
|
||||
// carries its whole facts (its profile among them), several kilobytes on a desktop: a value is as large as the chain's
|
||||
// buckets take, since a value is never cut (novox/hq ADR 0408 §6, issue 1162).
|
||||
func HaveBucketConfig() jetstream.KeyValueConfig {
|
||||
return jetstream.KeyValueConfig{
|
||||
Bucket: delivery.HaveBucket,
|
||||
Description: "what each machine has of each module, one key `<node>.<module>` (and `<node>.node` for the machine's " +
|
||||
"own facts) (novox/hq ADR 0429, design 47): written by each machine's node-engine under its own node's keys " +
|
||||
"alone, never by the controller; read by the delivery view and the watchman",
|
||||
History: delivery.HaveHistory,
|
||||
MaxValueSize: ChainValueBytes,
|
||||
MaxBytes: 64 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The streams a manifest declares, as the server is asked to create them (novox/hq ADR 0547 §5, issue 1790): the
|
||||
// grants part creates each one the bus does not hold yet, with what the controller asserts at its start, so the two
|
||||
// make the same stream. Pure. Only a create is ever asked with these: a stream the bus holds is never updated by the
|
||||
// grants part (CreateStreamGrants grants no update).
|
||||
|
||||
// BucketStreamConfig is a module's bucket as the stream the controller's CreateOrUpdateKeyValue makes of its options
|
||||
// (mesh-controller internal/broker BucketConfig, with no marker age held): the client library's key-value shape
|
||||
// (rollup, no delete, direct reads, discard new, a duplicate window of two minutes or the age when shorter) with the
|
||||
// mesh's caps, file storage and one replica.
|
||||
func BucketStreamConfig(b Bucket) jetstream.StreamConfig {
|
||||
history := b.History
|
||||
if history < 1 {
|
||||
history = 1
|
||||
}
|
||||
age := time.Duration(b.TTLSeconds) * time.Second
|
||||
dupes := 2 * time.Minute
|
||||
if age > 0 && age < dupes {
|
||||
dupes = age
|
||||
}
|
||||
cfg := jetstream.StreamConfig{
|
||||
Name: "KV_" + b.Bucket(),
|
||||
Description: b.Why(),
|
||||
Subjects: []string{"$KV." + b.Bucket() + ".>"},
|
||||
MaxMsgsPerSubject: int64(history),
|
||||
MaxBytes: StateMaxBytes,
|
||||
MaxAge: age,
|
||||
MaxMsgSize: StateMaxValueBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
Replicas: 1,
|
||||
AllowRollup: true,
|
||||
DenyDelete: true,
|
||||
Duplicates: dupes,
|
||||
MaxMsgs: -1,
|
||||
MaxConsumers: -1,
|
||||
AllowDirect: true,
|
||||
Discard: jetstream.DiscardNew,
|
||||
}
|
||||
if b.KeyTTL {
|
||||
cfg.AllowMsgTTL, cfg.SubjectDeleteMarkerTTL = true, KeyTTLMarkerTTL
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ModuleStreamConfig is a module's own stream as the controller asserts it (mesh-controller internal/broker
|
||||
// StreamConfigFor of ModuleStream.Stream).
|
||||
func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig {
|
||||
s := m.Stream()
|
||||
cfg := jetstream.StreamConfig{
|
||||
Name: s.Name,
|
||||
Subjects: s.Subjects,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: time.Duration(s.MaxAge) * time.Second,
|
||||
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
||||
Description: s.Why,
|
||||
AllowDirect: s.Direct,
|
||||
AllowMsgTTL: s.AllowMsgTTL,
|
||||
}
|
||||
if s.Retention == RetentionWorkQueue {
|
||||
cfg.Retention = jetstream.WorkQueuePolicy
|
||||
}
|
||||
if s.Restricted || s.WorkQueue {
|
||||
cfg.Metadata = map[string]string{}
|
||||
if s.Restricted {
|
||||
cfg.Metadata[RestrictedMetadata] = "true"
|
||||
}
|
||||
if s.WorkQueue {
|
||||
cfg.Metadata[WorkQueueMetadata] = "true"
|
||||
}
|
||||
}
|
||||
if s.MaxBytes > 0 {
|
||||
cfg.MaxBytes = s.MaxBytes
|
||||
}
|
||||
if s.DiscardNew {
|
||||
cfg.Discard = jetstream.DiscardNew
|
||||
}
|
||||
if s.DuplicatesSeconds > 0 {
|
||||
cfg.Duplicates = time.Duration(s.DuplicatesSeconds) * time.Second
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Defends issue 1790 (ADR 0547 §5): the grants part creates a declared bucket and stream as the controller asserts them.
|
||||
func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
|
||||
plain := BucketStreamConfig(Bucket{Module: "mesh-vault", Name: "bus-accounts"})
|
||||
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
|
||||
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
|
||||
WorkQueue: true, Duplicates: 1})
|
||||
for _, row := range []struct {
|
||||
what string
|
||||
got, want any
|
||||
}{
|
||||
{"bucket name", plain.Name, "KV_mesh-vault_bus-accounts"},
|
||||
{"bucket subjects", plain.Subjects[0], "$KV.mesh-vault_bus-accounts.>"},
|
||||
{"bucket history defaults to one", plain.MaxMsgsPerSubject, int64(1)},
|
||||
{"bucket cap", plain.MaxBytes, int64(StateMaxBytes)},
|
||||
{"bucket value cap", plain.MaxMsgSize, int32(StateMaxValueBytes)},
|
||||
{"bucket duplicate window", plain.Duplicates, 2 * time.Minute},
|
||||
{"bucket discards new", plain.Discard, jetstream.DiscardNew},
|
||||
{"bucket direct and rollup, no delete", plain.AllowDirect && plain.AllowRollup && plain.DenyDelete, true},
|
||||
{"bucket without key ttl", plain.AllowMsgTTL, false},
|
||||
{"history", keyed.MaxMsgsPerSubject, int64(5)},
|
||||
{"age", keyed.MaxAge, time.Minute},
|
||||
{"duplicate window under the age", keyed.Duplicates, time.Minute},
|
||||
{"key ttl markers", keyed.SubjectDeleteMarkerTTL, KeyTTLMarkerTTL},
|
||||
{"key ttl", keyed.AllowMsgTTL, true},
|
||||
{"queue retention", queue.Retention, jetstream.WorkQueuePolicy},
|
||||
{"queue subjects", queue.Subjects[0], "mesh.stream.b.event.ask"},
|
||||
{"queue age", queue.MaxAge, 7 * 24 * time.Hour},
|
||||
{"queue cap", queue.MaxBytes, int64(32 << 20)},
|
||||
{"queue discards new", queue.Discard, jetstream.DiscardNew},
|
||||
{"queue duplicates", queue.Duplicates, time.Hour},
|
||||
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
|
||||
} {
|
||||
if row.got != row.want {
|
||||
t.Errorf("%s: %v, want %v", row.what, row.got, row.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import "testing"
|
||||
|
||||
// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream
|
||||
// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing
|
||||
// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547
|
||||
// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user
|
||||
// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name.
|
||||
func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
|
||||
records := Records{
|
||||
Nodes: []string{"novox", "ace"},
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": {
|
||||
{Module: "nats", SnapshotsTheBus: true},
|
||||
// mesh-sdk's copy alone (ADR 0547 clause 9, named readers): the vault's hashes are read by the grants part
|
||||
// alone, whatever another module's reads say; the controller's copy grants any module that declares it.
|
||||
{Module: "mesh-vault", State: []Bucket{{Module: "mesh-vault", Name: "bus-accounts"}}},
|
||||
{Module: "spy", Serves: []string{"spy_x"}, Reads: []string{VaultBusAccounts}},
|
||||
{Module: "mesh-delivery-builder",
|
||||
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}},
|
||||
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}},
|
||||
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
|
||||
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
|
||||
{Module: RuntimeModule},
|
||||
}},
|
||||
}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNode || u.Kind == KindNodeTools {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", u.Username(), err)
|
||||
}
|
||||
perms[u.Username()] = p
|
||||
continue
|
||||
}
|
||||
if u.Kind != KindModule {
|
||||
continue
|
||||
}
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", u.Username(), err)
|
||||
}
|
||||
perms[u.Module] = p
|
||||
}
|
||||
for _, row := range []struct {
|
||||
who, subject string
|
||||
may bool
|
||||
}{
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true},
|
||||
{GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
|
||||
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
|
||||
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
|
||||
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
|
||||
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
|
||||
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
|
||||
{GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true},
|
||||
{GrantsModule, "$O.declaration-files-novox.C.abc", false},
|
||||
{GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false},
|
||||
{"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false},
|
||||
// The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write.
|
||||
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true},
|
||||
{"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true},
|
||||
{"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true},
|
||||
{"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true},
|
||||
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false},
|
||||
{"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false},
|
||||
{"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false},
|
||||
{"node.novox", "$O.mesh-grants-user-list.C.abc", false},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false},
|
||||
{"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false},
|
||||
{GrantsModule, "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", true},
|
||||
{"spy", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
|
||||
{"novox.node-tools", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
|
||||
} {
|
||||
if got := MayPublish(perms[row.who], row.subject); got != row.may {
|
||||
t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may)
|
||||
}
|
||||
}
|
||||
// Issue 1790 (2026-10-26): a module hears the bus create each bucket it may bind, its own and those it reads, so a
|
||||
// state missing at its start is bound when it appears; no other bucket's, and no other advisory.
|
||||
for _, row := range []struct {
|
||||
who, subject string
|
||||
may bool
|
||||
}{
|
||||
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps", true},
|
||||
{GrantsModule, "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-vault_bus-accounts", true},
|
||||
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions", false},
|
||||
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.LOG_mesh-delivery-builder_builds", false},
|
||||
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.DELETED.KV_mesh-delivery-builder_steps", false},
|
||||
} {
|
||||
if got := MaySubscribe(perms[row.who], row.subject); got != row.may {
|
||||
t.Errorf("%s may hear %s: %v, want %v", row.who, row.subject, got, row.may)
|
||||
}
|
||||
}
|
||||
// A name that is no plain token is never granted, and a module other than the grants part is never granted one.
|
||||
if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 {
|
||||
t.Errorf("grants for wildcard names: %v", got)
|
||||
}
|
||||
other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") {
|
||||
t.Error("a module other than the grants part was granted a create")
|
||||
}
|
||||
// The writers table refuses the grants part an update, and a create of a stream it was not given.
|
||||
g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}}
|
||||
if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil {
|
||||
t.Errorf("the declared create refused: %v", err)
|
||||
}
|
||||
for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} {
|
||||
if CheckWriters(g, []string{refused}) == nil {
|
||||
t.Errorf("%s was not refused", refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// The object buckets a declaration's large files travel in (novox/hq ADR 0486, issues 1696 and 1697): **one bucket for each
|
||||
// node**, `declaration-files-<node>`.
|
||||
//
|
||||
// **The controller alone writes them, and a node-engine reads an object by its name from its own bucket** (ADR 0486 §2): its
|
||||
// account may neither list the bucket nor write it, no account may touch another node's, and no other account has any access.
|
||||
// Why a bucket each: a subject pattern cannot tell a name from a wildcard. A grant of `...M.*` matches a publish to the literal
|
||||
// subject `...M.*` too (probed on nats-server 2.11.17 by the review of this issue: a node created a consumer filtered on
|
||||
// `$O.<bucket>.C.*` and read every object of the bucket), so in one shared bucket a node could read every other node's files.
|
||||
// With a bucket each, the same trick reads only the node's own files, which are the files of its own declaration.
|
||||
//
|
||||
// A bucket of the bus's object store is a stream, OBJ_<bucket>, whose messages are the objects' metadata
|
||||
// (`$O.<bucket>.M.<base64 of the name>`) and chunks (`$O.<bucket>.C.<nuid>`).
|
||||
|
||||
// DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike.
|
||||
const DeclarationFilesBucket = declaration.FileBucketPrefix
|
||||
|
||||
// DeclarationFilesBucketOf is the bucket a node's files are in: mesh-sdk's FileBucketFor, the one place the name is composed. A
|
||||
// node name it refuses (one that could carry a subject wildcard or separator into a grant) is an error, never a bucket.
|
||||
func DeclarationFilesBucketOf(node string) (string, error) { return declaration.FileBucketFor(node) }
|
||||
|
||||
// DeclarationFilesStreamOf is the stream under a node's bucket: mesh-sdk's FileStreamFor.
|
||||
func DeclarationFilesStreamOf(node string) (string, error) { return declaration.FileStreamFor(node) }
|
||||
|
||||
// DeclarationFilesMaxBytes is a bucket's size: the objects are immutable and regenerable, and one node's hold well under a hundred
|
||||
// megabytes.
|
||||
const DeclarationFilesMaxBytes = 1 << 30
|
||||
|
||||
// DeclarationFilesKeptFor is how long an object stays after no node's latest declaration names it (ADR 0486 §3).
|
||||
const DeclarationFilesKeptFor = 24 * time.Hour
|
||||
|
||||
// DeclarationFilesWrites is the one pattern the controller may publish to write a node's bucket: every object's metadata and
|
||||
// chunks. The server's API for the stream is the controller's `$JS.API.>`.
|
||||
func DeclarationFilesWrites(node string) (string, error) {
|
||||
bucket, err := DeclarationFilesBucketOf(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "$O." + bucket + ".>", nil
|
||||
}
|
||||
|
||||
// DeclarationFilesReads is what a node-engine's account may publish to read an object of its own bucket by its name, and nothing
|
||||
// more. The set is the one node-engine's own call (mesh-host internal/link FileObject) makes, for B the node's bucket and S its
|
||||
// stream (issue 1697, notes 4 and 5):
|
||||
//
|
||||
// - the object's metadata, by a direct get of the last message of the one subject that names it. **Only the suffixed
|
||||
// form**: the bare `$JS.API.DIRECT.GET.S` takes a sequence number and would read any message of the stream.
|
||||
// - the ordered consumer on that object's chunks: `$JS.API.CONSUMER.CREATE.S.<name>.$O.B.C.<nuid>`. **Not `S.>`**:
|
||||
// a consumer filtered on `$O.B.M.>` would read every object's name.
|
||||
// - the flow-control replies of that consumer, without which a large object stalls at the flow-control window.
|
||||
// - the consumer's deletion, best effort: the consumer's inactivity threshold removes it if this is refused.
|
||||
//
|
||||
// **Never granted:** STREAM.INFO (a `subjects_filter` of `>` lists every name), STREAM.MSG.GET (any message by its sequence), a
|
||||
// publish to `$O.B.>` (a write), and any subject of another node's stream. A literal `*` in a subject matches the grant's `*`: it
|
||||
// reaches the objects of this node's own bucket and no other (above).
|
||||
func DeclarationFilesReads(node string) ([]string, error) {
|
||||
bucket, err := DeclarationFilesBucketOf(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream, err := DeclarationFilesStreamOf(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{
|
||||
"$JS.API.DIRECT.GET." + stream + ".$O." + bucket + ".M.*",
|
||||
"$JS.API.CONSUMER.CREATE." + stream + ".*.$O." + bucket + ".C.*",
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".*",
|
||||
"$JS.FC." + stream + ".>",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnsureDeclarationFiles is the same on a JetStream the caller holds.
|
||||
func EnsureDeclarationFiles(ctx context.Context, js jetstream.JetStream, nodes ...string) error {
|
||||
for _, node := range nodes {
|
||||
cfg, err := DeclarationFilesConfig(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := js.CreateOrUpdateObjectStore(ctx, cfg); err != nil {
|
||||
return fmt.Errorf("asserting the object bucket %s: %w", cfg.Bucket, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeclarationFilesConfig is a node's bucket as the controller asserts it.
|
||||
func DeclarationFilesConfig(node string) (jetstream.ObjectStoreConfig, error) {
|
||||
bucket, err := DeclarationFilesBucketOf(node)
|
||||
if err != nil {
|
||||
return jetstream.ObjectStoreConfig{}, err
|
||||
}
|
||||
return jetstream.ObjectStoreConfig{
|
||||
Bucket: bucket,
|
||||
Description: "the contents of the files over 64 KiB of the declaration of " + node + ", each under the hex of its sha256 " +
|
||||
"digest (novox/hq ADR 0486): written by the controller alone, read by the node-engine of that node by name, removed " +
|
||||
"a day after the node's latest declaration no longer names it",
|
||||
MaxBytes: DeclarationFilesMaxBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Streams and consumers derived from what modules declare.
|
||||
//
|
||||
// The mesh's own four exist before any module does (streams.go). Everything here is the other
|
||||
// half: a seat's stream comes into being when the module declaring it is **registered**, and a
|
||||
// consumer when a module is **assigned** — which is why ADR 0116's task 1.4 had to be narrowed to
|
||||
// the foundation set. Neither has happened at genesis.
|
||||
//
|
||||
// All of it is a pure function of declarations. The controller is still the only writer; this is
|
||||
// only what it writes.
|
||||
|
||||
// A Consumer is a durable subscription the controller creates on a module's behalf. A module
|
||||
// declares what it reacts to, never how delivery works, so it does not name these and cannot
|
||||
// misconfigure them.
|
||||
type Consumer struct {
|
||||
Name string
|
||||
Stream string
|
||||
// Filters are the subjects this consumer receives. One consumer per module with several
|
||||
// filters, rather than one per consumed event: its ack subject is derived from its name, and
|
||||
// a module with five consumers would need five ack permissions to ack its own deliveries.
|
||||
Filters []string
|
||||
// Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a
|
||||
// seat later being relaxed to several. Authority and delivery are kept separate on purpose.
|
||||
Queue string
|
||||
// Push asks the server to deliver to a subject rather than wait to be pulled.
|
||||
//
|
||||
// For the mesh's own consumer, where the controller wants every message to arrive in the one
|
||||
// loop it already runs: pulling would mean a second goroutine fetching batches and handing
|
||||
// them over, and a loop that acts on one message at a time is the property the store window
|
||||
// depends on. A queue group implies this, because a group has nothing to pull from.
|
||||
Push bool
|
||||
// TakesOverFrom names a consumer of the same stream whose filter this one takes over (issue 973): made while
|
||||
// that consumer still filters this one's subject, it starts after what that consumer has acknowledged, so a
|
||||
// message the old one held unacknowledged is not lost to the change. Otherwise it starts as FromNow says.
|
||||
TakesOverFrom string
|
||||
// DeliverSubject, when set, is where a push consumer delivers instead of DeliverSubjectFor's.
|
||||
DeliverSubject string
|
||||
// AckWaitSeconds before an unacknowledged delivery is redelivered.
|
||||
AckWaitSeconds int
|
||||
// MaxDeliver is how often a message is handed over before the consumer gives it up; zero for no
|
||||
// bound. What a consumer gives up is kept in DEAD_LETTERS by the controller (novox/hq issue 330).
|
||||
MaxDeliver int
|
||||
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
|
||||
// the server's default, which is many. **One, for a consumer handled one at a time**
|
||||
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
|
||||
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
|
||||
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
|
||||
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
|
||||
MaxAckPending int
|
||||
// FromNow makes a consumer that does not exist yet start at the stream's end rather than its
|
||||
// beginning (novox/hq issue 248). For a consumer that reacts to announcements — a merge, a build's
|
||||
// outcome — on a stream that keeps a week of them: the server's default, everything the stream
|
||||
// holds, replays every merge and every build of that week as if it had just happened. A consumer
|
||||
// that exists keeps where it is, whatever this says; only its making is decided here.
|
||||
FromNow bool
|
||||
Why string
|
||||
// Resettable says why this consumer may be re-made to deliver from now by the mesh itself, with
|
||||
// nobody asked (novox/hq to-be 45 §7, healer H4): what a reset drops, something else catches up.
|
||||
// Empty for every consumer where nothing would — a module's, whose events would be lost to it.
|
||||
// Not a property of the consumer on the bus: nothing here is sent to the server.
|
||||
Resettable string
|
||||
}
|
||||
|
||||
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
|
||||
// the module holding it, because the holder can change and the queued work must not care — which
|
||||
// is the whole reason a caller addresses a seat instead of a module.
|
||||
func seatStreamName(seat string) string { return "SEAT_" + upperSnake(seat) }
|
||||
|
||||
// SeatStreams is one work queue per declared seat, created when the declaring module is
|
||||
// registered rather than when it is assigned.
|
||||
//
|
||||
// **The stream exists before anyone holds the seat, and that is the point.** Work queues until a
|
||||
// holder appears, so installing the telegram module a week after something started sending to it
|
||||
// flushes the backlog instead of having lost it. A stream created at assignment would make "the
|
||||
// holder is not here yet" mean "your messages are gone".
|
||||
func SeatStreams(seats []DeclaredSeat) []Stream {
|
||||
sorted := append([]DeclaredSeat(nil), seats...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
|
||||
var out []Stream
|
||||
for _, s := range sorted {
|
||||
if len(s.Accepts) == 0 {
|
||||
// A seat that only emits and serves needs no stream: its events ride EVENTS and its
|
||||
// tools are core request/reply, which is never persisted.
|
||||
continue
|
||||
}
|
||||
retain := s.RetainSeconds
|
||||
if retain == 0 {
|
||||
retain = 7 * 24 * 60 * 60
|
||||
}
|
||||
out = append(out, Stream{
|
||||
Name: seatStreamName(s.Name),
|
||||
Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
|
||||
Retention: RetentionWorkQueue,
|
||||
MaxAge: retain,
|
||||
Why: fmt.Sprintf("work submitted to the %s seat; one holder consumes it, and it "+
|
||||
"queues while nobody does", s.Name),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A DeclaredSeat is a seat as the catalogue knows it. Mirrored here rather than imported so this
|
||||
// package stays free of the catalogue's own types — the same reason the host mirrors the
|
||||
// contracts instead of importing the sdk.
|
||||
type DeclaredSeat struct {
|
||||
Name string
|
||||
Accepts []string
|
||||
// Emits are the verbs the seat's holder publishes under the seat's own name. An event about a
|
||||
// role belongs here rather than in the holder's namespace, because the name then outlives
|
||||
// whoever fills it (novox/hq ADR 0121, 04-ISSUES/127).
|
||||
Emits []string
|
||||
// Serves are the verbs the holder answers, request and reply.
|
||||
Serves []string
|
||||
RetainSeconds int
|
||||
// PulledByKind is Seat's: a worker per accepted kind, derived from the holders' seat traffic, not here.
|
||||
PulledByKind bool
|
||||
}
|
||||
|
||||
// ConsumerFor is the durable consumer a module's declarations imply, or false when it subscribes
|
||||
// to nothing and needs none.
|
||||
//
|
||||
// One per module, with every consumed subject as a filter, because its ack permission is derived
|
||||
// from its name: a module with a consumer per event would need an ack permission per consumer,
|
||||
// and the permission list would stop being derivable from the declaration.
|
||||
func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
|
||||
// a role was missing here, so the one module that does it got no consumer at all: it started,
|
||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||
hearsAStream := false
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) > 0 {
|
||||
hearsAStream = true
|
||||
}
|
||||
}
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
|
||||
return Consumer{}, false
|
||||
}
|
||||
perms, err := PermissionsFor(p)
|
||||
if err != nil {
|
||||
return Consumer{}, false
|
||||
}
|
||||
// Events, wherever they live: a module's own namespace, and the namespace of any role it watches
|
||||
// (novox/hq ADR 0121). Tool subjects and inboxes are subscribed directly and are not a consumer's
|
||||
// business, which is why this is a filter and not the whole list.
|
||||
var filters []string
|
||||
for _, s := range perms.Subscribe {
|
||||
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
|
||||
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
|
||||
filters = append(filters, s)
|
||||
}
|
||||
}
|
||||
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
|
||||
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
|
||||
if len(filters) == 0 && !hearsAStream {
|
||||
return Consumer{}, false
|
||||
}
|
||||
sort.Strings(filters)
|
||||
// And the events given up on and delivered again to this consumer alone (novox/hq issue 330).
|
||||
filters = append(filters, AgainFilter(consumerDurable(p)))
|
||||
return Consumer{
|
||||
Name: consumerDurable(p),
|
||||
Stream: consumerStream(p),
|
||||
Filters: filters,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
// From now, as the controller's own consumers have been since issue 248: a module made on a stream that
|
||||
// keeps a week replays none of it. What a part needs of the present it reads from its owner
|
||||
// (novox/hq ADR 0443 §1, §3). A consumer that exists keeps its place whatever this says.
|
||||
FromNow: true,
|
||||
Why: "what " + p.Module + " declared it consumes; after max-deliver it gives an event up, and the " +
|
||||
"controller keeps it in DEAD_LETTERS until a person delivers it again or drops it",
|
||||
}, true
|
||||
}
|
||||
|
||||
// ModuleConsumer is one module's durable consumer, with the module and node it is for.
|
||||
type ModuleConsumer struct {
|
||||
Node, Module string
|
||||
Consumer Consumer
|
||||
}
|
||||
|
||||
// ConsumersOf is every module's durable consumer the composed users imply: each module user's, and
|
||||
// each module a runtime carries — a carried module with no account of its own is no user (novox/hq
|
||||
// issue 195), and its consumer is still the controller's to make, since the runtime reads it on the
|
||||
// module's behalf (ADR 0198). One per module and node, whichever of the two named it first.
|
||||
func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||
var out []ModuleConsumer
|
||||
seen := map[string]bool{}
|
||||
add := func(p Principal) {
|
||||
key := p.Node + "/" + p.Module
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
c, needed := ConsumerFor(p)
|
||||
// **A module with a work-queue consumer of its own needs no other reason to be visited here**
|
||||
// (novox/hq ADR 0436 §1, §2, §6): mesh-mail's sender consumes and watches nothing of its own, so
|
||||
// ConsumerFor alone would have found nothing needed and skipped it before its one queue consumer
|
||||
// was ever composed.
|
||||
if !needed && len(p.Queues) == 0 {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
if needed {
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
|
||||
// And one on each module stream it hears (novox/hq ADR 0363 §5).
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
|
||||
}
|
||||
}
|
||||
// And the one durable consumer of each of its own work-queue streams, for the one process its
|
||||
// bus.queues names (novox/hq ADR 0436 §1, §2, §6).
|
||||
for _, q := range p.Queues {
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: QueueConsumer(p, q)})
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
if p.Kind == KindModule {
|
||||
add(p)
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
if p.Kind != KindNodeTools {
|
||||
continue
|
||||
}
|
||||
for _, d := range p.Carries {
|
||||
// **A carried module's work-queue consumer is still the controller's to make** (novox/hq ADR
|
||||
// 0436 §1, §2, §6), the same reason its ordinary consumer is (ADR 0198): mesh-mail has no
|
||||
// account of its own (NoAccount) and is carried here, but its process does, and the consumer
|
||||
// it reads through must exist and survive PruneHeard whether or not the module itself speaks.
|
||||
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||
Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
|
||||
//
|
||||
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
|
||||
// 0190). The seat is *authority* — who may be the telegram sender — and the worker is *delivery*,
|
||||
// kept separate so that relaxing one changes nothing about the other: a node-scoped seat has a
|
||||
// holder per machine, and all of them take from this one consumer, so the work is shared without
|
||||
// any holder knowing about the others. Pulled rather than pushed because a push consumer hands the
|
||||
// next ask to whichever subscriber the server picks, busy or not, and a pulled one is asked for by
|
||||
// a holder that has just become free. Which is also what ends the race issue 186 describes — asks
|
||||
// delivered behind the one being worked, expiring unacknowledged and dropped after the fifth
|
||||
// redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive
|
||||
// (stillWorking); the ack wait is for a holder that died.
|
||||
func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) {
|
||||
if len(seat.Accepts) == 0 || seat.PulledByKind {
|
||||
// A seat pulled by kind has a worker per kind, which its holders' seat traffic derives (SeatTrafficObjects): one
|
||||
// worker filtered on every accept would overlap them on the work queue.
|
||||
return Consumer{}, false
|
||||
}
|
||||
return Consumer{
|
||||
Name: "SEAT_" + upperSnake(seat.Name) + "_worker",
|
||||
Stream: seatStreamName(seat.Name),
|
||||
Filters: []string{"mesh.seat." + seat.Name + ".accept.>"},
|
||||
AckWaitSeconds: 60,
|
||||
MaxDeliver: 5,
|
||||
// As many in flight as there are holders working, which pulling bounds by itself: a holder
|
||||
// fetches one and fetches again only after it acknowledged. The server's default stands.
|
||||
Why: fmt.Sprintf("%s on %s holds %s; every holder pulls one ask at a time from this worker "+
|
||||
"and acknowledges after the work is done, so a crash mid-work redelivers rather than "+
|
||||
"loses and an idle holder is the one that takes the next ask", module, node, seat.Name),
|
||||
}, true
|
||||
}
|
||||
|
||||
// NodeConsumer is the durable consumer a node reads its own declaration through.
|
||||
//
|
||||
// **Derived from a node existing, and created by the controller, because a host cannot create it.**
|
||||
// A host's account may subscribe its own declaration subject and publish its own ack subject, and
|
||||
// reaches no part of the JetStream API — which is correct (the controller is the only writer of
|
||||
// consumer definitions, design 25 §3) and means the consumer must be waiting before the host binds
|
||||
// to it. Named after the node, because the node's ack grant is `$JS.ACK.NODES.<node>.>` and a
|
||||
// consumer named anything else is one the host cannot acknowledge a delivery from.
|
||||
//
|
||||
// **No max-deliver, and a long ack wait.** A declaration is settled only after the node has applied
|
||||
// it and reported, which is minutes on a machine pulling images; and a declaration the mesh cannot
|
||||
// get a node to accept is not one to give up on, because the stream keeps only the newest per node
|
||||
// anyway — so there is exactly one message per node to redeliver, for as long as that node is away.
|
||||
func NodeConsumer(node string) Consumer {
|
||||
return Consumer{
|
||||
Name: node,
|
||||
Stream: "NODES",
|
||||
Filters: []string{"mesh.node." + node + ".declare"},
|
||||
Push: true,
|
||||
AckWaitSeconds: 300,
|
||||
Why: "how " + node + " hears what it should be; last-per-subject, so a node that was away " +
|
||||
"gets exactly the current declaration and nothing older",
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNodeConsumers brings every known node's declaration consumer into being.
|
||||
//
|
||||
// Asserted on start as well as created at enrolment, for the reason the streams are: a mesh raised
|
||||
// from a restored backup, or one whose bus was recreated, has node records and no consumers, and a
|
||||
// node whose consumer is missing hears nothing while everything else about it looks correct.
|
||||
func AssertNodeConsumers(e Ensurer, nodes []string) error {
|
||||
for _, n := range nodes {
|
||||
if err := e.EnsureConsumer(NodeConsumer(n)); err != nil {
|
||||
return fmt.Errorf("asserting how %s hears its declaration: %w", n, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllOverlaps reports subject filters claimed by more than one stream, across the mesh's own and
|
||||
// every derived one.
|
||||
//
|
||||
// NATS refuses an overlapping stream rather than merging it (verified against nats-server 2.10:
|
||||
// "subjects overlap with an existing stream"), so this is not a subtle divergence — it is a
|
||||
// registration that fails. Catching it here names both streams, before a half-applied mesh does.
|
||||
func AllOverlaps(seats []DeclaredSeat) []string {
|
||||
all := append(MeshStreams(), SeatStreams(seats)...)
|
||||
seen := map[string]string{}
|
||||
var clashes []string
|
||||
for _, s := range all {
|
||||
for _, subject := range s.Subjects {
|
||||
if first, ok := seen[subject]; ok {
|
||||
clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject))
|
||||
continue
|
||||
}
|
||||
seen[subject] = s.Name
|
||||
}
|
||||
}
|
||||
sort.Strings(clashes)
|
||||
return clashes
|
||||
}
|
||||
|
||||
// upperSnake makes a stream name from a seat name. NATS stream names may not contain a dot,
|
||||
// a space or a wildcard, and a hyphen is legal but reads badly beside the mesh's own.
|
||||
func upperSnake(s string) string {
|
||||
out := []rune(s)
|
||||
for i, r := range out {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
out[i] = r - 32
|
||||
case r == '-' || r == '.':
|
||||
out[i] = '_'
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Package buscompose is the bus's composer: who may say what on the bus, as the user list and the server configuration
|
||||
// the bus loads, derived from the mesh's records (Users, WithPasswords, ComposeAccounts, Compose), with the grants,
|
||||
// streams, state and logs it composes them from.
|
||||
//
|
||||
// **A copy of the controller's internal/broker, composer half** (issue 1377, novox/hq ADR 0547 §5), taken at
|
||||
// mesh-controller 7cd4d18b with the grants part's membership grant of f67d1553 (ADR 0547 §5) and its grant to create the streams manifests declare (issue 1790): the bus client (jetstream.go), the broker's address from the environment (broker.go, onnats.go
|
||||
// but its one constant) and the two methods on that client are left out; the lease's two subjects are copied in
|
||||
// (nats.go). The controller keeps its own copy, unchanged, until it loses its lease at ADR 0547's third milestone, so
|
||||
// for that time two composers state who may say what and must say the same: TestTheCopiedComposerComposesAsTheControllers
|
||||
// holds this copy to lists the controller's own code wrote from the same fixtures. A change to what is granted is made
|
||||
// here and, while the controller composes, in the controller's copy too, never in one alone.
|
||||
package buscompose
|
||||
@@ -1,119 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// The fixtures the copied bus composer is held to (issue 1377, ADR 0547 §5): the same source is compiled into the
|
||||
// controller's internal/broker to write the expected lists, and into mesh-sdk's buscompose to compare against them.
|
||||
|
||||
// UserListFixture is one mesh's records, and the hashes its accounts carry.
|
||||
type UserListFixture struct {
|
||||
Name string
|
||||
Records Records
|
||||
// Hashless are users given no hash, so they are left out and named.
|
||||
Hashless []string
|
||||
}
|
||||
|
||||
func fixtureTelegramSeat() Seat {
|
||||
return Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
|
||||
}
|
||||
|
||||
// UserListFixtures are the meshes, from the controller's own tests' records outward.
|
||||
func UserListFixtures() []UserListFixture {
|
||||
return []UserListFixture{
|
||||
{Name: "empty"},
|
||||
{Name: "the controller's someRecords", Records: Records{
|
||||
Nodes: []string{"two", "one"},
|
||||
Assigned: map[string][]Declared{
|
||||
"one": {{Module: "telegram", Serves: []string{"status"}}},
|
||||
"two": {{Module: "shop", Emits: []string{"order.placed"}}},
|
||||
},
|
||||
Enrolling: []string{"three"},
|
||||
People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}},
|
||||
}},
|
||||
{Name: "seats, consumers, people and a hashless user", Hashless: []string{"two.audit"}, Records: Records{
|
||||
Nodes: []string{"one", "two"},
|
||||
Assigned: map[string][]Declared{
|
||||
"one": {
|
||||
{Module: "telegram", Holds: []Seat{fixtureTelegramSeat()}, Serves: []string{"status"}},
|
||||
{Module: "nats", SnapshotsTheBus: true, Serves: []string{"nats_streams"}},
|
||||
{Module: "mesh-watchman"},
|
||||
{Module: "mesh-grants", Emits: []string{"membership-changed"}},
|
||||
},
|
||||
"two": {
|
||||
{Module: "shop", Uses: []Seat{fixtureTelegramSeat()}, Emits: []string{"order.placed"}},
|
||||
{Module: "audit", Consumes: []string{"shop.order.placed"}},
|
||||
},
|
||||
},
|
||||
People: map[string][]string{"ada": {"*"}, "bob": {"shop.status"}},
|
||||
Interchangeable: map[string]bool{"shop": true},
|
||||
}},
|
||||
{Name: "state, logs, streams, queues and processes", Records: Records{
|
||||
Nodes: []string{"ace", "novox"},
|
||||
Assigned: map[string][]Declared{
|
||||
"novox": {
|
||||
{Module: "mesh-delivery-builder", Emits: []string{"built"},
|
||||
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5},
|
||||
{Module: "mesh-delivery-builder", Name: "presence", PerMachine: true, KeyTTL: true, TTLSeconds: 60}},
|
||||
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds", MaxMiB: 64}},
|
||||
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "asks", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32, WorkQueue: true}},
|
||||
Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}},
|
||||
Processes: []ProcessPrincipal{{Process: "worker", Emits: []string{"built"},
|
||||
Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}},
|
||||
State: []string{"steps"}, Invokes: []string{"gitea.gitea_commit"}}},
|
||||
Invokes: []string{"gitea.gitea_commit"},
|
||||
},
|
||||
{Module: "mesh-delivery-view", Reads: []string{"mesh-delivery-builder.steps"},
|
||||
KeyedReads: []KeyedRead{{Read: "mesh-delivery-builder.presence", Key: "novox"}}},
|
||||
// The grants part, which may create every stream declared here (ADR 0547 §5, issue 1790).
|
||||
{Module: "mesh-grants", Emits: []string{"membership-changed"},
|
||||
State: []Bucket{{Module: "mesh-grants", Name: "assertions", History: 5}}},
|
||||
},
|
||||
"ace": {
|
||||
{Module: "plex", Serves: []string{"plex_status"}, Checks: []string{"plex.plex_status"}},
|
||||
{Module: "silent", NoAccount: true},
|
||||
},
|
||||
},
|
||||
RootFree: map[string]bool{"ace": true},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// HashOf is the stand-in hash a fixture's user carries.
|
||||
func HashOf(user string) string {
|
||||
h := "$2a$11$"
|
||||
for len(h) < 29 {
|
||||
h += user
|
||||
}
|
||||
return h[:29] + "fixturefixturefixturefixture00"
|
||||
}
|
||||
|
||||
// ComposedFor is a fixture's user list as the composer writes it, and the users left out.
|
||||
func ComposedFor(f UserListFixture) (string, []string, error) {
|
||||
users, err := Users(f.Records)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
skip := map[string]bool{}
|
||||
for _, u := range f.Hashless {
|
||||
skip[u] = true
|
||||
}
|
||||
for _, u := range users {
|
||||
if !skip[u.Username()] {
|
||||
hashes[u.Username()] = HashOf(u.Username())
|
||||
}
|
||||
}
|
||||
filled, missing := WithPasswords(users, hashes)
|
||||
list, err := ComposeAccounts(filled)
|
||||
return list, missing, err
|
||||
}
|
||||
|
||||
// MembershipsFor is every membership a fixture's records issue, by `<node>.<module>`, as the composer makes them.
|
||||
func MembershipsFor(f UserListFixture) map[string]Membership {
|
||||
where := PlacementsOf(f.Records, f.Records.Interchangeable)
|
||||
out := map[string]Membership{}
|
||||
for node, ds := range f.Records.Assigned {
|
||||
for _, d := range ds {
|
||||
out[node+"."+d.Module] = MembershipFor(node, d, where)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// A module's log on the bus (novox/hq ADR 0297): its record of operations, each entry appended under
|
||||
// a key and kept as long as the log is.
|
||||
//
|
||||
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
|
||||
// its stream and subjects, the controller creates it from the catalogue on every raise and from
|
||||
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
|
||||
// is gone is reported, as a bucket is. Unlike a bucket, a log is its owner's alone: no other module
|
||||
// reads it, so there is no read of a log to grant or issue.
|
||||
//
|
||||
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
|
||||
// is exactly what is sent; jetstream.go is the part that asks a server.
|
||||
|
||||
// The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may
|
||||
// weigh — the value and its headers, which the server counts in a message's size, so a value of the
|
||||
// full size still fits with the expected-last-sequence header an append carries — and what a log holds
|
||||
// when its module says nothing and at most.
|
||||
const (
|
||||
LogMaxEntryBytes = 256 * 1024
|
||||
LogMaxMessageBytes = LogMaxEntryBytes + 4*1024
|
||||
LogDefaultMiB = 1024
|
||||
LogMostMiB = 8192
|
||||
)
|
||||
|
||||
// A Log is one module's declared log as the bus holds it.
|
||||
type Log struct {
|
||||
Module string
|
||||
Name string
|
||||
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
|
||||
MaxMiB int
|
||||
}
|
||||
|
||||
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
|
||||
const LogStreamPrefix = "LOG_"
|
||||
|
||||
// LogStreamName is the stream a module's log lives in: `LOG_<module>_<name>`. The module and the
|
||||
// local name are each one token with no underscore, so two modules can never derive one stream.
|
||||
func LogStreamName(module, name string) string { return LogStreamPrefix + module + "_" + name }
|
||||
|
||||
// LogSubject is the subject a log's entries are published under, without the key: an entry for key K
|
||||
// is on `<LogSubject>.<K>`.
|
||||
func LogSubject(module, name string) string { return "mesh.log." + module + "." + name }
|
||||
|
||||
// Stream is this log's stream name.
|
||||
func (l Log) Stream() string { return LogStreamName(l.Module, l.Name) }
|
||||
|
||||
// Subject is this log's subject, without the key.
|
||||
func (l Log) Subject() string { return LogSubject(l.Module, l.Name) }
|
||||
|
||||
// MaxBytes is this log's cap in bytes.
|
||||
func (l Log) MaxBytes() int64 {
|
||||
mib := l.MaxMiB
|
||||
if mib <= 0 {
|
||||
mib = LogDefaultMiB
|
||||
}
|
||||
return int64(mib) * 1024 * 1024
|
||||
}
|
||||
|
||||
// Why is carried into the server's description of the stream, so somebody reading the server's own
|
||||
// state finds whose it is and why it is kept.
|
||||
func (l Log) Why() string {
|
||||
return fmt.Sprintf("%s's log %q (novox/hq ADR 0297): its record of operations, one entry per operation "+
|
||||
"under its key, appended by %s alone and kept as long as the log; never removed by the mesh, because "+
|
||||
"it is data", l.Module, l.Name, l.Module)
|
||||
}
|
||||
|
||||
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
|
||||
// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of
|
||||
// headers at most, that refuses a new entry when full rather than
|
||||
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
|
||||
// is how the runtime reads it.
|
||||
func (l Log) Config() jetstream.StreamConfig {
|
||||
return jetstream.StreamConfig{
|
||||
Name: l.Stream(),
|
||||
Description: l.Why(),
|
||||
Subjects: []string{l.Subject() + ".>"},
|
||||
Storage: jetstream.FileStorage,
|
||||
Retention: jetstream.LimitsPolicy,
|
||||
MaxAge: 0,
|
||||
MaxMsgs: -1,
|
||||
MaxMsgsPerSubject: -1,
|
||||
MaxBytes: l.MaxBytes(),
|
||||
MaxMsgSize: LogMaxMessageBytes,
|
||||
Discard: jetstream.DiscardNew,
|
||||
AllowDirect: true,
|
||||
DenyDelete: true,
|
||||
DenyPurge: true,
|
||||
Replicas: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// logsIssuedFor is every log a module's code may reach, as its membership lists them: its own.
|
||||
func logsIssuedFor(d Declared) []LogIssued {
|
||||
var out []LogIssued
|
||||
for _, l := range d.Logs {
|
||||
if !safeSubject.MatchString(d.Module) || !safeSubject.MatchString(l.Name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, LogIssued{Name: l.Name, Stream: LogStreamName(d.Module, l.Name),
|
||||
Subject: LogSubject(d.Module, l.Name), Writes: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logGrants is what a principal publishes to reach the logs its module keeps: for each, appending
|
||||
// under the log's subjects, binding to its stream, and reading it directly. Nothing more — the
|
||||
// runtime reads a log by direct gets alone and makes no consumer on it — and nothing of any other
|
||||
// module's log, because a log is its owner's alone. Replies come on the principal's inbox, as for a
|
||||
// bucket.
|
||||
func logGrants(module string, names []string) []string {
|
||||
if !safeSubject.MatchString(module) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, name := range names {
|
||||
if !safeSubject.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
stream := LogStreamName(module, name)
|
||||
out = append(out,
|
||||
LogSubject(module, name)+".>",
|
||||
"$JS.API.STREAM.INFO."+stream,
|
||||
"$JS.API.DIRECT.GET."+stream,
|
||||
"$JS.API.DIRECT.GET."+stream+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logNames is the local names of a module's logs.
|
||||
func logNames(logs []Log) []string {
|
||||
out := make([]string, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
out = append(out, l.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A LogAsserter is the part of a JetStream connection log assertion needs. It has no way to remove a
|
||||
// log, by design: nothing the mesh runs asks for one.
|
||||
type LogAsserter interface {
|
||||
// EnsureLog creates the log's stream if absent and brings its configuration to match if present,
|
||||
// never deleting or recreating it.
|
||||
EnsureLog(l Log) error
|
||||
// LogStreams is every log stream on the server: every stream named with LogStreamPrefix.
|
||||
LogStreams() ([]string, error)
|
||||
}
|
||||
|
||||
// RaiseLogs asserts every declared log and answers the log streams on the server that nothing
|
||||
// declares any more.
|
||||
//
|
||||
// **Those are reported, never removed** (novox/hq ADR 0297 §2, ADR 0201 §15–16): a log is a module's
|
||||
// record, and a manifest edited, a module renamed or unassigned is an ordinary day's work that must
|
||||
// not take a record with it. Removing one is a person's act, outside the mesh.
|
||||
func RaiseLogs(a LogAsserter, logs []Log) (undeclared []string, err error) {
|
||||
sorted := append([]Log(nil), logs...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Stream() < sorted[j].Stream() })
|
||||
declared := map[string]bool{}
|
||||
for _, l := range sorted {
|
||||
if err := a.EnsureLog(l); err != nil {
|
||||
return nil, fmt.Errorf("asserting %s's log %q: %w", l.Module, l.Name, err)
|
||||
}
|
||||
declared[l.Stream()] = true
|
||||
}
|
||||
names, err := a.LogStreams()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing the bus's logs: %w", err)
|
||||
}
|
||||
for _, n := range names {
|
||||
if strings.HasPrefix(n, LogStreamPrefix) && !declared[n] {
|
||||
undeclared = append(undeclared, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(undeclared)
|
||||
return undeclared, nil
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160).
|
||||
//
|
||||
// A module's code names its tools and its events; **where they land is the mesh's to decide**, and
|
||||
// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the
|
||||
// controller composes a membership for every module on every machine and publishes it to a subject
|
||||
// only that assignment reads; the runtime serves exactly what the membership says, and the account's
|
||||
// grant is the same composition read the other way. The shape issued today is the shape the mesh
|
||||
// already had, so nothing moves when a membership first arrives; only who decides it moves.
|
||||
|
||||
// Placements is where every module runs, for deciding which instance answers for the module.
|
||||
type Placements struct {
|
||||
// Nodes is each module's machines.
|
||||
Nodes map[string][]string
|
||||
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
||||
// so the module's plain subject is issued to all of them in one queue.
|
||||
Interchangeable map[string]bool
|
||||
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
|
||||
Kinds []KindHeld
|
||||
}
|
||||
|
||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
||||
// plain subject: when it is the only instance, or when the definition says instances are
|
||||
// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call
|
||||
// that names none reaches nothing rather than the wrong store.
|
||||
func (p Placements) AnswersForTheModule(module string) bool {
|
||||
return len(p.Nodes[module]) <= 1 || p.Interchangeable[module]
|
||||
}
|
||||
|
||||
// MembershipFor composes one assignment's membership from what it declared and where everything
|
||||
// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the
|
||||
// grant itself is read from the membership — which is the next step, not this one.
|
||||
func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
own := "mesh.mod." + d.Module
|
||||
m := Membership{
|
||||
Node: node, Module: d.Module,
|
||||
Emits: own + ".event.{event}",
|
||||
Tools: own + ".tool.tools",
|
||||
}
|
||||
// This machine's address always; the module's when this instance answers for the module.
|
||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node})
|
||||
if where.AnswersForTheModule(d.Module) {
|
||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
||||
}
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue // answered by the serving controller alone, never through a membership
|
||||
}
|
||||
for _, verb := range s.Serves {
|
||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||
}
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
m.Waits = waitsIssuedFor(d)
|
||||
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||
if !s.Kinded {
|
||||
continue
|
||||
}
|
||||
for _, k := range where.Kinds {
|
||||
if k.Seat == s.Name && !kindListed(t.Kinds, k) {
|
||||
t.Kinds = append(t.Kinds, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 {
|
||||
m.SeatTraffic = &t
|
||||
}
|
||||
if len(d.Invokes) > 0 {
|
||||
m.Reaches = map[string][]string{}
|
||||
for _, t := range d.Invokes {
|
||||
if t == "*" || strings.HasPrefix(t, "seat:") {
|
||||
continue // every tool, or a role's: addressed by name, not resolved per instance
|
||||
}
|
||||
module, tool, ok := strings.Cut(t, ".")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var reach []string
|
||||
if where.AnswersForTheModule(module) {
|
||||
reach = append(reach, "mesh.mod."+module+".tool."+tool)
|
||||
}
|
||||
nodes := append([]string{}, where.Nodes[module]...)
|
||||
sort.Strings(nodes)
|
||||
for _, n := range nodes {
|
||||
reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n)
|
||||
}
|
||||
m.Reaches[t] = reach
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
|
||||
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
||||
}
|
||||
}
|
||||
for _, nodes := range p.Nodes {
|
||||
sort.Strings(nodes)
|
||||
}
|
||||
// Where the router runs: a verified sender is believed only while its machine is root-free too. A router
|
||||
// placed nowhere, or on more than one machine, frees nothing.
|
||||
var routerNodes []string
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
|
||||
routerNodes = append(routerNodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]]
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, s := range d.Holds {
|
||||
if s.Kinded && s.Kind != "" {
|
||||
p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node,
|
||||
Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(p.Kinds, func(i, j int) bool {
|
||||
a, b := p.Kinds[i], p.Kinds[j]
|
||||
if a.Seat != b.Seat {
|
||||
return a.Seat < b.Seat
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.Node < b.Node
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3).
|
||||
const routerSeat = "operator-channel"
|
||||
|
||||
// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it
|
||||
// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus
|
||||
// account of its own — never one the machine's runtime carries as the operator's account — and only while
|
||||
// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The
|
||||
// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks
|
||||
// the controller's root-free verb again before it honours an approval, and that is the check that holds.
|
||||
func placedCapabilities(declared []string, runsAs string, rootFree bool) []string {
|
||||
var out []string
|
||||
for _, c := range declared {
|
||||
if c == "verified-sender" && (runsAs == "" || !rootFree) {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func kindListed(list []KindHeld, k KindHeld) bool {
|
||||
for _, x := range list {
|
||||
if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own stream on the bus (novox/hq ADR 0363).
|
||||
//
|
||||
// A module declares a stream as it declares a log: named locally, created by the controller from the
|
||||
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
|
||||
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
|
||||
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
|
||||
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
|
||||
// EVENTS. Pure; the controller's raise asserts what is composed here.
|
||||
|
||||
// moduleStreamPrefix starts every subject a module's own stream takes.
|
||||
const moduleStreamPrefix = "mesh.stream."
|
||||
|
||||
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
|
||||
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
|
||||
func ModuleStreamSubject(module, event string) string {
|
||||
return moduleStreamPrefix + module + ".event." + event
|
||||
}
|
||||
|
||||
// OnAModuleStream says a subject is one a module's own stream takes.
|
||||
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
|
||||
|
||||
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
|
||||
func WirePattern(local string) string {
|
||||
parts := strings.Split(local, ".")
|
||||
for i, p := range parts {
|
||||
if p == "**" {
|
||||
parts[i] = ">"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// A ModuleStream is one module's declared stream as the bus holds it.
|
||||
type ModuleStream struct {
|
||||
Module string
|
||||
// Name is its name on the bus, `MAIL`.
|
||||
Name string
|
||||
// Events are the events it carries, as the bus spells them (WirePattern).
|
||||
Events []string
|
||||
MaxAgeDays int
|
||||
MaxMiB int
|
||||
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
|
||||
Restricted bool
|
||||
// WorkQueue says this stream holds ADR 0436 §6's work-queue retention: a message is removed once a
|
||||
// consumer acknowledges it, and the bus refuses a new one when full rather than dropping its oldest.
|
||||
WorkQueue bool
|
||||
// Duplicates is the work-queue's duplicate window in hours (novox/hq ADR 0436 §6); meaningless unless
|
||||
// WorkQueue.
|
||||
Duplicates int
|
||||
}
|
||||
|
||||
// Subjects are the subjects the stream takes.
|
||||
func (s ModuleStream) Subjects() []string {
|
||||
var out []string
|
||||
for _, e := range s.Events {
|
||||
out = append(out, ModuleStreamSubject(s.Module, e))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
|
||||
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
|
||||
// answers for anything older.
|
||||
func (s ModuleStream) Stream() Stream {
|
||||
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
|
||||
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
|
||||
if s.Restricted {
|
||||
why += "; heard only by the consumers its trusted config value grants"
|
||||
}
|
||||
if s.WorkQueue {
|
||||
why += "; a work-queue (novox/hq ADR 0436 §6): a message is removed once its one consumer " +
|
||||
"acknowledges it, and a full stream refuses a new one rather than dropping what it holds"
|
||||
}
|
||||
out := Stream{
|
||||
Name: s.Name,
|
||||
Subjects: s.Subjects(),
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
|
||||
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
|
||||
Why: why,
|
||||
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
||||
Restricted: s.Restricted,
|
||||
}
|
||||
if s.WorkQueue {
|
||||
out.Retention = RetentionWorkQueue
|
||||
out.DiscardNew = true
|
||||
out.DuplicatesSeconds = s.Duplicates * 60 * 60
|
||||
// Said on the bus too, reusing the one flag a restricted stream's dead letters already age and hide
|
||||
// by (novox/hq ADR 0436 §6, ADR 0376 §3-4): a work-queue stream's grant is always empty (ADR 0436
|
||||
// §6 refuses one that carries a grant), so this never collides with a trusted-config restriction.
|
||||
out.Restricted = true
|
||||
// And its own metadata, apart from the bus's work-queue retention SEAT_* and other mesh-internal
|
||||
// streams share without being one of this clause's: only this flag says a dead letter kept from the
|
||||
// stream is drop-only (novox/hq ADR 0436 §6), never the stream's Retention alone.
|
||||
out.WorkQueue = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue
|
||||
// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration
|
||||
// to be the only one (catalogue.WorkQueueStreamProblems).
|
||||
type QueueOwner struct {
|
||||
// Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR
|
||||
// 0436 §2).
|
||||
Process string
|
||||
// Stream is the work-queue stream's name on the bus.
|
||||
Stream string
|
||||
// MaxDeliver bounds how many times a message is handed over before it is given up as a dead letter
|
||||
// (novox/hq ADR 0436 §6).
|
||||
MaxDeliver int
|
||||
}
|
||||
|
||||
// QueueConsumer is the durable consumer the one process a module's bus.queues names is granted on its own
|
||||
// work-queue stream (novox/hq ADR 0436 §1, §2, §6): no filter, since it is the stream's only consumer —
|
||||
// unlike heardConsumer, which is one of possibly several hearing a module's ordinary stream.
|
||||
func QueueConsumer(p Principal, q QueueOwner) Consumer {
|
||||
return Consumer{
|
||||
Name: p.Node + "_" + p.Module + "_" + q.Process,
|
||||
Stream: q.Stream,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: q.MaxDeliver,
|
||||
Why: fmt.Sprintf("%s's process %s is the one consumer %s's bus.queues names for its work-queue "+
|
||||
"stream %s (novox/hq ADR 0436 §6); after max-deliver %d it gives a message up, and the "+
|
||||
"controller keeps it in %s, offered only drop", p.Module, q.Process, p.Module, q.Stream,
|
||||
q.MaxDeliver, DeadLettersStream),
|
||||
}
|
||||
}
|
||||
|
||||
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
|
||||
func ModuleStreams(declared []ModuleStream) []Stream {
|
||||
sorted := append([]ModuleStream(nil), declared...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
out := make([]Stream, 0, len(sorted))
|
||||
for _, s := range sorted {
|
||||
out = append(out, s.Stream())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
|
||||
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
|
||||
// (ADR 0363 §5). Composed only with at least one subject.
|
||||
type Heard struct {
|
||||
Stream string
|
||||
Emitter string
|
||||
Filters []string
|
||||
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
|
||||
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it
|
||||
// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own
|
||||
// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus
|
||||
// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a
|
||||
// consumer, its grants or its bind word composed there would hand the stream to any session. A module that
|
||||
// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account
|
||||
// the mesh keeps from agents.
|
||||
func ComposedHears(d Declared) []Heard {
|
||||
if d.RunsAs != "" {
|
||||
return d.Hears
|
||||
}
|
||||
var out []Heard
|
||||
for _, h := range d.Hears {
|
||||
if !h.Restricted {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the
|
||||
// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never
|
||||
// silent.
|
||||
type Withheld struct {
|
||||
Node, Module, Stream, Emitter string
|
||||
}
|
||||
|
||||
// Said is a withheld hearing in the operator's words: the module, the stream and the remedy.
|
||||
func (w Withheld) Said() string {
|
||||
return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+
|
||||
"by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+
|
||||
"for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)",
|
||||
w.Module, w.Node, w.Emitter, w.Stream, w.Module)
|
||||
}
|
||||
|
||||
// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module.
|
||||
func WithheldOf(r Records) []Withheld {
|
||||
var out []Withheld
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs != "" {
|
||||
continue
|
||||
}
|
||||
for _, h := range d.Hears {
|
||||
if h.Restricted && len(h.Filters) > 0 {
|
||||
out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
|
||||
// on EVENTS, on that stream, with the subjects it was granted as its filters.
|
||||
func heardConsumer(p Principal, h Heard) Consumer {
|
||||
filters := append([]string(nil), h.Filters...)
|
||||
sort.Strings(filters)
|
||||
return Consumer{
|
||||
Name: consumerDurable(p),
|
||||
Stream: h.Stream,
|
||||
Filters: filters,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
// From now, like a module's consumer on EVENTS (novox/hq ADR 0443 §1).
|
||||
FromNow: true,
|
||||
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
|
||||
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
|
||||
"DEAD_LETTERS until a person delivers it again or drops it",
|
||||
}
|
||||
}
|
||||
|
||||
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
|
||||
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
|
||||
func heardGrants(p Principal, hears []Heard) []string {
|
||||
var out []string
|
||||
durable := consumerDurable(p)
|
||||
for _, h := range hears {
|
||||
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out,
|
||||
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
|
||||
"$JS.ACK."+h.Stream+"."+durable+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
|
||||
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
||||
var out []string
|
||||
for _, s := range streams {
|
||||
if s.Module != module {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.Subjects()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// streamsIssuedFor is every stream of its own a module's membership names.
|
||||
func streamsIssuedFor(d Declared) []StreamIssued {
|
||||
var out []StreamIssued
|
||||
for _, s := range d.Streams {
|
||||
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
|
||||
continue
|
||||
}
|
||||
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
|
||||
Subject: ModuleStreamSubject(d.Module, "{event}")})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
|
||||
func HeardIssuedFor(node string, d Declared) []HeardIssued {
|
||||
var out []HeardIssued
|
||||
// A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1).
|
||||
for _, h := range ComposedHears(d) {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
|
||||
return out
|
||||
}
|
||||
|
||||
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
|
||||
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
|
||||
type Pruner interface {
|
||||
ConsumerNames(stream string) ([]string, error)
|
||||
RemoveConsumer(stream, name string) error
|
||||
}
|
||||
|
||||
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
|
||||
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
|
||||
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
|
||||
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
|
||||
keep := map[string]bool{}
|
||||
for _, c := range wanted {
|
||||
keep[c.Stream+"/"+c.Name] = true
|
||||
}
|
||||
var removed []string
|
||||
for _, s := range streams {
|
||||
names, err := p.ConsumerNames(s.Name)
|
||||
if err != nil {
|
||||
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
|
||||
}
|
||||
for _, n := range names {
|
||||
if keep[s.Name+"/"+n] {
|
||||
continue
|
||||
}
|
||||
if err := p.RemoveConsumer(s.Name, n); err != nil {
|
||||
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
|
||||
}
|
||||
removed = append(removed, s.Name+"/"+n)
|
||||
}
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer
|
||||
// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with
|
||||
// its wider filters after a narrowing was refused would keep handing over what the grant no longer names,
|
||||
// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is
|
||||
// returned either way, saying what was done, so the send says it and raises it.
|
||||
func EnsureHeard(r Raiser, c Consumer, restricted bool) error {
|
||||
err := r.EnsureConsumer(c)
|
||||
if err == nil || !restricted {
|
||||
return err
|
||||
}
|
||||
p, prunes := r.(Pruner)
|
||||
if !prunes {
|
||||
return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err)
|
||||
}
|
||||
if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil {
|
||||
return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr)
|
||||
}
|
||||
return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+
|
||||
"again with its granted filters (novox/hq ADR 0363 §6)", err)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// NATSVar names the environment variable that says the controller is on the bus (mesh-controller internal/broker
|
||||
// onnats.go); kept with the composer for the readiness it composes.
|
||||
const NATSVar = "MESH_BUS_NATS"
|
||||
@@ -1,56 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
// The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5).
|
||||
//
|
||||
// **Never the module's own account's grants, never the tool runner's.** A process's authority is read
|
||||
// straight from its bus declaration — its emits, its queue's consumer, its state, whether it reads the
|
||||
// records of its module's own asks, and the tools and seat verbs it calls — and nothing else: it holds no
|
||||
// seat, serves no tool, hears no module stream, and answers nothing a caller asked (§3). Modelled on
|
||||
// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11).
|
||||
|
||||
// processGrants is one process's whole authority: to publish, and to subscribe.
|
||||
func processGrants(p Principal) (pub, sub []string, err error) {
|
||||
// 1. Its own events, under its module's own namespace — the same subjects the module's own principal
|
||||
// would publish them on, had it declared them there instead (novox/hq ADR 0436 §1).
|
||||
own := "mesh.mod." + p.Module
|
||||
for _, e := range p.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
|
||||
// 2. The one durable consumer of each work-queue stream its bus names (novox/hq ADR 0436 §1, §2, §6):
|
||||
// asked about, pulled from and acknowledged, by the same name ConsumersOf composes the consumer under
|
||||
// (QueueConsumer) — and no other stream's.
|
||||
for _, q := range p.Queues {
|
||||
consumer := p.Node + "_" + p.Module + "_" + q.Process
|
||||
pub = append(pub,
|
||||
"$JS.API.CONSUMER.INFO."+q.Stream+"."+consumer,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+q.Stream+"."+consumer,
|
||||
"$JS.ACK."+q.Stream+"."+consumer+".>")
|
||||
}
|
||||
|
||||
// 3. Its module's own state, by the local names its bus names alone — never the whole of what the
|
||||
// module keeps (novox/hq ADR 0436 §1), read and written as stateGrants gives any module's own bucket.
|
||||
kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State}
|
||||
pub = append(pub, stateGrants(kept)...)
|
||||
// And it hears each of them created, so one not on the bus at its start is bound when it appears (novox/hq issue
|
||||
// 1790; process.AwaitBucket).
|
||||
sub = append(sub, stateCreatedHeard(kept)...)
|
||||
|
||||
// 4. The router's records of its module's own asks (novox/hq ADR 0436 §1, ADR 0259 §3): the same
|
||||
// direct-get subjects a module's own principal reads for the seats its Uses names that keep them —
|
||||
// read here from the one list, so the two cannot disagree.
|
||||
if p.Asks {
|
||||
pub = append(pub, SeatTrafficOf(p.Module, nil, p.Uses, nil).Records...)
|
||||
}
|
||||
|
||||
// 5. The tools and seat verbs it calls (novox/hq ADR 0152, ADR 0436 §1): the same derivation a
|
||||
// module's own principal's invokes takes, so `callerNamed` (applied to every non-controller principal)
|
||||
// adds the caller-named `call` variants for it exactly as it would for a module.
|
||||
invoked, err := invokedSubjects(p.Invokes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
|
||||
return pub, sub, nil
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3).
|
||||
//
|
||||
// **Not the tool runner's account.** The client fills a machine's files and asks the vault for the secrets that
|
||||
// machine's declaration names; the vault hands a secret only to an account of its own, because a call under the
|
||||
// tool runner's account cannot be told from an agent's on that machine (ADR 0305 §3, §12). So each machine that
|
||||
// runs the client has a principal of its own, KindProvisioner, named for the machine, and granted what the client
|
||||
// does and nothing else: read its own machine's keys in each domain's bucket, write its own machine's keys of the
|
||||
// provisioner server's needs, applied and collections (design 57 P4), read the server's registry, say what it
|
||||
// applied on its own machine, and ask the vault in its own name.
|
||||
|
||||
// NodeProvisionerSeat is the seat the provisioner client holds on every machine (ADR 0349 clause 3).
|
||||
const NodeProvisionerSeat = "node-provisioner"
|
||||
|
||||
// ProvisionerSecret is the own secret of the module running the client that its bus account is delivered in, as
|
||||
// `broker` is a module's own (novox/hq issue 195): a module that claims NodeProvisionerSeat and declares no own
|
||||
// secret of this name has nowhere to read an account, and is given none.
|
||||
const ProvisionerSecret = "provisioner-broker"
|
||||
|
||||
// ConfigResolvedBucket is the config domain's resolved values, keyed `<node>.<module>.<key>` (ADR 0348, research
|
||||
// 051): mesh-config's state `resolved`, written by mesh-config alone.
|
||||
var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
|
||||
|
||||
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
|
||||
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
|
||||
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
|
||||
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
|
||||
var ProvisionerReads = []string{ConfigResolvedBucket}
|
||||
|
||||
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
|
||||
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
|
||||
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
|
||||
// and plans, never a secret's value; the client writes none of it.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket}
|
||||
|
||||
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
|
||||
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
|
||||
// ${bound:<provision>:<key>} (issue 679).
|
||||
const BoundState = "bound"
|
||||
|
||||
// ProvisionerServerModule is the module holding the seat mesh-provisioner once for the mesh (ADR 0349 clause 3,
|
||||
// design 57 P4), whose buckets the clients write their own machine's keys of.
|
||||
const ProvisionerServerModule = "mesh-provisioner"
|
||||
|
||||
// The provisioner server's buckets (ADR 0349 clause 3): its state, created by the controller from its manifest
|
||||
// like any module's, and written by its own account whole.
|
||||
var (
|
||||
// ProvisionerRegistryBucket is which domain answers which kind of placeholder, keyed by the kind; the
|
||||
// server's alone, read whole by every client.
|
||||
ProvisionerRegistryBucket = BucketName(ProvisionerServerModule, "registry")
|
||||
// ProvisionerNeedsBucket is what each module on each machine needs, `<node>.<module>.<kind>.<name>`.
|
||||
ProvisionerNeedsBucket = BucketName(ProvisionerServerModule, "needs")
|
||||
// ProvisionerAppliedBucket is what is applied where, `<node>.<module>`.
|
||||
ProvisionerAppliedBucket = BucketName(ProvisionerServerModule, "applied")
|
||||
// ProvisionerCollectBucket is the items of every collection, `<node>.<collection>.<contributor>`.
|
||||
ProvisionerCollectBucket = BucketName(ProvisionerServerModule, "collect")
|
||||
)
|
||||
|
||||
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
|
||||
// client cannot write another machine's needs, or say another machine applied a file.
|
||||
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
|
||||
|
||||
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
|
||||
// clause 11): `get`, a secret its machine's declaration names, answered sealed to the asker's key. On the subject
|
||||
// that names its caller, so the vault knows which machine's client asks from the bus's word.
|
||||
var VerbsTheProvisionerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "get"}}
|
||||
|
||||
// VaultSecretChangedSubject is where the vault says a secret it holds changed (`secret-changed`: which, by whom,
|
||||
// when, its generation; never a value, ADR 0349 clause 11), as link.EventSubject addresses mesh-vault's event. A
|
||||
// provisioner client listens to it so a rotated secret reaches its file without a new declaration (design 57 V4):
|
||||
// it asks the vault again, in its own name, for a secret its machine's declaration names.
|
||||
const VaultSecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed"
|
||||
|
||||
// ProvisionerAppliedSubject is where a machine's client says what it applied for one module, applied or failed
|
||||
// with the reason (ADR 0349 clauses 4 and 15): `prov.applied.<node>.<module>`.
|
||||
func ProvisionerAppliedSubject(node, module string) string {
|
||||
return "prov.applied." + node + "." + module
|
||||
}
|
||||
|
||||
// ProvisionerAskSubject is where a machine's tool runner asks that machine's provisioner client for a config value
|
||||
// or a non-secret bound value of a module it carries, naming the module from the bundle that asks (novox/hq ADR
|
||||
// 0370 §1, §3, §4; issue 685): `prov.ask.<node>`, which the client subscribes and the tool runner's account of the
|
||||
// same machine alone may publish. The bus proves only that the tool runner asks; the module is the runtime's word,
|
||||
// so the client answers no secret here (ADR 0370 §2).
|
||||
func ProvisionerAskSubject(node string) string { return "prov.ask." + node }
|
||||
|
||||
// ProvisionerChangedSubject is where a machine's client says that a value of one module it answers moved, so the
|
||||
// tool runner asks again for the module's code's watches (ADR 0349 §14, ADR 0370 §1): `prov.changed.<node>.<module>`,
|
||||
// carrying which value and never the value itself, heard by that machine's tool runner alone.
|
||||
func ProvisionerChangedSubject(node, module string) string {
|
||||
return "prov.changed." + node + "." + module
|
||||
}
|
||||
|
||||
// provisionerGrants is a provisioner client's whole authority: to publish, and to subscribe.
|
||||
func provisionerGrants(p Principal) (pub, sub []string, err error) {
|
||||
if p.Node == "" {
|
||||
// Refused rather than composed into `$KV.<bucket>..>`, a pattern no machine's keys are under, and a
|
||||
// name every nameless client would share.
|
||||
return nil, nil, errors.New("a provisioner client names no machine, so it would have no keys of its own " +
|
||||
"to read and an account it shared with every other")
|
||||
}
|
||||
for _, bucket := range ProvisionerReads {
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
}
|
||||
for _, bucket := range ProvisionerReadsWhole {
|
||||
pub = append(pub, wholeBucketRead(bucket)...)
|
||||
}
|
||||
for _, bucket := range p.BoundBuckets {
|
||||
module, name, ok := strings.Cut(bucket, "_")
|
||||
if !ok || name != BoundState || !safeSubject.MatchString(module) || strings.Contains(module, "_") {
|
||||
return nil, nil, fmt.Errorf("%q is not a provider's bucket of entries, <module>_%s", bucket, BoundState)
|
||||
}
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
}
|
||||
// The server's buckets (design 57 P4): its own machine's keys of the needs, the applied and the
|
||||
// collections, read and written, `$KV.<bucket>.<node>.>` and nothing wider; and the registry, read whole,
|
||||
// which says which domain answers which kind and is the same for every machine.
|
||||
for _, bucket := range ProvisionerWrites {
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
pub = append(pub, "$KV."+bucket+"."+p.Node+".>")
|
||||
}
|
||||
pub = append(pub, wholeBucketRead(ProvisionerRegistryBucket)...)
|
||||
// Its own node's own have, `<node>.node`, read and never written, and no other key of the bucket: it fills every
|
||||
// module again when the node-engine moves to a new composition of the node's own (issue 1945), since an apply can
|
||||
// leave a filled file as the composition had it.
|
||||
pub = append(pub, readGrants(delivery.HaveBucket, p.Node+".node")...)
|
||||
pub = append(pub, "prov.applied."+p.Node+".>")
|
||||
token := CallerToken(p.Username())
|
||||
if token == "" {
|
||||
return nil, nil, fmt.Errorf("%q cannot name a caller", p.Username())
|
||||
}
|
||||
for _, v := range VerbsTheProvisionerAsksTheVault {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+token)
|
||||
}
|
||||
// What moved of a value it answers its own machine's tool runner (ADR 0370 §1), under its own machine's name.
|
||||
pub = append(pub, "prov.changed."+p.Node+".>")
|
||||
// Its own reply space, for the vault's answers and its reads' and watches' deliveries; what the vault says
|
||||
// changed; and its own machine's tool runner asking it for a module's config and non-secret bound values (ADR
|
||||
// 0370 §3), which it answers on the ask's reply and nowhere else.
|
||||
return pub, []string{p.inbox(), VaultSecretChangedSubject, ProvisionerAskSubject(p.Node)}, nil
|
||||
}
|
||||
|
||||
// machineKeysRead is reading every key of a bucket under one machine's name, `<node>.>`, and no other key: a
|
||||
// direct get of a key's own subject, and a consumer whose filter is under it — the server's create-with-filter
|
||||
// form, which it holds to the filter the subject names (as stateGrants does for one key, novox/hq ADR 0260).
|
||||
// Binding, deleting a consumer and flow control cannot be narrowed by key and read no value.
|
||||
func machineKeysRead(bucket, node string) []string {
|
||||
stream := "KV_" + bucket
|
||||
keys := "$KV." + bucket + "." + node + ".>"
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + stream,
|
||||
"$JS.API.DIRECT.GET." + stream + "." + keys,
|
||||
"$JS.API.CONSUMER.CREATE." + stream + ".*." + keys,
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".>",
|
||||
"$JS.FC." + stream + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// wholeBucketRead is reading every key of a bucket and writing none: what stateGrants grants a module for a
|
||||
// bucket it `reads`.
|
||||
func wholeBucketRead(bucket string) []string {
|
||||
stream := "KV_" + bucket
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + stream,
|
||||
"$JS.API.DIRECT.GET." + stream + ".>",
|
||||
"$JS.API.CONSUMER.CREATE." + stream + ".>",
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".>",
|
||||
"$JS.FC." + stream + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// LastProvApplied is what a machine's provisioner client last said of one module's files, as it published it on
|
||||
// ProvisionerAppliedSubject and ProvAppliedStream keeps it (design 57 P6): false when it has said nothing of it in
|
||||
// the stream's week.
|
||||
func LastProvApplied(js nats.JetStreamContext, node, module string) ([]byte, bool, error) {
|
||||
msg, err := js.GetLastMsg(ProvAppliedStream, ProvisionerAppliedSubject(node, module))
|
||||
if errors.Is(err, nats.ErrMsgNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("what %s's provisioner client said of %s cannot be read: %w", node, module, err)
|
||||
}
|
||||
return msg.Data, true, nil
|
||||
}
|
||||
|
||||
// ProvAppliedReports is the newest word of every machine's provisioner client on every module it said one of, kept by
|
||||
// ProvAppliedStream, by subject (`prov.applied.<node>.<module>`).
|
||||
func ProvAppliedReports(js nats.JetStreamContext) (map[string][]byte, error) {
|
||||
info, err := js.StreamInfo(ProvAppliedStream, &nats.StreamInfoRequest{SubjectsFilter: ProvAppliedSubjects})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the provisioner clients' reports cannot be listed: %w", err)
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for subject := range info.State.Subjects {
|
||||
msg, err := js.GetLastMsg(ProvAppliedStream, subject)
|
||||
if errors.Is(err, nats.ErrMsgNotFound) {
|
||||
continue // aged out between the two reads
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the provisioner client's report on %s cannot be read: %w", subject, err)
|
||||
}
|
||||
out[subject] = msg.Data
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Bringing the bus's own objects into being, in the one order that works.
|
||||
//
|
||||
// **Asserted on every start rather than created once at genesis.** A stream somebody deleted, a mesh
|
||||
// raised from a restored backup, or a bus whose data directory was replaced all have records and no
|
||||
// objects — and a node whose consumer is missing hears nothing while everything else about it looks
|
||||
// correct. Idempotence is the whole requirement, and the parts are already idempotent; this is the
|
||||
// order they have to be asked in.
|
||||
|
||||
// Raiser is everything asserting the bus's objects needs of a connection to it.
|
||||
type Raiser interface {
|
||||
Asserter
|
||||
Ensurer
|
||||
}
|
||||
|
||||
// Raise asserts the mesh's streams, the controller's own consumers, and one consumer per node.
|
||||
//
|
||||
// **The order is not a preference.** A consumer on a stream that does not exist is refused, and the
|
||||
// refusal names the stream rather than the order — so somebody reading it goes looking for a deleted
|
||||
// stream instead of a reversed pair of lines. Nodes last, because the one a node reads lives on a
|
||||
// stream the mesh's own set defines.
|
||||
func Raise(r Raiser, nodes []string) error {
|
||||
if err := AssertMeshStreams(r); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := AssertMeshConsumers(r); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := AssertNodeConsumers(r, nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RaiseSeats asserts one work queue per declared seat, and the worker of whoever holds it.
|
||||
//
|
||||
// Separate from Raise because it is answered by a different question: the mesh's own objects exist
|
||||
// because the mesh does, and a seat's exist because a module declaring one was registered. Kept
|
||||
// beside it so the order is visible — a holder's worker needs the seat's stream, and a seat's stream
|
||||
// needs nothing.
|
||||
func RaiseSeats(r Raiser, seats []DeclaredSeat, holders map[string]Holder) error {
|
||||
for _, s := range SeatStreams(seats) {
|
||||
if err := r.EnsureStream(s); err != nil {
|
||||
return fmt.Errorf("asserting the work queue for %s: %w", s.Name, err)
|
||||
}
|
||||
}
|
||||
for _, s := range seats {
|
||||
h, held := holders[s.Name]
|
||||
if !held {
|
||||
// **The stream exists and the consumer does not, on purpose.** Work queues until a
|
||||
// holder appears, so installing the module a week after something started sending to it
|
||||
// flushes the backlog instead of having lost it.
|
||||
continue
|
||||
}
|
||||
c, needed := HolderConsumerFor(h.Node, h.Module, s)
|
||||
if !needed {
|
||||
continue
|
||||
}
|
||||
if err := r.EnsureConsumer(c); err != nil {
|
||||
return fmt.Errorf("asserting how %s on %s works %s: %w", h.Module, h.Node, s.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Holder is which module on which machine holds a seat.
|
||||
type Holder struct {
|
||||
Node string
|
||||
Module string
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Whether a mesh could move its bus, and what is missing if not.
|
||||
//
|
||||
// **Asked before anything moves, and answerable from records alone.** The rollout moves every node at
|
||||
// once (novox/hq ADR 0116 step 5), so there is no partial state to inspect afterwards and no half to
|
||||
// roll back: either the mesh was ready or it was not. That makes a readiness question the most
|
||||
// valuable thing here — it costs nothing, it can be asked of a running mesh any number of times, and
|
||||
// every answer is a thing somebody can go and fix.
|
||||
//
|
||||
// Deliberately pure. It is handed what the mesh knows and returns sentences; nothing here connects to
|
||||
// anything, so it can be asked on a workstation about a mesh it has never reached.
|
||||
|
||||
// Readiness is what the mesh knows about its own ability to move.
|
||||
type Readiness struct {
|
||||
// TheBus is the address the mesh's own traffic would move to, empty when nothing names one.
|
||||
TheBus string
|
||||
// ServerStanding is whether a bus is reachable at that address, as somebody checked.
|
||||
ServerStanding bool
|
||||
// Holder is the node running the module that holds the bus seat, empty when nothing does.
|
||||
Holder string
|
||||
// AccountsComposed is whether that node has been sent the composed user list.
|
||||
AccountsComposed bool
|
||||
// Nodes is every machine the mesh knows.
|
||||
Nodes []string
|
||||
// Credentialled is which of them has a credential for the new bus.
|
||||
Credentialled map[string]bool
|
||||
// Modules is every assigned module, as `<node>/<module>`.
|
||||
Modules []string
|
||||
// ModuleCredentialled is which of those has one.
|
||||
ModuleCredentialled map[string]bool
|
||||
}
|
||||
|
||||
// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them.
|
||||
//
|
||||
// Empty means ready. **Each entry names one thing and what to do about it**, because a readiness check
|
||||
// that says "not ready" is a check nobody can act on — and this is read at the point where the next
|
||||
// step is irreversible.
|
||||
func NotReady(r Readiness) []string {
|
||||
var why []string
|
||||
|
||||
if strings.TrimSpace(r.TheBus) == "" {
|
||||
why = append(why, "nothing names the bus to move to: set "+NATSVar+" on the control node "+
|
||||
"to the address the new server answers on")
|
||||
}
|
||||
if !r.ServerStanding {
|
||||
why = append(why, "no bus is answering at that address. Step 2 of the change raises it beside "+
|
||||
"the one the mesh is on, carrying nothing — assign the module that holds "+
|
||||
"mesh-broker and push the machine that runs it")
|
||||
}
|
||||
if r.Holder == "" {
|
||||
why = append(why, "no machine holds mesh-broker, so nothing would compose the bus's user "+
|
||||
"list. Assign the module that claims it")
|
||||
} else if !r.AccountsComposed {
|
||||
why = append(why, fmt.Sprintf(
|
||||
"%s holds mesh-broker and has not been sent the composed user list, so the bus would "+
|
||||
"refuse every connection. `push %s`", r.Holder, r.Holder))
|
||||
}
|
||||
|
||||
// A node with no credential cannot come back after the move, and a node that cannot come back is
|
||||
// a machine the mesh has lost until somebody goes to it.
|
||||
var missing []string
|
||||
for _, n := range r.Nodes {
|
||||
if !r.Credentialled[n] {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
why = append(why, fmt.Sprintf(
|
||||
"%d machine(s) have no credential for the new bus and would not come back: %s. Each needs "+
|
||||
"one minted before the move, not after — after, there is no bus to ask over",
|
||||
len(missing), strings.Join(missing, ", ")))
|
||||
}
|
||||
|
||||
// A module without one keeps running and stops being reachable, which is a smaller fault and still
|
||||
// one somebody should choose rather than discover.
|
||||
var quiet []string
|
||||
for _, m := range r.Modules {
|
||||
if !r.ModuleCredentialled[m] {
|
||||
quiet = append(quiet, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(quiet)
|
||||
if len(quiet) > 0 {
|
||||
why = append(why, fmt.Sprintf(
|
||||
"%d module(s) have no credential for the new bus: %s. Each keeps serving and stops "+
|
||||
"answering tools and hearing events until it is issued one",
|
||||
len(quiet), strings.Join(quiet, ", ")))
|
||||
}
|
||||
|
||||
return why
|
||||
}
|
||||
|
||||
// WhatMoves is what the rollout would do, in order, for somebody reading before they commit.
|
||||
//
|
||||
// **Written out rather than summarised.** This is the one step with nothing to inspect afterwards, so
|
||||
// the last useful moment to disagree with it is while reading this.
|
||||
func WhatMoves(r Readiness) []string {
|
||||
out := []string{
|
||||
fmt.Sprintf("compose the bus's user list and send it to %s", holderOr(r.Holder)),
|
||||
fmt.Sprintf("move this control plane to %s, and confirm it is heard", busOr(r.TheBus)),
|
||||
}
|
||||
nodes := append([]string(nil), r.Nodes...)
|
||||
sort.Strings(nodes)
|
||||
for _, n := range nodes {
|
||||
out = append(out, fmt.Sprintf("move %s, and confirm it reports", n))
|
||||
}
|
||||
if len(r.Modules) > 0 {
|
||||
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
|
||||
len(r.Modules)))
|
||||
}
|
||||
// **The old broker goes, and it goes last** (novox/hq ADR 0131). AMQP is not a provision, so once
|
||||
// every machine reports on the new bus nothing of the mesh is left speaking to it, and its module
|
||||
// is unassigned. Said as a step so nobody reads the move as leaving a second bus behind.
|
||||
out = append(out, "then unassign the old broker's module: AMQP is not a provision (ADR 0131), and "+
|
||||
"once every machine reports on the new bus nothing of the mesh speaks to it")
|
||||
return out
|
||||
}
|
||||
|
||||
func holderOr(node string) string {
|
||||
if node == "" {
|
||||
return "whichever machine holds mesh-broker"
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func busOr(address string) string {
|
||||
if address == "" {
|
||||
return "the new bus"
|
||||
}
|
||||
return address
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
|
||||
// The membership the mesh issues an assignment (novox/hq ADR 0160), its subject, its stream, the bare address and the
|
||||
// pin are mesh-sdk's, public so that a module's program outside this Go module, the route proxy, reads them too
|
||||
// (novox/hq ADR 0480 clause 4, issue 1633). This package keeps no second copy: what follows only names them where the
|
||||
// controller already did.
|
||||
type (
|
||||
Membership = membership.Membership
|
||||
Served = membership.Served
|
||||
SeatServed = membership.SeatServed
|
||||
StateIssued = membership.StateIssued
|
||||
LogIssued = membership.LogIssued
|
||||
Worker = membership.Worker
|
||||
SeatTraffic = membership.SeatTraffic
|
||||
KindHeld = membership.KindHeld
|
||||
StreamIssued = membership.StreamIssued
|
||||
HeardIssued = membership.HeardIssued
|
||||
WaitIssued = membership.WaitIssued
|
||||
)
|
||||
|
||||
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
||||
const AssignmentsStream = membership.AssignmentsStream
|
||||
|
||||
var (
|
||||
// MembershipSubject is where an assignment's own membership is published.
|
||||
MembershipSubject = membership.Subject
|
||||
// CredentialIn reads the user and password out of a bus address, and the address without them.
|
||||
CredentialIn = membership.CredentialIn
|
||||
// BareAddress is a bus address with any credential stripped.
|
||||
BareAddress = membership.BareAddress
|
||||
// PinnedToFingerprint accepts exactly the certificate with this SHA-256 and no other.
|
||||
PinnedToFingerprint = process.PinnedToFingerprint
|
||||
)
|
||||
@@ -1,308 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR
|
||||
// 0259 §3, to-be 46 §10).
|
||||
//
|
||||
// Three rules are added to the ones a seat always had, each a subject whose last token names who may
|
||||
// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the
|
||||
// machine (ADR 0219):
|
||||
//
|
||||
// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that
|
||||
// event, under its own module's name and no other: `accept.ask.<module>`, `event.decided.<module>`. The
|
||||
// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the
|
||||
// server enforces, and a warrant reaches only the asker it is for.
|
||||
// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its
|
||||
// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any
|
||||
// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and
|
||||
// holds up no other kind.
|
||||
// - **A proof** (`proofs`): core request and reply on `mesh.seat.<seat>.proof.<verb>.<kind>`. No stream's
|
||||
// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded
|
||||
// holder asks with its own kind; the modules that watch the seat answer.
|
||||
//
|
||||
// And two reads: **a holder's records**, a bucket the seat names, read by each user under its own name only
|
||||
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's; and on a seat pulled by
|
||||
// kind, **what its workers gave up on**, the copies DEAD_LETTERS keeps of the seat's stream (DeadLetterReads, issue 734).
|
||||
|
||||
// BuildAskMaxDeliver bounds the deliveries of a build seat's ask (issue 1907): mesh-sdk delivery.AskMaxDeliver, written
|
||||
// here as the broker mirrors the catalogue rather than importing it.
|
||||
const BuildAskMaxDeliver = 5
|
||||
|
||||
// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded.
|
||||
var KindedBenches = map[string]bool{"channel": true, "intake": true}
|
||||
|
||||
// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench.
|
||||
func WorkerName(seat, kind string) string {
|
||||
if kind == "" {
|
||||
return "SEAT_" + upperSnake(seat) + "_worker"
|
||||
}
|
||||
return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker"
|
||||
}
|
||||
|
||||
func namesVerb(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats
|
||||
// carrying one of the rules above are read: every other seat is composed as it always was.
|
||||
func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
|
||||
var t SeatTraffic
|
||||
for _, s := range holds {
|
||||
if !s.isNewTraffic() {
|
||||
continue
|
||||
}
|
||||
kind := ""
|
||||
if s.Kinded {
|
||||
kind = s.Kind
|
||||
if kind == "" || !safeSubject.MatchString(kind) {
|
||||
// A kinded claim without a usable kind is refused at registration; here it is granted
|
||||
// nothing, which is the same answer at the last place it could be asked.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(s.Accepts) > 0 && s.PulledByKind {
|
||||
// One worker per accepted kind, which every holder pulls in the order of Accepts (ADR 0525 §4, ADR 0536 §4):
|
||||
// `SEAT_<SEAT>_<KIND>_worker`, filtered to `accept.<kind>` (mesh-sdk delivery.AskWorker).
|
||||
for _, a := range s.Accepts {
|
||||
t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a),
|
||||
Filter: "mesh.seat." + s.Name + ".accept." + a})
|
||||
}
|
||||
} else if len(s.Accepts) > 0 {
|
||||
stream := seatStreamName(s.Name)
|
||||
filter := "mesh.seat." + s.Name + ".accept.>"
|
||||
if kind != "" {
|
||||
filter = "mesh.seat." + s.Name + ".accept.*." + kind
|
||||
}
|
||||
t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter})
|
||||
}
|
||||
for _, a := range s.Accepts {
|
||||
switch {
|
||||
case kind != "":
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind))
|
||||
case namesVerb(s.ByCaller, a):
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*"))
|
||||
}
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
switch {
|
||||
case kind != "":
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind))
|
||||
case namesVerb(s.ByCaller, e):
|
||||
t.Publish = append(t.Publish, seatSubject(s, "event", e+".*"))
|
||||
}
|
||||
}
|
||||
if kind != "" {
|
||||
for _, v := range s.Proofs {
|
||||
t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range uses {
|
||||
if !s.isNewTraffic() {
|
||||
continue
|
||||
}
|
||||
for _, a := range s.Accepts {
|
||||
switch {
|
||||
case namesVerb(s.ByCaller, a):
|
||||
t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module))
|
||||
case s.Kinded && module == s.DeclaredBy:
|
||||
// Work for a kind is put on its queue by the bench's own router, and by no other user.
|
||||
t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*"))
|
||||
}
|
||||
}
|
||||
for _, e := range s.Emits {
|
||||
if namesVerb(s.ByCaller, e) {
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module))
|
||||
}
|
||||
}
|
||||
for _, b := range s.Records {
|
||||
if !safeSubject.MatchString(b) {
|
||||
continue
|
||||
}
|
||||
t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>")
|
||||
}
|
||||
if s.PulledByKind && len(s.Accepts) > 0 && safeSubject.MatchString(s.Name) {
|
||||
t.Records = append(t.Records, DeadLetterReads(seatStreamName(s.Name))...)
|
||||
}
|
||||
}
|
||||
for _, w := range watches {
|
||||
if w.Kinded {
|
||||
for _, e := range w.Emits {
|
||||
t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*"))
|
||||
}
|
||||
if module == w.DeclaredBy {
|
||||
// A code is answered by the bench's own router, and by no other watcher.
|
||||
for _, v := range w.Proofs {
|
||||
t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Publish = unique(t.Publish)
|
||||
t.Subscribe = unique(t.Subscribe)
|
||||
t.Answers = unique(t.Answers)
|
||||
t.Records = unique(t.Records)
|
||||
sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer })
|
||||
return t
|
||||
}
|
||||
|
||||
// DeadLetterReads is what a user of a seat pulled by kind is granted to hear which of the asks it put the seat's workers
|
||||
// gave up on (novox/hq issue 734): an ordered consumer on DEAD_LETTERS filtered to the copies the controller keeps of that
|
||||
// seat's stream (DeadLetterSubject(stream, *)), pulled, and its deletion. A copy carries the ask's own message id
|
||||
// (Mesh-Dead-Msg-Id), so the asker ends the step it waits for instead of waiting for ever; it reads the seat's dead
|
||||
// letters and no other stream's, and changes none.
|
||||
func DeadLetterReads(stream string) []string {
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + DeadLettersStream,
|
||||
"$JS.API.CONSUMER.CREATE." + DeadLettersStream + ".*." + DeadLetterSubject(stream, ">"),
|
||||
"$JS.API.CONSUMER.MSG.NEXT." + DeadLettersStream + ".*",
|
||||
"$JS.API.CONSUMER.DELETE." + DeadLettersStream + ".*",
|
||||
}
|
||||
}
|
||||
|
||||
// seatTrafficGrants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a
|
||||
// worker is pulled and acknowledged through and a record is read through.
|
||||
func seatTrafficGrants(t SeatTraffic) (pub, sub []string) {
|
||||
pub = append(pub, t.Publish...)
|
||||
sub = append(sub, t.Subscribe...)
|
||||
sub = append(sub, t.Answers...)
|
||||
for _, w := range t.Workers {
|
||||
pub = append(pub,
|
||||
"$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer,
|
||||
"$JS.ACK."+w.Stream+"."+w.Consumer+".>")
|
||||
}
|
||||
pub = append(pub, t.Records...)
|
||||
return pub, sub
|
||||
}
|
||||
|
||||
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
|
||||
// composed exactly as before them.
|
||||
func (s Seat) isNewTraffic() bool {
|
||||
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind
|
||||
}
|
||||
|
||||
// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by
|
||||
// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else.
|
||||
func plainVerbs(s Seat, verbs []string) []string {
|
||||
if s.Kinded {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, v := range verbs {
|
||||
if !namesVerb(s.ByCaller, v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies
|
||||
// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it —
|
||||
// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only
|
||||
// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'.
|
||||
func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) {
|
||||
streams := map[string]Stream{}
|
||||
consumers := map[string]Consumer{}
|
||||
add := func(module string, holds []Seat) {
|
||||
for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers {
|
||||
seat, byKind := "", false
|
||||
for _, s := range holds {
|
||||
if seatStreamName(s.Name) == w.Stream {
|
||||
seat, byKind = s.Name, s.PulledByKind
|
||||
}
|
||||
}
|
||||
streams[w.Stream] = Stream{
|
||||
Name: w.Stream,
|
||||
Subjects: []string{"mesh.seat." + seat + ".accept.>"},
|
||||
Retention: RetentionWorkQueue,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does",
|
||||
}
|
||||
c := Consumer{
|
||||
Name: w.Consumer,
|
||||
Stream: w.Stream,
|
||||
Filters: []string{w.Filter},
|
||||
AckWaitSeconds: 60,
|
||||
// No bound on redelivery: a channel away for a day keeps its work, offered again later and
|
||||
// later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08).
|
||||
MaxDeliver: 0,
|
||||
Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " +
|
||||
"recorded it, so a crash redelivers rather than loses",
|
||||
}
|
||||
if byKind {
|
||||
// A build seat's ask is offered a bounded number of times (issue 1907): the holder that takes it past
|
||||
// mesh-sdk delivery.AskRuns ends it failed saying it is poison, and the bound stops a run that kills
|
||||
// every node it reaches even when that holder dies too.
|
||||
c.MaxDeliver = BuildAskMaxDeliver
|
||||
c.Why = module + " holds " + seat + "; each holder pulls an ask when it has a free slot, and an ask that " +
|
||||
"no run ends is offered at most " + fmt.Sprint(BuildAskMaxDeliver) + " times, its step failed as poison"
|
||||
}
|
||||
consumers[w.Consumer] = c
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
add(p.Module, p.Holds)
|
||||
case KindNodeTools:
|
||||
for _, d := range p.Carries {
|
||||
add(d.Module, d.Holds)
|
||||
}
|
||||
}
|
||||
}
|
||||
var ss []Stream
|
||||
for _, s := range streams {
|
||||
ss = append(ss, s)
|
||||
}
|
||||
sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name })
|
||||
var cs []Consumer
|
||||
for _, c := range consumers {
|
||||
cs = append(cs, c)
|
||||
}
|
||||
sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name })
|
||||
return ss, cs
|
||||
}
|
||||
|
||||
// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it
|
||||
// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender.
|
||||
func trustedTraffic(d Declared) string {
|
||||
for _, s := range d.Holds {
|
||||
for _, e := range s.Emits {
|
||||
if namesVerb(s.ByCaller, e) {
|
||||
return "it says " + s.Name + "'s " + e + " to one caller each"
|
||||
}
|
||||
}
|
||||
if s.Kinded && namesVerb(s.Capabilities, "verified-sender") {
|
||||
return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not
|
||||
// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it.
|
||||
func TrafficQueues(seats []Seat) []Stream {
|
||||
var out []Stream
|
||||
seen := map[string]bool{}
|
||||
for _, s := range seats {
|
||||
if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] {
|
||||
continue
|
||||
}
|
||||
seen[s.Name] = true
|
||||
out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
|
||||
Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3).
|
||||
//
|
||||
// A module names the state it keeps (`state`) and the state of others it reads (`reads`), and each
|
||||
// is a key-value bucket: the server's own last-per-subject stream with direct reads, delete markers
|
||||
// and watches, which is the state relationship the mesh already uses for declarations, opened to
|
||||
// modules. The controller creates every bucket from the catalogue — from registration, like a
|
||||
// seat's stream, so a reader may watch before the owner runs anywhere — and no module can.
|
||||
//
|
||||
// Pure, like everything else in this package that decides what the bus holds; jetstream.go is the
|
||||
// part that asks a server.
|
||||
|
||||
// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file,
|
||||
// and a bucket that grew without bound would be one module filling the bus's disk for everyone.
|
||||
const (
|
||||
StateMaxValueBytes = 256 * 1024
|
||||
StateMaxBytes = 64 * 1024 * 1024
|
||||
)
|
||||
|
||||
// A Bucket is one module's declared state as the bus holds it.
|
||||
type Bucket struct {
|
||||
Module string
|
||||
Name string
|
||||
// History is how many values a key keeps; zero is one.
|
||||
History int
|
||||
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
|
||||
TTLSeconds int
|
||||
// PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR
|
||||
// 0260), and is granted that key and no other.
|
||||
PerMachine bool
|
||||
// KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its
|
||||
// own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends.
|
||||
KeyTTL bool
|
||||
}
|
||||
|
||||
// KeyTTLMarkerTTL is how long the marker an expired key leaves is kept: long enough for a watcher to hear it.
|
||||
const KeyTTLMarkerTTL = time.Minute
|
||||
|
||||
// BucketName is the bucket a module's state lives in: the module and the local name joined by an
|
||||
// underscore, which neither may contain, so two modules can never derive one bucket.
|
||||
func BucketName(module, name string) string { return module + "_" + name }
|
||||
|
||||
// Bucket is this bucket's name on the bus.
|
||||
func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) }
|
||||
|
||||
// Why is carried into the server's description of the bucket, so somebody reading the server's
|
||||
// own state finds whose it is and why it is kept.
|
||||
func (b Bucket) Why() string {
|
||||
return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+
|
||||
"read by whatever declares it reads it; kept when %s is unassigned, because it is data",
|
||||
b.Module, b.Name, b.Module, b.Module)
|
||||
}
|
||||
|
||||
// bucketOfRead is the bucket a read names, `<module>.<name>`, or false when it names none.
|
||||
func bucketOfRead(read string) (string, bool) {
|
||||
at := strings.LastIndex(read, ".")
|
||||
if at <= 0 || at == len(read)-1 {
|
||||
return "", false
|
||||
}
|
||||
module, name := read[:at], read[at+1:]
|
||||
if !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
|
||||
return "", false
|
||||
}
|
||||
return BucketName(module, name), true
|
||||
}
|
||||
|
||||
// readGrants is what reaching a bucket's keys needs: binding to it, a direct get, an ordered consumer for listing and
|
||||
// watching and the deletion of that consumer and its flow control. key is one key's subject tail, a pattern such as
|
||||
// `one.>` for a prefix of keys, or empty for every key. The bucket is made with AllowDirect, so a get is the direct get
|
||||
// and never the stream's message get.
|
||||
func readGrants(bucket, key string) []string {
|
||||
stream := "KV_" + bucket
|
||||
out := []string{"$JS.API.STREAM.INFO." + stream}
|
||||
if key == "" {
|
||||
out = append(out,
|
||||
"$JS.API.DIRECT.GET."+stream+".>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".>")
|
||||
} else {
|
||||
out = append(out,
|
||||
"$JS.API.DIRECT.GET."+stream+".$KV."+bucket+"."+key,
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+"."+key)
|
||||
}
|
||||
return append(out,
|
||||
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
||||
"$JS.FC."+stream+".>")
|
||||
}
|
||||
|
||||
// NodeKeysGrants is what a node's node-engine is granted of a bucket it writes by node, `<node>.>` and no other key
|
||||
// (novox/hq ADR 0429 §1: each key one writer, enforced by credentials): the writes of those keys and what a get,
|
||||
// create, update and watch of them need. The node name is checked as every subject token is.
|
||||
func NodeKeysGrants(bucket, node string) ([]string, error) {
|
||||
if !safeSubject.MatchString(node) {
|
||||
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node)
|
||||
}
|
||||
return append([]string{"$KV." + bucket + "." + node + ".>"}, readGrants(bucket, node+".>")...), nil
|
||||
}
|
||||
|
||||
// stateGrants is what a principal publishes to reach the state its modules keep and read: for every
|
||||
// bucket, binding to it, reading a key directly, and an ordered consumer for listing and watching,
|
||||
// created and deleted on the bucket's own stream, with its flow control answered; for a bucket an
|
||||
// owner keeps, writing under the bucket's own subjects too.
|
||||
//
|
||||
// **Measured against a running server, 2026-10-04** (novox/hq research 024), and each one is there
|
||||
// because leaving it out failed: without STREAM.INFO nothing binds; without DIRECT.GET nothing is
|
||||
// read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a
|
||||
// watch cannot be stopped and lingers on the server. A write outside these is refused by the server
|
||||
// — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first.
|
||||
//
|
||||
// **One key, where the state is per machine** (novox/hq ADR 0260). The owner's instance on a machine
|
||||
// writes and reads the key named for that machine, and a holder granted a read for its machine reads
|
||||
// that key: a direct get of the key's own subject, and a consumer whose filter is that subject — the
|
||||
// server's create-with-filter form, which a client uses for a watch of one key and which the server
|
||||
// holds to the filter it names. What cannot be narrowed by key: binding (STREAM.INFO), deleting a
|
||||
// consumer (named at random by the client) and flow control; none of them reads or writes a value.
|
||||
func stateGrants(a stateAccess) []string {
|
||||
var out []string
|
||||
read := func(bucket, key string) { out = append(out, readGrants(bucket, key)...) }
|
||||
machineKey := ""
|
||||
if safeSubject.MatchString(a.Node) {
|
||||
machineKey = a.Node
|
||||
}
|
||||
perMachine := map[string]bool{}
|
||||
for _, n := range a.PerMachine {
|
||||
perMachine[n] = true
|
||||
}
|
||||
for _, name := range a.Keeps {
|
||||
if !safeSubject.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
bucket := BucketName(a.Module, name)
|
||||
if perMachine[name] {
|
||||
if machineKey == "" {
|
||||
continue // a machine with no usable name reaches none of it
|
||||
}
|
||||
read(bucket, machineKey)
|
||||
out = append(out, "$KV."+bucket+"."+machineKey)
|
||||
continue
|
||||
}
|
||||
read(bucket, "")
|
||||
out = append(out, "$KV."+bucket+".>")
|
||||
}
|
||||
// **And it says what it keeps** (novox/hq ADR 0495 clause 2, issue 1790): a module keeping state announces it on its
|
||||
// own event subject, from its code; refused, its readers are read as before.
|
||||
if len(a.Keeps) > 0 && safeSubject.MatchString(a.Module) {
|
||||
out = append(out, announce.Subject(a.Module))
|
||||
}
|
||||
for _, r := range a.Reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
read(bucket, "")
|
||||
}
|
||||
}
|
||||
for _, k := range a.KeyedReads {
|
||||
if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) {
|
||||
read(bucket, k.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StreamCreatedAdvisory is the subject the bus says a stream was created on (the server's own advisory), here for a
|
||||
// bucket's stream KV_<bucket>.
|
||||
func StreamCreatedAdvisory(bucket string) string {
|
||||
return "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_" + bucket
|
||||
}
|
||||
|
||||
// stateCreatedHeard is what a principal subscribes to so that a bucket it may bind and that is not on the bus yet is
|
||||
// bound when it appears (novox/hq issue 1790, 2026-10-26: since the grants part creates a manifest's buckets, ADR 0547,
|
||||
// a module's delivery can start it before its new state exists): the bus's advisory that the bucket's stream was
|
||||
// created, for each bucket stateGrants lets it bind, and no other. Read-only: the server publishes it in the mesh's own
|
||||
// account, and hearing it changes nothing.
|
||||
func stateCreatedHeard(a stateAccess) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
add := func(bucket string) {
|
||||
if !seen[bucket] {
|
||||
seen[bucket] = true
|
||||
out = append(out, StreamCreatedAdvisory(bucket))
|
||||
}
|
||||
}
|
||||
perMachine := map[string]bool{}
|
||||
for _, n := range a.PerMachine {
|
||||
perMachine[n] = true
|
||||
}
|
||||
for _, name := range a.Keeps {
|
||||
if !safeSubject.MatchString(name) || (perMachine[name] && !safeSubject.MatchString(a.Node)) {
|
||||
continue
|
||||
}
|
||||
add(BucketName(a.Module, name))
|
||||
}
|
||||
for _, r := range a.Reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
add(bucket)
|
||||
}
|
||||
}
|
||||
for _, k := range a.KeyedReads {
|
||||
if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) {
|
||||
add(bucket)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KeyedRead is one key of another module's state a principal reads, and no other key of it.
|
||||
type KeyedRead struct {
|
||||
Read string `json:"read"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// stateAccess is what one principal may do with state: the module's own buckets (some per machine),
|
||||
// what it reads whole, and what it reads for its machine's key alone.
|
||||
type stateAccess struct {
|
||||
Module, Node string
|
||||
Keeps, PerMachine []string
|
||||
Reads []string
|
||||
KeyedReads []KeyedRead
|
||||
}
|
||||
|
||||
// perMachineNames is the local names of a module's buckets keyed by machine.
|
||||
func perMachineNames(buckets []Bucket) []string {
|
||||
var out []string
|
||||
for _, b := range buckets {
|
||||
if b.PerMachine {
|
||||
out = append(out, b.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stateIssuedFor is every bucket a module's code may reach on a machine, as its membership lists them.
|
||||
func stateIssuedFor(d Declared, node string) []StateIssued {
|
||||
var out []StateIssued
|
||||
for _, b := range d.State {
|
||||
issued := StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true}
|
||||
if b.PerMachine {
|
||||
issued.Key = node
|
||||
}
|
||||
out = append(out, issued)
|
||||
}
|
||||
for _, r := range d.Reads {
|
||||
if bucket, ok := bucketOfRead(r); ok {
|
||||
out = append(out, StateIssued{Name: r, Bucket: bucket})
|
||||
}
|
||||
}
|
||||
for _, k := range d.KeyedReads {
|
||||
if bucket, ok := bucketOfRead(k.Read); ok {
|
||||
out = append(out, StateIssued{Name: k.Read, Bucket: bucket, Key: k.Key})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stateNames is the local names of a module's own buckets.
|
||||
func stateNames(buckets []Bucket) []string {
|
||||
out := make([]string, 0, len(buckets))
|
||||
for _, b := range buckets {
|
||||
out = append(out, b.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A BucketAsserter is the part of a JetStream connection bucket assertion needs.
|
||||
type BucketAsserter interface {
|
||||
// EnsureBucket creates the bucket if absent and brings its options to match if present, never
|
||||
// discarding what it holds.
|
||||
EnsureBucket(b Bucket) error
|
||||
// BucketNames is every key-value bucket on the server.
|
||||
BucketNames() ([]string, error)
|
||||
}
|
||||
|
||||
// RaiseBuckets asserts every declared bucket and answers the buckets on the server that nothing
|
||||
// declares any more.
|
||||
//
|
||||
// **Those are reported, never removed** (novox/hq ADR 0201, ADR 0030): what a module stored is
|
||||
// data, and a manifest edited, a module renamed or a catalogue entry dropped is an ordinary day's
|
||||
// work that must not take data with it. Removing one is a person's act.
|
||||
func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err error) {
|
||||
sorted := append([]Bucket(nil), buckets...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Bucket() < sorted[j].Bucket() })
|
||||
declared := map[string]bool{}
|
||||
for _, b := range sorted {
|
||||
if err := a.EnsureBucket(b); err != nil {
|
||||
return nil, fmt.Errorf("asserting %s's state %q: %w", b.Module, b.Name, err)
|
||||
}
|
||||
declared[b.Bucket()] = true
|
||||
}
|
||||
names, err := a.BucketNames()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing the bus's state: %w", err)
|
||||
}
|
||||
for _, n := range names {
|
||||
// A seat's cancelled set is the mesh's own (novox/hq ADR 0219), not a module's state; so are
|
||||
// the controller's own buckets (novox/hq to-be 45 §1).
|
||||
if !declared[n] && !IsCancelledSet(n) && !IsControllerBucket(n) {
|
||||
undeclared = append(undeclared, n)
|
||||
}
|
||||
}
|
||||
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+".>")...)
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The mesh's own streams.
|
||||
//
|
||||
// **These and no more** (novox/hq ADR 0116 task 1.4, as revised by ADR 0118; the two that keep what a
|
||||
// consumer gave up on added for issue 330). An earlier
|
||||
// reading had the controller create *every* stream at genesis, from a fixed set. That is only the
|
||||
// mesh's own half: a seat's streams are created when the module declaring it is registered, and a
|
||||
// module's durable consumers when it is assigned — neither of which has happened at genesis. What
|
||||
// is here is the foundation, which exists before any module does.
|
||||
//
|
||||
// The controller is the only writer of stream definitions (design 25 §3). A module declares
|
||||
// nothing about them and cannot reach the JetStream API to make one.
|
||||
|
||||
// Retention is how a stream decides what to keep, which is the whole of what distinguishes the
|
||||
// mesh's four relationships on the wire (design 29 §4).
|
||||
type Retention string
|
||||
|
||||
const (
|
||||
// RetentionWorkQueue: a message is removed once a consumer acknowledges it. Exactly one
|
||||
// worker does the work, and a worker that dies has its message redelivered.
|
||||
RetentionWorkQueue Retention = "workqueue"
|
||||
// RetentionLastPerSubject: only the newest message on each subject survives. This is the
|
||||
// state shape — a node that was away gets exactly the current declaration and nothing older.
|
||||
RetentionLastPerSubject Retention = "last_per_subject"
|
||||
// RetentionLimits: kept until it ages or the stream fills. Events, where a subscriber that
|
||||
// was down catches up and nobody is obliged to act.
|
||||
RetentionLimits Retention = "limits"
|
||||
)
|
||||
|
||||
// A Stream is one of the mesh's own, as the controller asserts it.
|
||||
type Stream struct {
|
||||
Name string
|
||||
Subjects []string
|
||||
Retention Retention
|
||||
// MaxAge in seconds, zero for unbounded. Per stream — JetStream has no per-subject age,
|
||||
// which is why differing retention between modules would mean a stream each.
|
||||
MaxAge int
|
||||
// MaxMsgsPerSubject caps each subject independently, so one noisy emitter cannot push
|
||||
// another's events out of a shared stream. Verified: with a cap of 3, ten messages on one
|
||||
// subject and one on another leave four in the stream, not three.
|
||||
MaxMsgsPerSubject int
|
||||
// Why is carried into the assertion so an operator reading the server's own state finds the
|
||||
// reason there, rather than only in a repository they may not have.
|
||||
Why string
|
||||
// Direct lets a client read a subject's last message without a consumer, which is how a
|
||||
// runtime reads its own membership with no JetStream API beyond one request (ADR 0160).
|
||||
Direct bool
|
||||
// MaxBytes bounds the stream's size, zero for unbounded. With DiscardNew a full stream refuses
|
||||
// what comes next rather than dropping what it holds: the publisher is told, and says so.
|
||||
MaxBytes int64
|
||||
DiscardNew bool
|
||||
// DuplicatesSeconds is the window in which a message id published twice is kept once; zero for the
|
||||
// server's default (two minutes).
|
||||
DuplicatesSeconds int
|
||||
// AllowMsgTTL lets a message carry a time to live of its own (`Nats-TTL`), for what must not outlive the
|
||||
// stream it came from (novox/hq ADR 0376 §3, §5).
|
||||
AllowMsgTTL bool
|
||||
// Restricted marks a module's stream restricted by a grant (novox/hq ADR 0363 §4), in the stream's own
|
||||
// metadata, so whoever keeps one of its messages reads it from the bus (ADR 0376 §3).
|
||||
Restricted bool
|
||||
// WorkQueue marks a module stream declared retention: work-queue (novox/hq ADR 0436 §6), in the
|
||||
// stream's own metadata, so a dead letter kept from it is read as drop-only from the bus, by the
|
||||
// declaration and not by the bus's work-queue retention alone — which SEAT_* and other mesh-internal
|
||||
// streams share without being one of ADR 0436's, and whose dead letters are still delivered again
|
||||
// (ADR 0264 §4).
|
||||
WorkQueue bool
|
||||
}
|
||||
|
||||
// RestrictedMetadata is the key a restricted stream's metadata says it under.
|
||||
const RestrictedMetadata = "mesh-restricted"
|
||||
|
||||
// WorkQueueMetadata is the key a module stream declared retention: work-queue (novox/hq ADR 0436 §6) says
|
||||
// it under: never set by a mesh-internal work-queue stream (a seat's, a check's), only by Stream.WorkQueue.
|
||||
const WorkQueueMetadata = "mesh-work-queue"
|
||||
|
||||
// What a durable consumer gave up on is kept (novox/hq issue 330, design 25 §3).
|
||||
//
|
||||
// **The server says it and keeps it; the controller copies it.** A consumer that handed a message over
|
||||
// as often as it may stops offering it and publishes `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES` with
|
||||
// the stream and the message's sequence. DeadLetterNoticesStream captures those advisories as the
|
||||
// server publishes them, so one said while no controller listens is still there when one starts. The
|
||||
// controller's consumer on it fetches the given-up message by its sequence, while the source stream
|
||||
// still holds it, and keeps a copy in DeadLettersStream under DeadLetterSubject, with the consumer,
|
||||
// the subject, how often it was handed over and when it was given up. It stays there until a person
|
||||
// delivers it again or drops it, with why; a condition is open for as long as it does.
|
||||
const (
|
||||
DeadLetterNoticesStream = "DEAD_LETTER_NOTICES"
|
||||
DeadLettersStream = "DEAD_LETTERS"
|
||||
// MaxDeliveriesAdvisories is the subject the server says a given-up message on.
|
||||
MaxDeliveriesAdvisories = "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>"
|
||||
deadLetterPrefix = "mesh.events.dead."
|
||||
againPrefix = "mesh.again."
|
||||
// DeadLettersBytes bounds the kept copies. Full, the stream refuses the next copy, which is said
|
||||
// as the consumer's condition; it never drops one it holds.
|
||||
DeadLettersBytes = 256 << 20
|
||||
)
|
||||
|
||||
// DeadLetterSubject is where a message one consumer gave up on is kept: `mesh.events.dead.<stream>.<consumer>`.
|
||||
func DeadLetterSubject(stream, consumer string) string {
|
||||
return deadLetterPrefix + stream + "." + consumer
|
||||
}
|
||||
|
||||
// DeadLetterOf is the stream and consumer a kept message's subject names; false for any other subject.
|
||||
func DeadLetterOf(subject string) (stream, consumer string, ok bool) {
|
||||
rest, found := strings.CutPrefix(subject, deadLetterPrefix)
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
stream, consumer, ok = strings.Cut(rest, ".")
|
||||
return stream, consumer, ok && stream != "" && consumer != "" && !strings.Contains(consumer, ".")
|
||||
}
|
||||
|
||||
// AgainSubject is where an event given up on is delivered again to the one consumer that gave it up,
|
||||
// and to nobody else: `mesh.again.<consumer>.` and the original subject without its `mesh.`. Every
|
||||
// consumer on EVENTS filters its own (AgainFilter), and a module's runtime reads the event's key from
|
||||
// the tokens around `.event.`, so the handler sees the same key it saw the first time.
|
||||
func AgainSubject(consumer, original string) string {
|
||||
return againPrefix + consumer + "." + strings.TrimPrefix(original, "mesh.")
|
||||
}
|
||||
|
||||
// AgainFilter is the one consumer's share of the subjects events are delivered again on.
|
||||
func AgainFilter(consumer string) string { return againPrefix + consumer + ".>" }
|
||||
|
||||
// OriginalOfAgain is the subject an event delivered again was first published on; false for a subject
|
||||
// that is not one delivered again.
|
||||
func OriginalOfAgain(subject string) (string, bool) {
|
||||
rest, found := strings.CutPrefix(subject, againPrefix)
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
_, original, ok := strings.Cut(rest, ".")
|
||||
if !ok || original == "" {
|
||||
return "", false
|
||||
}
|
||||
return "mesh." + original, true
|
||||
}
|
||||
|
||||
// MeshStreams is the foundation set, in the order a person reads it.
|
||||
//
|
||||
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
||||
// under that prefix and must not be persisted: a lost heartbeat is the next heartbeat, and a
|
||||
// stream of them is a stream of the least valuable messages the mesh sends, competing for the
|
||||
// same retention as the ones that matter.
|
||||
//
|
||||
// **EVENTS filters on the `event` token**, which is the reason that token exists. A module's
|
||||
// namespace carries both its events and its tool calls; a filter of `mesh.mod.*.>` would persist
|
||||
// every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps
|
||||
// tools on core NATS, where a lost call is a timeout the caller already handles).
|
||||
func MeshStreams() []Stream {
|
||||
return []Stream{
|
||||
{
|
||||
Name: "CONTROL",
|
||||
// A build's outcome is no longer here: it is the build-machine seat's own event, so one
|
||||
// publish reaches whoever asked, the controller and the catalogue (novox/hq ADR 0121).
|
||||
Subjects: []string{"mesh.control.*.report", "mesh.control.enrol"},
|
||||
Retention: RetentionWorkQueue,
|
||||
Why: "the store-window guarantee (ADR 0083): the controller naks with a delay while its " +
|
||||
"store is away and the message is redelivered; nothing is dropped",
|
||||
},
|
||||
{
|
||||
Name: "NODES",
|
||||
Subjects: []string{"mesh.node.*.declare"},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||
"n-1 by construction (issue 107)",
|
||||
},
|
||||
{
|
||||
Name: AssignmentsStream,
|
||||
Subjects: []string{"mesh.assignment.*.*"},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Direct: true,
|
||||
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
|
||||
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
|
||||
},
|
||||
{
|
||||
Name: EventsStream,
|
||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
|
||||
// tools (`tool`), which must not be persisted.
|
||||
// And an event given up on, delivered again to the one consumer that gave it up
|
||||
// (novox/hq issue 330): under `mesh.again.<consumer>.`, which only that consumer filters.
|
||||
Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>", againPrefix + ">"},
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
MaxMsgsPerSubject: 10000,
|
||||
// A restricted dead letter delivered again keeps what is left of its stream's age (ADR 0376 §5).
|
||||
AllowMsgTTL: true,
|
||||
Why: "a subscriber that was down catches up; tool traffic under the same prefix is " +
|
||||
"excluded by the event token; per-subject caps keep a noisy emitter from " +
|
||||
"evicting a quiet one without splitting the stream",
|
||||
},
|
||||
{
|
||||
Name: DeadLetterNoticesStream,
|
||||
Subjects: []string{MaxDeliveriesAdvisories},
|
||||
Retention: RetentionWorkQueue,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "the server's word that a consumer gave up on a message, kept until the controller has " +
|
||||
"copied the message into DEAD_LETTERS (novox/hq issue 330); a week, the longest the source " +
|
||||
"streams keep what they are about",
|
||||
},
|
||||
{
|
||||
Name: DeadLettersStream,
|
||||
Subjects: []string{deadLetterPrefix + ">"},
|
||||
Retention: RetentionLimits,
|
||||
MaxBytes: DeadLettersBytes,
|
||||
DiscardNew: true,
|
||||
DuplicatesSeconds: 24 * 60 * 60,
|
||||
AllowMsgTTL: true,
|
||||
Why: "every message a consumer gave up on, with its consumer, subject, deliveries and when, kept " +
|
||||
"until a person delivers it again or drops it with why (novox/hq issue 330); no age but a restricted " +
|
||||
"stream's message, which ages out with its stream (ADR 0376), and full it refuses the next copy " +
|
||||
"rather than drop one it holds",
|
||||
},
|
||||
{
|
||||
Name: ProvAppliedStream,
|
||||
Subjects: []string{ProvAppliedSubjects},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Direct: true,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "each machine's provisioner client's newest word on each module's files, applied, waiting or " +
|
||||
"failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " +
|
||||
"ADR 0349 §15), read by subject; a week, as the events are",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per
|
||||
// subject (novox/hq ADR 0349 §4, ADR 0362, design 57 P6): `prov.applied.<node>.<module>` lies outside `mesh.*`, so
|
||||
// no other stream captures it, and a report said while no controller listened is still there to be read.
|
||||
const ProvAppliedStream = "PROV_APPLIED"
|
||||
|
||||
// ProvAppliedSubjects is every machine's client's reports: `prov.applied.<node>.<module>`, a module's name dots and
|
||||
// all (ProvisionerAppliedSubject).
|
||||
const ProvAppliedSubjects = "prov.applied.>"
|
||||
|
||||
// eventsTheControllerFollows are the events the controller's events consumer takes: all it follows but the forge's
|
||||
// merges, which the merges consumer takes (MergesConsumerName).
|
||||
func eventsTheControllerFollows() []string {
|
||||
var out []string
|
||||
for _, subject := range ControllerFollows {
|
||||
if subject != ControllerFollows[3] {
|
||||
out = append(out, subject)
|
||||
}
|
||||
}
|
||||
return append(out, ControllerHears...)
|
||||
}
|
||||
|
||||
// DeliverSubjectFor is where a push consumer's messages land.
|
||||
//
|
||||
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
|
||||
// consumer delivers onto an ordinary subject, and everything subscribed to that subject gets a
|
||||
// copy. The controller holds a consumer called `controller` on CONTROL and another called
|
||||
// `controller` on EVENTS; while both were given `_DELIVER.controller`, the one process holding
|
||||
// both subscriptions acted on every message twice — a joining machine was enrolled twice from one
|
||||
// request, and the second enrolment minted a credential that replaced the one the machine had just
|
||||
// been handed. Every report and every followed event doubled the same way, silently: nothing is
|
||||
// redelivered, no count is wrong, the work simply happens twice.
|
||||
//
|
||||
// The stream belongs in it because the pair is what identifies a consumer — the server scopes a
|
||||
// durable's name to its stream, and this subject was the one place that scoping was dropped. It
|
||||
// stays inside what a controller may already subscribe (`_DELIVER.controller.>`).
|
||||
func DeliverSubjectFor(c Consumer) string {
|
||||
if c.DeliverSubject != "" {
|
||||
return c.DeliverSubject
|
||||
}
|
||||
return "_DELIVER." + c.Name + "." + c.Stream
|
||||
}
|
||||
|
||||
// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it
|
||||
// keeps this testable without a server, and keeps the client library out of everything that only
|
||||
// wants to know what the streams are.
|
||||
type Asserter interface {
|
||||
// EnsureStream creates the stream if absent and updates it to match if present. It must be
|
||||
// idempotent: the controller asserts on every start, not only at genesis.
|
||||
EnsureStream(s Stream) error
|
||||
}
|
||||
|
||||
// AssertMeshStreams brings the foundation set into being, in order, and says which one failed
|
||||
// rather than that something did.
|
||||
//
|
||||
// Asserted on every start rather than created once at genesis, because a stream that was deleted,
|
||||
// or a mesh raised from a restored backup, must converge rather than run without the guarantee
|
||||
// its messages assume. Idempotence is the whole requirement.
|
||||
func AssertMeshStreams(a Asserter) error {
|
||||
for _, s := range MeshStreams() {
|
||||
if err := a.EnsureStream(s); err != nil {
|
||||
return fmt.Errorf("asserting stream %s: %w", s.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Overlaps reports subject filters claimed by more than one stream.
|
||||
//
|
||||
// **Corrected against the server**: an earlier version of this comment said NATS accepts
|
||||
// overlapping streams and stores the message twice. It does not — it refuses the second stream
|
||||
// with "subjects overlap with an existing stream" (verified against nats-server 2.10). The check
|
||||
// still earns its place, for a different reason: the server's refusal arrives when the controller
|
||||
// is applying, naming one stream, at a moment when the mesh is half-configured. This one arrives
|
||||
// where the set is written, names both, and cannot reach a running mesh.
|
||||
//
|
||||
// It also decides a design question. Because overlap is refused rather than merged, a shared
|
||||
// EVENTS stream and a per-module stream cannot coexist — the module's would be refused — so
|
||||
// "one stream for most, its own for a module that wants different retention" is not an option
|
||||
// the server allows. It is all of one or all of the other.
|
||||
func Overlaps() []string {
|
||||
seen := map[string]string{}
|
||||
var clashes []string
|
||||
for _, s := range MeshStreams() {
|
||||
for _, subject := range s.Subjects {
|
||||
if first, ok := seen[subject]; ok {
|
||||
clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject))
|
||||
continue
|
||||
}
|
||||
seen[subject] = s.Name
|
||||
}
|
||||
}
|
||||
sort.Strings(clashes)
|
||||
return clashes
|
||||
}
|
||||
|
||||
// The mesh's own consumers.
|
||||
//
|
||||
// A seat's streams and a module's consumers are derived from declarations (derived.go). These two
|
||||
// are not: **the controller is not a module and files no manifest**, so its authority and its
|
||||
// subscriptions cannot come from a declaration that does not exist. They are named here, where the
|
||||
// mesh's own streams are named, and narrowly — a controller subscribing `mesh.mod.*.event.>` would
|
||||
// hear every event in the mesh, which it has no business doing and which would make its permission
|
||||
// list stop explaining anything.
|
||||
|
||||
// ControllerName is the controller's durable consumer on each stream it reads, and the name its
|
||||
// ack subject is derived from (nats.go: `$JS.ACK.<stream>.controller.>`).
|
||||
const ControllerName = "controller"
|
||||
|
||||
// MergesConsumerName is the controller's durable consumer of the forge's merged event on EVENTS, apart from the
|
||||
// events consumer (novox/hq ADR 0407 decision 3, issue 973). A merge is never dropped, so this consumer has no
|
||||
// max-deliver, which would send a merge held five times to DEAD_LETTERS to wait for a person, and no healer
|
||||
// resets it, which would drop the merges it holds. A consumer of its own because both of those are properties of
|
||||
// a consumer, not of a subject.
|
||||
const MergesConsumerName = "controller-merges"
|
||||
|
||||
// ControllerSeat is the role the control plane holds, and ControllerStates are the facts it states
|
||||
// under it (novox/hq ADR 0134).
|
||||
//
|
||||
// **Written here as well as in `link`, and a test keeps them agreeing.** `link` imports `broker`, so
|
||||
// `broker` cannot import `link`; a grant naming a subject the controller never publishes is authority
|
||||
// nobody uses, and a controller publishing one the grant omits is refused at the moment it has
|
||||
// something to say.
|
||||
const ControllerSeat = "mesh-controller"
|
||||
|
||||
var ControllerStates = []string{"applied", "refused", "built-before",
|
||||
// What is wrong, said as it changes (novox/hq to-be 45 §2): a condition raised, changed in
|
||||
// severity, resolver or silence, and cleared. The operator-channel's holder and any other surface
|
||||
// consume them; the controller tells nobody itself.
|
||||
"condition-raised", "condition-changed", "condition-cleared",
|
||||
// And the self-check's heartbeat, at the end of every run (to-be 45 §4, S10): watched from a
|
||||
// machine that is not the control node, so the controller going quiet is itself said.
|
||||
"doctor-heartbeat",
|
||||
// And a value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
|
||||
"secret-replaced",
|
||||
// And every act a healer takes on a condition (novox/hq to-be 45 §7, Phase 3): a repair the mesh
|
||||
// made by itself is said like one a person made, never quietly.
|
||||
"healer-acted",
|
||||
// And a build put back after its gate failed on its first machine (novox/hq ADR 0236, to-be 45 §8).
|
||||
"rolled-back",
|
||||
// And a pull request's merge check, judged (novox/hq to-be 45 §9): what the forge's holder sets as
|
||||
// the pull request's status, and anybody else may read.
|
||||
"checked",
|
||||
// (A walk kept in a new state and a tier sent to a machine are gone with the plans, novox/hq ADR 0539.)
|
||||
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and
|
||||
// each registered module's declared config (design 57 F3).
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed",
|
||||
// And a builder's key not taken in because the artifact store does not hold what it names (novox/hq issue 1937).
|
||||
"artifacts-gone",
|
||||
// And a bucket of the plans' state deleted at the controller's first start, and the declarations' stream once no node
|
||||
// reads one (novox/hq ADR 0539 clauses 11 and 13).
|
||||
"bucket-deleted"}
|
||||
|
||||
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
||||
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
||||
// may and gave up on it, and one that was deleted. Read-only: an advisory is the server's to
|
||||
// publish, and the controller's subscription changes nothing on the bus.
|
||||
var BusAdvisories = []string{
|
||||
"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>",
|
||||
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
|
||||
}
|
||||
|
||||
// ControllerFollows are the events the controller reacts to: the catalogue saying a module's
|
||||
// current version moved, and a catalogue that has just started saying it may have missed builds.
|
||||
//
|
||||
// **Derived the same way a module's subscription is**, from the emitter and the bare local event
|
||||
// name, rather than written out. They were written out while the catalogue still spelled its events
|
||||
// as the old bus's routing keys, and the moment those were converted (novox/hq 04-ISSUES/127) a
|
||||
// hard-coded pair became a controller listening to a subject nothing publishes — the same fault, from
|
||||
// the other side. Deriving them means the conversion could not leave these behind.
|
||||
var ControllerFollows = []string{
|
||||
moduleEventSubject("mesh-catalog", "upgraded"),
|
||||
moduleEventSubject("mesh-catalog", "catching-up"),
|
||||
// A build's outcome, which is the build-machine role's own event now (ADR 0121) rather than a
|
||||
// message on the control branch. Same three audiences, one publish: whoever asked, this, and the
|
||||
// catalogue.
|
||||
seatEventSubject("node-build-agent", "built"),
|
||||
// The forge's merges: what moved a source, so the mesh builds what that source produces
|
||||
// without anybody telling it (novox/hq 04-ISSUES/131). Appended, because the index is a name.
|
||||
moduleEventSubject("gitea", "pull.merged"),
|
||||
// The retired build role's outcome too, while the handover runs (novox/hq ADR 0190): the one
|
||||
// build machine keeps answering on its seat until build-agent replaces it, and the outcome that
|
||||
// registers build-agent itself comes from there. Appended, for the same reason as above; goes
|
||||
// with the retired seat row.
|
||||
seatEventSubject("mesh-build-machine", "built"),
|
||||
// **Every provider's standing** (novox/hq ADR 0224): a consumer it has failed for minutes, and
|
||||
// that consumer recovered. The one pattern on this list, and a narrow one — two named events,
|
||||
// from whichever module provides — because the rule is about every provider, and a list of
|
||||
// providers here would be a list somebody forgets to extend. On 2026-10-05 the identity provider
|
||||
// failed every consumer for a day and only its journal said so (issue 179). Appended, because
|
||||
// the index is a name.
|
||||
moduleEventSubject("*", ProvisionerFailing),
|
||||
moduleEventSubject("*", ProvisionerRecovered),
|
||||
// **What becomes of a consumer the mesh stopped asking for** (novox/hq ADR 0230): retired, waiting
|
||||
// for a person, re-enabled, deleted — a provider's third word, from whichever module provides.
|
||||
// Appended, because the index is a name.
|
||||
moduleEventSubject("*", ProvisionerRetirement),
|
||||
// **A pull request's head, announced** (novox/hq to-be 45 §9): what the controller asks the build
|
||||
// seat to check before it merges — every machine of the facts snapshot composed with the change.
|
||||
// Appended, because the index is a name.
|
||||
moduleEventSubject("gitea", "pull.updated"),
|
||||
// **The operator's answers to what the controller asked** (novox/hq ADR 0259): the router's warrant, or
|
||||
// the end of an ask without one, said to the controller alone under its own name. On the stream, so a
|
||||
// controller that was away hears what was decided meanwhile. Appended, because the index is a name.
|
||||
DecidedSubject,
|
||||
}
|
||||
|
||||
// ControllerHears are events the controller's events consumer takes beside the ones it follows, and that need no
|
||||
// subscribe grant of their own. **A push consumer delivers onto its deliver subject** (`_DELIVER.controller.>`, already
|
||||
// granted), so the subject a consumer filters on is never one the controller subscribes: listing this one in
|
||||
// ControllerFollows would add a subscribe grant the installer's carried user list, which lives in mesh-host, would
|
||||
// have to carry too (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), for nothing the consumer uses.
|
||||
//
|
||||
// A merge mesh-delivery made, announced (novox/hq ADR 0420 §7, issue 1025): with the ask whose Approve it merged on and
|
||||
// the heads that Approve covered, kept a week to read the warrant a moved module's registration needs.
|
||||
var ControllerHears = []string{DeliveryMergedSubject}
|
||||
|
||||
// DeliveryMergedEvent is the event a module says when it merged a pull request.
|
||||
const DeliveryMergedEvent = "merged"
|
||||
|
||||
// DeliveryMergedSubject is where the delivery's owner says it merged a pull request: one wildcard subject, the
|
||||
// module that claims the delivery seat being whichever the catalogue says (novox/hq ADR 0437 §3). Who said it is
|
||||
// read from the subject, and whether it claims the seat is the controller's to check.
|
||||
var DeliveryMergedSubject = moduleEventSubject("*", DeliveryMergedEvent)
|
||||
|
||||
// DeliveryMergedSubjectOf is where one module says it merged a pull request.
|
||||
func DeliveryMergedSubjectOf(module string) string {
|
||||
return moduleEventSubject(module, DeliveryMergedEvent)
|
||||
}
|
||||
|
||||
// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's.
|
||||
const AsksSeat = "operator-channel"
|
||||
|
||||
// DecidedSubject is where the router says the controller's warrants: the seat's event named by the
|
||||
// controller as its caller.
|
||||
var DecidedSubject = seatEventSubject(AsksSeat, "decided."+ControllerSeat)
|
||||
|
||||
// The provider standing events, by their local names. Written here as well as in the catalogue
|
||||
// (catalogue.ProvisionerEvents), which this package cannot import; a test keeps them agreeing.
|
||||
const (
|
||||
ProvisionerFailing = "provisioner.failing"
|
||||
ProvisionerRecovered = "provisioner.recovered"
|
||||
ProvisionerRetirement = "provisioner.retirement"
|
||||
)
|
||||
|
||||
// moduleEventSubject is where one module's event lands. The same derivation PermissionsFor uses, so
|
||||
// what the controller subscribes and what the emitter is permitted to publish cannot drift apart.
|
||||
func moduleEventSubject(module, event string) string {
|
||||
return "mesh.mod." + module + ".event." + event
|
||||
}
|
||||
|
||||
// seatEventSubject is where a role's own event lands, derived the same way a holder's permission is.
|
||||
func seatEventSubject(seat, verb string) string {
|
||||
return "mesh.seat." + seat + ".event." + verb
|
||||
}
|
||||
|
||||
// EventsStream holds every module's and every role's events, a build's log among them.
|
||||
const EventsStream = "EVENTS"
|
||||
|
||||
// MeshConsumers is what the controller consumes, in the order a person reads it.
|
||||
//
|
||||
// **Unlimited redelivery on CONTROL, deliberately.** The store window's bound is the controller's,
|
||||
// not the server's (window.go): a message is held with a nak-and-delay until the controller either
|
||||
// takes it or gives up and says so. A max-deliver here would give up on a push that was being
|
||||
// held through a store restart — the exact message the stream exists to protect — some minutes
|
||||
// before the controller had finished deciding about it.
|
||||
func MeshConsumers() []Consumer {
|
||||
return ControllerConsumers()
|
||||
}
|
||||
|
||||
// ControllerConsumers are the consumers the controller's own event loop takes from: MeshConsumers without the build
|
||||
// seat's check workers, which the builders take from and the loop never does (issue 1063). A consumer added to the
|
||||
// controller is counted here without a change elsewhere.
|
||||
func ControllerConsumers() []Consumer {
|
||||
return []Consumer{
|
||||
{
|
||||
Name: ControllerName,
|
||||
Stream: "CONTROL",
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
Why: "the controller is the single consumer of what nodes say; explicit ack and no " +
|
||||
"max-deliver, because the store window's bound is the controller's own",
|
||||
},
|
||||
// The forge's merges (novox/hq ADR 0407 decision 3, issue 973): never dropped, so no max-deliver and no
|
||||
// reset; a merge the controller cannot record yet is held with a growing delay until it can. **Asserted before
|
||||
// the events consumer**, which stops filtering merges: the order of this list is the order of assertion.
|
||||
{
|
||||
Name: MergesConsumerName,
|
||||
Stream: "EVENTS",
|
||||
// Within what the controller may subscribe already (`_DELIVER.controller.>`).
|
||||
DeliverSubject: "_DELIVER." + ControllerName + ".merges",
|
||||
Filters: []string{ControllerFollows[3]},
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
MaxAckPending: 1,
|
||||
FromNow: true,
|
||||
TakesOverFrom: ControllerName,
|
||||
Why: "the forge's merges, one at a time; no max-deliver and never reset, because a merge is never " +
|
||||
"dropped and no person releases one",
|
||||
},
|
||||
{
|
||||
Name: ControllerName,
|
||||
Stream: "EVENTS",
|
||||
Filters: append(eventsTheControllerFollows(), AgainFilter(ControllerName)),
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
// One at a time (novox/hq issue 175): acting on a merge builds for minutes, and an
|
||||
// announcement handed over behind it must wait on the server, not time out on the
|
||||
// client and come back to be acted on again.
|
||||
MaxAckPending: 1,
|
||||
FromNow: true,
|
||||
// **The one consumer the mesh resets by itself** (healer H4, issue 248): it fell a week
|
||||
// behind once and held every merge after it. What a reset drops is caught up elsewhere — a
|
||||
// build's outcome from the build records a plan settles from (issue 214), a provider's failing
|
||||
// word by the provider saying it again every quarter of an hour (ADR 0224). **Not the merges**:
|
||||
// they are the merges consumer's below, which a reset leaves alone (ADR 0407 decision 3).
|
||||
Resettable: "what it drops is caught up: build outcomes from the build records (issue 214), a " +
|
||||
"provider's failing word said again (ADR 0224); the forge's merges are not its, they have a " +
|
||||
"consumer of their own that no reset touches",
|
||||
Why: "the events the mesh's own controller reacts to, one at a time; after " +
|
||||
"max-deliver it gives the event up, and the controller keeps it in DEAD_LETTERS until " +
|
||||
"a person delivers it again or drops it",
|
||||
},
|
||||
// What the server said a consumer gave up on (novox/hq issue 330): copied into DEAD_LETTERS and
|
||||
// acknowledged. No max-deliver: a notice the controller could not copy is offered again, and said.
|
||||
{
|
||||
Name: ControllerName,
|
||||
Stream: DeadLetterNoticesStream,
|
||||
Push: true,
|
||||
AckWaitSeconds: 30,
|
||||
Why: "the controller copies each message a consumer gave up on into DEAD_LETTERS; no max-deliver, " +
|
||||
"because a notice it gave up on would lose the message it is about",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NoticesConsumer is the controller's consumer on DEAD_LETTER_NOTICES (novox/hq issue 330).
|
||||
func NoticesConsumer() Consumer {
|
||||
for _, c := range MeshConsumers() {
|
||||
if c.Stream == DeadLetterNoticesStream {
|
||||
return c
|
||||
}
|
||||
}
|
||||
panic("the mesh's consumers carry none on " + DeadLetterNoticesStream)
|
||||
}
|
||||
|
||||
// AssertServingConsumers are the controller's own consumers its serving cannot go without: all but the
|
||||
// one on DEAD_LETTER_NOTICES, which the keeper of dead letters asserts and retries by itself, so a fault
|
||||
// there never stops the controller serving (novox/hq issue 330).
|
||||
func AssertServingConsumers(e Ensurer) error {
|
||||
for _, c := range MeshConsumers() {
|
||||
if c.Stream == DeadLetterNoticesStream {
|
||||
continue
|
||||
}
|
||||
if err := e.EnsureConsumer(c); err != nil {
|
||||
return fmt.Errorf("asserting consumer %s on %s: %w", c.Name, c.Stream, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensurer is the part of a JetStream connection consumer assertion needs, narrow for the reason
|
||||
// Asserter is.
|
||||
type Ensurer interface {
|
||||
EnsureConsumer(c Consumer) error
|
||||
}
|
||||
|
||||
// AssertMeshConsumers brings the controller's own consumers into being, and says which one failed.
|
||||
//
|
||||
// After the streams, necessarily: a consumer on a stream that does not exist is refused, and the
|
||||
// refusal names the stream rather than the order.
|
||||
func AssertMeshConsumers(e Ensurer) error {
|
||||
for _, c := range MeshConsumers() {
|
||||
if err := e.EnsureConsumer(c); err != nil {
|
||||
return fmt.Errorf("asserting consumer %s on %s: %w", c.Name, c.Stream, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Vendored
-78
@@ -1,78 +0,0 @@
|
||||
# Composed by the mesh controller. Do not edit: the next composition overwrites it.
|
||||
# Accounts and permissions are derived from what each module declares and nothing
|
||||
# else (novox/hq ADR 0043, design 29 §2).
|
||||
|
||||
port: 4222
|
||||
http: 127.0.0.1:8222
|
||||
|
||||
tls {
|
||||
cert_file: "/tls/tls.crt"
|
||||
key_file: "/tls/tls.key"
|
||||
ca_file: "/tls/ca.crt"
|
||||
}
|
||||
|
||||
jetstream {
|
||||
store_dir: "/data"
|
||||
}
|
||||
|
||||
# The mesh's users, composed by the controller. Do not edit: the next
|
||||
# composition overwrites it. Permissions are derived from what each module
|
||||
# declares and nothing else (novox/hq ADR 0043, design 29 §2).
|
||||
|
||||
accounts {
|
||||
MESH {
|
||||
jetstream: enabled
|
||||
imports = [
|
||||
{ stream: { account: "$SYS", subject: "$SYS.ACCOUNT.MESH.CONNECT" }, to: "mesh.bus.connect" }
|
||||
{ stream: { account: "$SYS", subject: "$SYS.ACCOUNT.MESH.DISCONNECT" }, to: "mesh.bus.disconnect" }
|
||||
{ service: { account: "$SYS", subject: "$SYS.REQ.ACCOUNT.MESH.CONNZ" }, to: "mesh.bus.connz" }
|
||||
]
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.ACK.KV_mesh-delivery-builder_builds.mesh-controller-take-in.>", "$JS.ACK.KV_mesh-delivery-indexer_modules.mesh-controller-take-in.>", "$JS.API.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_composed.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.artifacts-gone", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.bucket-deleted", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
|
||||
publish: { allow: ["mesh.control.enrol"] }
|
||||
subscribe: { allow: ["_INBOX.enrol.one.>"] }
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.CREATE.KV_composed-one.>", "$JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_wants.*.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.CONSUMER.CREATE.KV_node-engine_have.*.$KV.node-engine_have.one.>", "$JS.API.CONSUMER.CREATE.OBJ_declaration-files-one.*.$O.declaration-files-one.C.*", "$JS.API.CONSUMER.DELETE.KV_composed-one.>", "$JS.API.CONSUMER.DELETE.KV_mesh-delivery-assigner_wants.>", "$JS.API.CONSUMER.DELETE.KV_node-engine_have.>", "$JS.API.CONSUMER.DELETE.OBJ_declaration-files-one.*", "$JS.API.CONSUMER.INFO.NODES.one", "$JS.API.DIRECT.GET.KV_composed-one.>", "$JS.API.DIRECT.GET.KV_mesh-delivery-assigner_wants.$KV.mesh-delivery-assigner_wants.want.one.>", "$JS.API.DIRECT.GET.KV_node-engine_have.$KV.node-engine_have.one.>", "$JS.API.DIRECT.GET.OBJ_declaration-files-one.$O.declaration-files-one.M.*", "$JS.API.STREAM.INFO.KV_composed-one", "$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_wants", "$JS.API.STREAM.INFO.KV_node-engine_have", "$JS.FC.KV_composed-one.>", "$JS.FC.KV_mesh-delivery-assigner_wants.>", "$JS.FC.KV_node-engine_have.>", "$JS.FC.OBJ_declaration-files-one.>", "$KV.node-engine_have.one.>", "$SRV.PING.node-tools.one", "mesh.control.one.>", "mesh.seat.node-backup.call.backed-up.one.node~one", "mesh.seat.node-backup.call.now.one.node~one", "mesh.seat.node-backup.tool.backed-up.one", "mesh.seat.node-backup.tool.now.one"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.mod.restic.event.backed-up", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.mesh-delivery-watchman", password: "$2a$11$wwwwwwwwwwwwwwwwwwwwww", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_mesh-delivery-watchman.>", "$JS.API.CONSUMER.INFO.EVENTS.one_mesh-delivery-watchman", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_mesh-delivery-watchman", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connz"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.mesh-delivery-watchman", "$SRV.INFO.mesh-delivery-watchman.>", "$SRV.PING", "$SRV.PING.mesh-delivery-watchman", "$SRV.PING.mesh-delivery-watchman.>", "$SRV.STATS", "$SRV.STATS.mesh-delivery-watchman", "$SRV.STATS.mesh-delivery-watchman.>", "_INBOX.one.mesh-delivery-watchman.>", "mesh.assignment.one.mesh-delivery-watchman", "mesh.bus.connect", "mesh.bus.disconnect", "mesh.mod.mesh-delivery-watchman.call.>", "mesh.mod.mesh-delivery-watchman.tool.>"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
|
||||
publish: { allow: ["$JS.API.STREAM.INFO.*", "$JS.API.STREAM.NAMES", "$JS.API.STREAM.SNAPSHOT.*", "$JS.SNAPSHOT.ACK.>"] }
|
||||
subscribe: { allow: ["_INBOX.one.nats.>"] }
|
||||
} }
|
||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||
subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
]
|
||||
}
|
||||
"$SYS" {
|
||||
exports = [
|
||||
{ stream: "$SYS.ACCOUNT.MESH.CONNECT", accounts: ["MESH"] }
|
||||
{ stream: "$SYS.ACCOUNT.MESH.DISCONNECT", accounts: ["MESH"] }
|
||||
{ service: "$SYS.REQ.ACCOUNT.MESH.CONNZ", accounts: ["MESH"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
-283
File diff suppressed because one or more lines are too long
@@ -1,111 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clause 7, issue 1945). The grants part writes
|
||||
// the list it composed as one object of its own object bucket, sealed to the key of the node holding mesh-broker, and
|
||||
// names it in one key of its own state `user-list`. The node-engine of that node follows the key when the switch says
|
||||
// so (clause 8), opens the object and writes the bus's user-list file.
|
||||
//
|
||||
// Who may do what, and nothing more:
|
||||
//
|
||||
// - the grants part writes its object bucket (create, put, purge of an object's old chunks, delete) and its own state
|
||||
// as before;
|
||||
// - the node-engine of the node holding mesh-broker reads that one key of the state, watched, and an object of the
|
||||
// bucket by its name: never the bucket's list of names, never a write, never another key of the grants part's state;
|
||||
// - no other principal touches either.
|
||||
|
||||
// GrantsUserListBucket, GrantsUserListKey, GrantsUserListObjects and GrantsUserListObjectStream are delivery's names,
|
||||
// here where the grants are composed.
|
||||
const (
|
||||
GrantsUserListBucket = delivery.GrantsUserListBucket
|
||||
GrantsUserListKey = delivery.GrantsUserListKey
|
||||
GrantsUserListObjects = delivery.GrantsUserListObjects
|
||||
GrantsUserListObjectStream = delivery.GrantsUserListObjectStream
|
||||
)
|
||||
|
||||
// GrantsUserListFollows is what the node-engine of the node holding mesh-broker may publish to follow the grants part's
|
||||
// list: the one key `user-list.sealed` read and watched (its own ordered consumer, filtered on that key alone), and an
|
||||
// object of the bucket by its name, as a node reads its declaration's files (ADR 0486 §2: never STREAM.INFO of the object
|
||||
// stream, which lists every name, and never STREAM.MSG.GET).
|
||||
func GrantsUserListFollows() []string {
|
||||
kv := "KV_" + GrantsUserListBucket
|
||||
key := "$KV." + GrantsUserListBucket + "." + GrantsUserListKey
|
||||
obj, s := GrantsUserListObjects, GrantsUserListObjectStream
|
||||
return []string{
|
||||
"$JS.API.DIRECT.GET." + kv + "." + key,
|
||||
"$JS.API.CONSUMER.CREATE." + kv + ".*." + key,
|
||||
"$JS.API.CONSUMER.DELETE." + kv + ".*",
|
||||
"$JS.API.STREAM.INFO." + kv,
|
||||
"$JS.FC." + kv + ".>",
|
||||
"$JS.API.DIRECT.GET." + s + ".$O." + obj + ".M.*",
|
||||
"$JS.API.CONSUMER.CREATE." + s + ".*.$O." + obj + ".C.*",
|
||||
"$JS.API.CONSUMER.DELETE." + s + ".*",
|
||||
"$JS.FC." + s + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// GrantsUserListWrites is what the grants part may publish to keep its object bucket: create it and read its info back,
|
||||
// put an object (its chunks and its metadata, the old chunks of a name purged), read an object's metadata, and delete
|
||||
// one. Its own state `user-list` it writes already, as every module writes its own.
|
||||
func GrantsUserListWrites() []string {
|
||||
obj, s := GrantsUserListObjects, GrantsUserListObjectStream
|
||||
return []string{
|
||||
"$JS.API.STREAM.CREATE." + s,
|
||||
"$JS.API.STREAM.INFO." + s,
|
||||
"$JS.API.STREAM.PURGE." + s,
|
||||
"$JS.API.DIRECT.GET." + s + ".>",
|
||||
"$JS.API.STREAM.MSG.GET." + s,
|
||||
"$O." + obj + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// grantsOwnObjects is the grants part writing its own object bucket, and nothing of another.
|
||||
func grantsOwnObjects(p Principal, pattern string) bool {
|
||||
return p.Kind == KindModule && p.Module == GrantsModule &&
|
||||
(pattern == "$O."+GrantsUserListObjects+".>" || pattern == "$JS.API.STREAM.CREATE."+GrantsUserListObjectStream)
|
||||
}
|
||||
|
||||
// **Named readers** (novox/hq ADR 0547 clause 9, progressive insight of 2026-10-26; issue 1945). A state named here is
|
||||
// read by the modules named and by no other, whatever another module's `reads` says: the vault's `bus-accounts` holds
|
||||
// every bus account's bcrypt hash, and from take-over the grants part alone may read it. A module's announcement may name
|
||||
// readers too (announce.State.Readers); both are honoured.
|
||||
const VaultBusAccounts = "mesh-vault.bus-accounts"
|
||||
|
||||
// NamedReaders are the states only the modules named may read, by `<owner>.<name>`.
|
||||
var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
func ReaderMay(module, read string) bool {
|
||||
readers, named := NamedReaders[read]
|
||||
return !named || slices.Contains(readers, module)
|
||||
}
|
||||
|
||||
// readsNamed splits a module's reads by the named readers: those it may read, and those named for others.
|
||||
func readsNamed(module string, reads []string) (granted, refused []string) {
|
||||
for _, r := range reads {
|
||||
if ReaderMay(module, strings.TrimSpace(r)) {
|
||||
granted = append(granted, r)
|
||||
} else {
|
||||
refused = append(refused, r)
|
||||
}
|
||||
}
|
||||
return granted, refused
|
||||
}
|
||||
|
||||
// withNamedReaders is d with the reads named for other modules moved to its refused reads.
|
||||
func withNamedReaders(d Declared) Declared {
|
||||
granted, refused := readsNamed(d.Module, d.Reads)
|
||||
if len(refused) == 0 {
|
||||
return d
|
||||
}
|
||||
d.Reads = granted
|
||||
d.ReadsRefused = append(append([]string(nil), d.ReadsRefused...), refused...)
|
||||
return d
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0547 clause 7 on a real server (issue 1945): with the list this composer writes, the grants part
|
||||
// creates its object bucket and its state, puts the sealed list and names it in `user-list.sealed`; the node-engine of
|
||||
// the node holding mesh-broker watches that one key, reads the object by its name and opens it with its key, and may
|
||||
// neither list the bucket, nor read another key of the state, nor write; another node reads nothing of either.
|
||||
func TestTheBusNodeFollowsTheGrantsPartsSealedListUnderTheComposedGrants(t *testing.T) {
|
||||
records := Records{
|
||||
Nodes: []string{"novox", "ace"},
|
||||
Assigned: map[string][]Declared{
|
||||
"novox": {{Module: "nats", SnapshotsTheBus: true},
|
||||
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "user-list"}}}},
|
||||
"ace": {{Module: "plex"}},
|
||||
},
|
||||
}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = "pw-" + users[i].Username()
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
conf := filepath.Join(dir, "server.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: \""+dir+"\" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed list is not the server's: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(30 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
dial := func(user string) *nats.Conn {
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw-"+user), nats.CustomInboxPrefix("_INBOX."+user))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", user, err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// The grants part: its state and its object bucket, the sealed list put and named.
|
||||
grants, err := jetstream.New(dial("novox." + GrantsModule))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Its state is made as the grants part makes a declared state, by its stream's name (CreateKeyValue would ask the
|
||||
// account's info, which no module is granted).
|
||||
if _, err := grants.CreateStream(ctx, jetstream.StreamConfig{Name: "KV_" + GrantsUserListBucket,
|
||||
Subjects: []string{"$KV." + GrantsUserListBucket + ".>"}, MaxMsgsPerSubject: 1, AllowDirect: true,
|
||||
AllowRollup: true, DenyDelete: true, Discard: jetstream.DiscardNew}); err != nil {
|
||||
t.Fatalf("the grants part's state: %v", err)
|
||||
}
|
||||
kv, err := grants.KeyValue(ctx, GrantsUserListBucket)
|
||||
if err != nil {
|
||||
t.Fatalf("the grants part binds its state: %v", err)
|
||||
}
|
||||
objects, err := grants.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: GrantsUserListObjects})
|
||||
if err != nil {
|
||||
t.Fatalf("the grants part's object bucket: %v", err)
|
||||
}
|
||||
node, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := []byte(accounts)
|
||||
digest := delivery.UserListDigest(list)
|
||||
object, err := delivery.SealUserList(list, node.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := delivery.UserListObjectName(digest)
|
||||
for range 2 { // put twice: a second put of a name purges its old chunks
|
||||
if _, err := objects.PutBytes(ctx, name, object); err != nil {
|
||||
t.Fatalf("the grants part put its object: %v", err)
|
||||
}
|
||||
}
|
||||
value, err := delivery.EncodeGrantsUserList(delivery.GrantsUserList{Follow: delivery.FollowGrants, Digest: digest,
|
||||
Object: name, SealedTo: delivery.SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: len(users)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, GrantsUserListKey, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, "list", []byte(`{"v":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The bus node's engine: the key watched, the object by name (as mesh-host's link reads a declaration's file).
|
||||
busNode := dial("node.novox")
|
||||
js, err := busNode.JetStream(nats.MaxWait(5 * time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys, err := js.KeyValue(GrantsUserListBucket)
|
||||
if err != nil {
|
||||
t.Fatalf("the bus node binds the grants part's state: %v", err)
|
||||
}
|
||||
w, err := keys.Watch(GrantsUserListKey)
|
||||
if err != nil {
|
||||
t.Fatalf("the bus node watches the key: %v", err)
|
||||
}
|
||||
defer w.Stop()
|
||||
var got delivery.GrantsUserList
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil {
|
||||
t.Fatal("the watch found no value")
|
||||
}
|
||||
if got, err = delivery.DecodeGrantsUserList(e.Value()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("the watch said nothing")
|
||||
}
|
||||
read := func(js nats.JetStreamContext, name string) ([]byte, error) {
|
||||
meta, err := js.GetLastMsg(GrantsUserListObjectStream, "$O."+GrantsUserListObjects+".M."+
|
||||
base64.URLEncoding.EncodeToString([]byte(name)), nats.DirectGet())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info struct {
|
||||
NUID string `json:"nuid"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
if err := json.Unmarshal(meta.Data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub, err := js.SubscribeSync("$O."+GrantsUserListObjects+".C."+info.NUID, nats.OrderedConsumer(),
|
||||
nats.BindStream(GrantsUserListObjectStream))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
var body []byte
|
||||
for len(body) < info.Size {
|
||||
m, err := sub.NextMsg(5 * time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = append(body, m.Data...)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
body, err := read(js, got.Object)
|
||||
if err != nil {
|
||||
t.Fatalf("the bus node reads the object by its name: %v", err)
|
||||
}
|
||||
opened, err := delivery.OpenUserList(body, got.Digest, func(b seal.Box) ([]byte, error) {
|
||||
s, err := seal.Open(b, node.PrivateKey)
|
||||
return []byte(s), err
|
||||
})
|
||||
if err != nil || !bytes.Equal(opened, list) {
|
||||
t.Fatalf("the bus node opened %d bytes, %v", len(opened), err)
|
||||
}
|
||||
|
||||
// And nothing beyond: no other key, no list of the bucket, no write.
|
||||
if _, err := keys.Get("list"); err == nil {
|
||||
t.Error("the bus node read another key of the grants part's state")
|
||||
}
|
||||
if _, err := js.StreamInfo(GrantsUserListObjectStream); err == nil {
|
||||
t.Error("the bus node read the object stream's info, which lists every name")
|
||||
}
|
||||
if _, err := js.Publish("$O."+GrantsUserListObjects+".M.x", []byte("x")); err == nil {
|
||||
t.Error("the bus node wrote the grants part's bucket")
|
||||
}
|
||||
// Another node reads nothing of it.
|
||||
other, err := dial("node.ace").JetStream(nats.MaxWait(2 * time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := read(other, got.Object); err == nil {
|
||||
t.Error("another node read the object")
|
||||
}
|
||||
if _, err := other.GetLastMsg("KV_"+GrantsUserListBucket, "$KV."+GrantsUserListBucket+"."+GrantsUserListKey, nats.DirectGet()); err == nil {
|
||||
t.Errorf("another node read the key: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Every user the composed file should contain, derived from what the mesh knows.
|
||||
//
|
||||
// **The list is derived, never kept.** A stored user list would be a second account of who may
|
||||
// reach the bus, able to disagree with the records it came from — and the disagreement would be
|
||||
// invisible, because both would look internally consistent. So this is a pure function of the
|
||||
// mesh's records, run again every time the file is written.
|
||||
//
|
||||
// Records are mirrored into this package's own types rather than imported from the catalogue, for
|
||||
// the reason DeclaredSeat is: composing authority is a different job from parsing a manifest, and
|
||||
// this package stays free of the other's types so a change to a manifest field cannot quietly widen
|
||||
// a permission.
|
||||
|
||||
// Declared is one module on one node, as composing its authority needs it.
|
||||
type Declared struct {
|
||||
Module string
|
||||
Emits []string
|
||||
Consumes []string
|
||||
Serves []string
|
||||
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
|
||||
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
|
||||
// which is right: those seats are about who does a job, not about who may say what.
|
||||
Holds []Seat
|
||||
// Uses are the seats this module sends to.
|
||||
Uses []Seat
|
||||
// Issued are the tools that answer a credential its software issued, one per offer that says so (novox/hq
|
||||
// ADR 0305): the controller is granted to ask each in its own name, and no other tool so.
|
||||
Issued []string
|
||||
// Watches are the seats whose events it consumes.
|
||||
Watches []Seat
|
||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||
Invokes []string
|
||||
// Waits are the event families a session may wait on through the tool runner, the tool runner's module's
|
||||
// alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer.
|
||||
Waits []Wait
|
||||
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
||||
State []Bucket
|
||||
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
||||
Logs []Log
|
||||
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436
|
||||
// §1, §2, §6): one durable consumer per entry, named `<node>_<module>_<process id>`.
|
||||
Queues []QueueOwner
|
||||
// Hears are the module streams it hears on this machine, each with the subjects it was granted
|
||||
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
|
||||
// or not, so its membership is issued again when a grant changes.
|
||||
Hears []Heard
|
||||
HearsAStream bool
|
||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||
Reads []string
|
||||
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
|
||||
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
|
||||
ReadsRefused []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
|
||||
KeyedReads []KeyedRead
|
||||
// NoAccount says the module declares no own secret named broker, so no account could ever be
|
||||
// delivered to it and nothing can connect as it (novox/hq issue 195). Said in the negative so a
|
||||
// record that does not say is composed as it always was.
|
||||
NoAccount bool
|
||||
// SnapshotsTheBus says the module holds mesh-broker — it is the bus — and so is the one module
|
||||
// granted the snapshot API, to copy the bus's streams for the night's backup (novox/hq ADR 0235).
|
||||
SnapshotsTheBus bool
|
||||
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
|
||||
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
|
||||
Checks []string
|
||||
// Loads says the module has a compiled bundle whose entrypoints the machine's tool runtime loads and runs
|
||||
// (novox/hq ADR 0175; issue 1329): a helper that reaches the bus on the runner's account, so the runner
|
||||
// must carry the module although it declares no tools.
|
||||
Loads bool
|
||||
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||
RunsAs string
|
||||
// ProvisionsNode says the module runs the machine's provisioner client: it claims NodeProvisionerSeat and
|
||||
// declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57
|
||||
// P3). The client is given a bus account of its own, never the module's or the tool runner's.
|
||||
ProvisionsNode bool
|
||||
// Processes are this module's own processes that declare bus (novox/hq ADR 0436 §1), each getting a
|
||||
// principal of its own: granted exactly what its bus says, and nothing of this module's other grants —
|
||||
// composed whether or not the module itself has an account (NoAccount), because a process account is
|
||||
// delivered in its own secret, broker-<process id>, never the module's `broker`.
|
||||
Processes []ProcessPrincipal
|
||||
}
|
||||
|
||||
// ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker
|
||||
// composes its own account from: its own emits, its own work-queue consumers, its own state, whether it
|
||||
// reads the router's records of its module's own asks, and the tools and seat verbs it calls.
|
||||
type ProcessPrincipal struct {
|
||||
// Process is the process resource's id, naming the account <node>.<module>.<process id>.
|
||||
Process string
|
||||
// Emits are the events this process may publish, named as the module's own Emits are.
|
||||
Emits []string
|
||||
// Queues are the module's own work-queue streams this process is the one consumer of.
|
||||
Queues []QueueOwner
|
||||
// State is the module's own state, by local name, this process reads and writes.
|
||||
State []string
|
||||
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
|
||||
// whichever seats the module uses that keep them (the router's, operator-channel, among others).
|
||||
Asks bool
|
||||
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
|
||||
// seat:<seat>.<verb>, or * for every tool on the mesh.
|
||||
Invokes []string
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
type Records struct {
|
||||
// Nodes is every machine the mesh knows. Each gets a host user.
|
||||
Nodes []string
|
||||
// Assigned is the modules on each node, as they declare themselves.
|
||||
Assigned map[string][]Declared
|
||||
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
|
||||
// answer goes to is scoped to the token and a shared one is one machine reading another's
|
||||
// sealed credentials (design 25 §6).
|
||||
Enrolling []string
|
||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||
People map[string][]string
|
||||
// Interchangeable is each module whose definition says its instances are the same anywhere
|
||||
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
||||
Interchangeable map[string]bool
|
||||
// RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an
|
||||
// account agents run as, judged unable to become root by its node-engine, and serves no login shell
|
||||
// execute. A machine absent is not free: no record of a pass is no pass.
|
||||
RootFree map[string]bool
|
||||
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
|
||||
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
|
||||
Announced map[string]announce.States
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
//
|
||||
// The controller is always first and always present: a mesh whose own controller is not in the file
|
||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
var checks []string
|
||||
bus := false
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.Module == controllerModule {
|
||||
witness = true
|
||||
}
|
||||
if d.SnapshotsTheBus {
|
||||
bus = true
|
||||
}
|
||||
checks = append(checks, d.Checks...)
|
||||
}
|
||||
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.
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.ProvisionsNode {
|
||||
out = append(out, Principal{Kind: KindProvisioner, Node: node, Module: d.Module,
|
||||
BoundBuckets: boundBucketsOf(r)})
|
||||
break
|
||||
}
|
||||
}
|
||||
// **Where the runtime is assigned, the machine gets one runtime principal in place of the
|
||||
// runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the
|
||||
// node: its serving grants are the union of theirs. Every other module keeps its own
|
||||
// principal — a module still serving tools from its own container holds its own
|
||||
// credential until it moves, and the two serve side by side in the meantime.
|
||||
runtimeHere := false
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.Module == RuntimeModule {
|
||||
runtimeHere = true
|
||||
}
|
||||
}
|
||||
for _, d := range r.Assigned[node] {
|
||||
// A state with named readers is read by them alone (ADR 0547 clause 9): a read named for another module is
|
||||
// granted nothing, here and in the runtime carrying the module.
|
||||
d = withNamedReaders(d)
|
||||
// **A process account is its own, delivered in its own secret** (broker-<process id>, novox/hq
|
||||
// ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module
|
||||
// itself has an account, and is never carried by the machine's runtime, which runs as the
|
||||
// operator's account (ADR 0436 §5): added here, before either of those is judged.
|
||||
for _, pr := range d.Processes {
|
||||
out = append(out, Principal{
|
||||
Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process,
|
||||
Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks,
|
||||
Invokes: pr.Invokes,
|
||||
})
|
||||
}
|
||||
if runtimeHere && d.Module == RuntimeModule {
|
||||
continue
|
||||
}
|
||||
// **A module with nowhere to read an account is no user** (novox/hq issue 195). `module
|
||||
// issue` refuses it one (issue 078: an account nothing reads is an orphan), so its user
|
||||
// could only ever be left out of the file for want of a password — and every such module
|
||||
// was named, on every status and plan, as a credential the mesh had not minted. Where the
|
||||
// runtime is, it speaks for the module; where it is not, the module cannot speak at all,
|
||||
// and a user would not change that.
|
||||
if d.NoAccount {
|
||||
continue
|
||||
}
|
||||
out = append(out, Principal{
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
|
||||
})
|
||||
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
}
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
// account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it.
|
||||
if holdsSeat(d, VaultSeat) {
|
||||
out[len(out)-1].AsksTheDesk = true
|
||||
out[len(out)-1].AsksIssued = issuedOf(r)
|
||||
}
|
||||
}
|
||||
if runtimeHere {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs == "" && runnerServes(d) {
|
||||
d = withNamedReaders(d)
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
}
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried})
|
||||
}
|
||||
}
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node})
|
||||
}
|
||||
for _, person := range sortedNames(r.People) {
|
||||
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
|
||||
if administers(p.Invokes) {
|
||||
// The operator's hand path writes each node's compositions (ADR 0539 clause 10).
|
||||
p.FilesFor = sortedCopy(r.Nodes)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
// Refused here rather than discovered by the server. Two users with one name is a file the
|
||||
// server reads as one of them, and which one depends on the order — so a module assigned to a
|
||||
// node twice, or a person named after nothing, is a composition that must not be written.
|
||||
seen := map[string]string{}
|
||||
for _, p := range out {
|
||||
name := p.Username()
|
||||
if name == "" || name == "." {
|
||||
return nil, fmt.Errorf("a %s user has no name, so nothing could authenticate as it", p.Kind)
|
||||
}
|
||||
if first, already := seen[name]; already {
|
||||
return nil, fmt.Errorf(
|
||||
"two users would be called %q (a %s and a %s): the server would read the file as "+
|
||||
"one of them, and which one depends on the order", name, first, p.Kind)
|
||||
}
|
||||
seen[name] = string(p.Kind)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithPasswords fills each user's hash from what the mesh minted, and says which users have none.
|
||||
//
|
||||
// **Separated from Users because they fail differently.** A user missing from the records is a bug
|
||||
// in deriving them; a user with no password is a step that has not happened yet — a module assigned
|
||||
// but never given a credential, a node enrolled before this existed. The second is ordinary and its
|
||||
// remedy is to mint one, so it is named rather than returned as an error, and the caller decides
|
||||
// whether a partial composition is worth writing.
|
||||
func WithPasswords(principals []Principal, hashes map[string]string) (filled []Principal, missing []string) {
|
||||
for _, p := range principals {
|
||||
hash, ok := hashes[p.Username()]
|
||||
if !ok || hash == "" {
|
||||
missing = append(missing, p.Username())
|
||||
continue
|
||||
}
|
||||
p.PasswordHash = hash
|
||||
filled = append(filled, p)
|
||||
}
|
||||
return filled, missing
|
||||
}
|
||||
|
||||
func sortedCopy(in []string) []string {
|
||||
out := append([]string(nil), in...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedNames(in map[string][]string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for k := range in {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// controllerModule is the controller's module: the machine assigned it witnesses its upgrades.
|
||||
const controllerModule = "mesh-controller"
|
||||
|
||||
// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller
|
||||
// asks the operator through it like any other user, and is granted what its declaration names for a caller.
|
||||
// None while nothing holds it.
|
||||
// issuedOf is every tool of a module assigned anywhere that answers a credential its software issued, as
|
||||
// `<module>.<tool>` (novox/hq ADR 0305): what the controller asks in its own name, and nothing beside.
|
||||
func issuedOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, t := range d.Issued {
|
||||
if key := d.Module + "." + t; !seen[key] {
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func asksSeatOf(r Records) []Seat {
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == AsksSeat && namesVerb(s.ByCaller, "ask") {
|
||||
seat := s
|
||||
seat.Kind, seat.Capabilities = "", nil
|
||||
return []Seat{seat}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||
const VaultSeat = "mesh-vault"
|
||||
|
||||
func holdsSeat(d Declared, seat string) bool {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == seat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
|
||||
// is granted to ask it in its own name. None while no holder serves them.
|
||||
func vaultVerbsOf(r Records) []string { return servedOf(r, VerbsTheControllerAsksTheVault) }
|
||||
|
||||
// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name.
|
||||
func servedOf(r Records, asked []SeatVerb) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
for _, v := range asked {
|
||||
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
|
||||
seen[v.Verb] = true
|
||||
out = append(out, v.Verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// servesMoveIn says the vault's holder serves move-in, as the records say (issue 676): what lets each machine's
|
||||
// node-engine ask it in its own name.
|
||||
func servesMoveIn(r Records) bool { return servesVerb(r, VaultMoveIn) }
|
||||
|
||||
// servesVerb says a holder of verb's seat serves it, as the records say.
|
||||
func servesVerb(r Records, verb SeatVerb) bool {
|
||||
for _, node := range r.Nodes {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == verb.Seat && slices.Contains(s.Serves, verb.Verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// boundBucketsOf is every provider's bucket of entries, `<module>_bound` (mesh-sdk go/provider, issue 678): the state
|
||||
// named bound of every module placed anywhere, once each, in name order. A consumer on any machine may be bound to
|
||||
// it, so each machine's provisioner client reads its own keys of each (issue 679).
|
||||
func boundBucketsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
if b.Name == BoundState {
|
||||
seen[b.Bucket()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for b := range seen {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1,
|
||||
// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on,
|
||||
// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and
|
||||
// carrying it would hand every session on the machine the runner's account with that module's state, seat
|
||||
// traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it
|
||||
// stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not
|
||||
// the test: a module with tools and its own credential is still carried (ADR 0175).
|
||||
func runnerServes(d Declared) bool {
|
||||
return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0
|
||||
}
|
||||
|
||||
// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name
|
||||
// order (novox/hq ADR 0547 §5, issue 1790): `KV_<module>_<state>`, `LOG_<module>_<log>` and each module stream's own
|
||||
// name. What the grants part may create, and nothing beside.
|
||||
func declaredStreamsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
seen["KV_"+b.Bucket()] = true
|
||||
}
|
||||
for _, l := range d.Logs {
|
||||
seen[l.Stream()] = true
|
||||
}
|
||||
for _, s := range d.Streams {
|
||||
seen[s.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a session may wait on through the mesh MCP server (novox/hq ADR 0367).
|
||||
//
|
||||
// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its
|
||||
// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain
|
||||
// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its
|
||||
// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one
|
||||
// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest
|
||||
// adds a family (§6).
|
||||
|
||||
// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the
|
||||
// emitter — a seat or a module — and the event, as the inventory resolved them.
|
||||
type Wait struct {
|
||||
// Name is the family as the manifest names it, `<emitter>.<event>`: what a session asks for.
|
||||
Name string
|
||||
Emitter string
|
||||
// Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121).
|
||||
Seat bool
|
||||
// Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a
|
||||
// seat that names it last (ADR 0259 §3).
|
||||
Event string
|
||||
// Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming
|
||||
// its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited
|
||||
// on with it given, and refused without.
|
||||
Last string
|
||||
}
|
||||
|
||||
// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a
|
||||
// wildcard, a dot inside a name or an empty token would widen the grant.
|
||||
func WaitSubject(w Wait) (string, error) {
|
||||
if !safeSubject.MatchString(w.Emitter) {
|
||||
return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter)
|
||||
}
|
||||
for _, t := range strings.Split(w.Event, ".") {
|
||||
if !safeSubject.MatchString(t) {
|
||||
return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+
|
||||
"(novox/hq ADR 0367 §2)", w.Event)
|
||||
}
|
||||
}
|
||||
if w.Last != "" && !strings.Contains(w.Event, ".") {
|
||||
return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+
|
||||
"without it no event would ever be heard (novox/hq ADR 0367 §5)",
|
||||
w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last)
|
||||
}
|
||||
if w.Seat {
|
||||
return "mesh.seat." + w.Emitter + ".event." + w.Event, nil
|
||||
}
|
||||
return "mesh.mod." + w.Emitter + ".event." + w.Event, nil
|
||||
}
|
||||
|
||||
// waitGrants is the subscription the tool runner's account is granted for its module's families, and an
|
||||
// error for a carried module other than the tool runner's that declares any (ADR 0367 §2).
|
||||
func waitGrants(carries []Declared) ([]string, error) {
|
||||
var sub []string
|
||||
for _, d := range carries {
|
||||
if len(d.Waits) == 0 {
|
||||
continue
|
||||
}
|
||||
if d.Module != RuntimeModule {
|
||||
return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+
|
||||
"does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule)
|
||||
}
|
||||
for _, w := range d.Waits {
|
||||
s, err := WaitSubject(w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub = append(sub, s)
|
||||
}
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's.
|
||||
func waitsIssuedFor(d Declared) []WaitIssued {
|
||||
if d.Module != RuntimeModule {
|
||||
return nil
|
||||
}
|
||||
var out []WaitIssued
|
||||
for _, w := range d.Waits {
|
||||
s, err := WaitSubject(w)
|
||||
if err != nil {
|
||||
continue // refused where the account is composed, which names it
|
||||
}
|
||||
out = append(out, WaitIssued{Event: w.Name, Subject: s})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The writers table (novox/hq to-be 45 §1, ADR 0227 rule 1), compiled in.
|
||||
//
|
||||
// **Every kind of state the core keeps has one writer; anyone else asks it.** The table is the design's,
|
||||
// row for row, with what each row writes on the bus where it writes there. Two things read it: the
|
||||
// composition of every principal's grants (PermissionsFor), which **refuses a grant that lets a
|
||||
// principal publish on a subject another writes** — so a second writer cannot be granted by accident,
|
||||
// and the composition says which state and whose — and the test beside it, which walks the rows
|
||||
// against the design's list and the composition of a whole mesh. Changing a writer is a change to
|
||||
// this table, through a decision.
|
||||
|
||||
// WriterRow is one row of the writers table.
|
||||
type WriterRow struct {
|
||||
State string
|
||||
Writer string
|
||||
KeptIn string
|
||||
Others string
|
||||
// Subjects are the bus subjects a write of this state is a publish to; none for state kept off the
|
||||
// bus (the controller's store, a module's own) or not built yet.
|
||||
Subjects []string
|
||||
// Writes says a principal granted a publish pattern overlapping Subjects is this state's writer —
|
||||
// given the pattern, because a machine writes its own report and no other's.
|
||||
Writes func(p Principal, pattern string) bool
|
||||
// Shared says why more than one principal may publish here; empty for one writer.
|
||||
Shared string
|
||||
}
|
||||
|
||||
// isController, and the other writers' tests, are who a row's writer is as a principal.
|
||||
func isController(p Principal, _ string) bool { return p.Kind == KindController }
|
||||
|
||||
// createsDeclared is the controller, or the grants part creating one stream a manifest declares, by its own name: the
|
||||
// create alone, never an update or a delete (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54).
|
||||
func createsDeclared(p Principal, pattern string) bool {
|
||||
if isController(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
if ok && p.Kind == KindModule && p.Module == GrantsModule && name == GrantsUserListObjectStream {
|
||||
return true
|
||||
}
|
||||
return ok && p.Kind == KindModule && p.Module == GrantsModule && slices.Contains(p.CreatesStreams, name) &&
|
||||
safeSubject.MatchString(name)
|
||||
}
|
||||
|
||||
// ownMachine is a node writing a subject whose third token is its own name, and no wildcard there.
|
||||
func ownMachine(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node
|
||||
}
|
||||
|
||||
// holdsSeatOf is a principal holding the seat a `mesh.seat.<seat>.…` pattern names: its module's own
|
||||
// principal, or the node tools carrying a module that holds it (ADR 0175, the runtime is its modules).
|
||||
func holdsSeatOf(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
seat := tokens[2]
|
||||
holds := func(seats []Seat) bool {
|
||||
return slices.ContainsFunc(seats, func(s Seat) bool { return s.Name == seat })
|
||||
}
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
return holds(p.Holds)
|
||||
case KindNodeTools:
|
||||
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return holds(d.Holds) })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownModule is a module publishing under its own name, or the node tools carrying it.
|
||||
func ownModule(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
module := tokens[2]
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
return p.Module == module
|
||||
case KindNodeTools:
|
||||
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
|
||||
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
|
||||
func ownAnnouncer(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
if len(tokens) < 3 {
|
||||
return false
|
||||
}
|
||||
switch p.Kind {
|
||||
case KindController:
|
||||
return tokens[2] == ControllerSeat
|
||||
case KindProcess:
|
||||
return p.Module == tokens[2]
|
||||
}
|
||||
return ownModule(p, pattern)
|
||||
}
|
||||
|
||||
// ownProvisioner is a machine's provisioner client saying a report about its own machine: the third token of
|
||||
// `prov.applied.<node>.…` is its machine (novox/hq ADR 0349 §4, design 57 P3).
|
||||
func ownProvisioner(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
return p.Kind == KindProvisioner && len(tokens) > 3 && tokens[2] == p.Node
|
||||
}
|
||||
|
||||
// ownNodeKeys is a node writing the have keys of its own node: `$KV.<bucket>.<node>.…`, the node in the third token
|
||||
// and no wildcard there (novox/hq ADR 0429 §1).
|
||||
func ownNodeKeys(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
return p.Kind == KindNode && len(tokens) > 2 && tokens[2] == p.Node
|
||||
}
|
||||
|
||||
// kvOf is a bucket's write subjects.
|
||||
func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
|
||||
|
||||
// WritersTable is to-be 45 §1, in its order.
|
||||
var WritersTable = []WriterRow{
|
||||
{State: "a machine's declaration", Writer: "controller (lease holder)", KeptIn: "the bus, last per subject",
|
||||
Others: "read", Subjects: []string{"mesh.node.*.declare"}, Writes: isController},
|
||||
// The operator's hand-over of a directory used as found, asked of the machine's engine at the controller's
|
||||
// terminal (novox/hq issue 356). One publisher; and because a responder can still reach the subject through a
|
||||
// reply, the ask is signed with the mesh's key and the engine verifies it — the row bounds who is granted the
|
||||
// publish, the signature who is believed.
|
||||
{State: "a hand-over asked of a machine", Writer: "controller, at its terminal", KeptIn: "the machine, beside its state",
|
||||
Others: "the engine verifies the mesh's signature and records it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.hand-over"}, Writes: isController},
|
||||
// The operator asking a machine's engine for a fresh search for setuid programs, at the controller's
|
||||
// terminal (novox/hq issue 361): signed as a hand-over is, under a signing context of its own.
|
||||
{State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal",
|
||||
KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController},
|
||||
// A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a
|
||||
// hand-over is, under a signing context of its own. It changes nothing on the machine.
|
||||
{State: "a judgement of a machine's directories asked of it", Writer: "controller",
|
||||
KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController},
|
||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
||||
// machine, inside the grant it already had (`mesh.control.<its own>.>`). And a line mesh-cli was given
|
||||
// on the machine (novox/hq ADR 0272): the node is what the controller judges the terminal by, so that
|
||||
// subject is this machine's engine's alone.
|
||||
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
|
||||
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
|
||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
|
||||
// The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder.
|
||||
{State: "the merges mesh-delivery announced", Writer: "controller (lease holder)",
|
||||
KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration",
|
||||
Subjects: kvOf(DeliveryMergesBucket), Writes: isController},
|
||||
// And the compositions (novox/hq ADR 0539 clauses 2 and 4): each node's in its own bucket, which only that node's
|
||||
// node-engine reads, and each digest with the judgement of its move, which the assigner reads.
|
||||
{State: "the compositions and their digests", Writer: "controller (lease holder), compare-and-set",
|
||||
KeptIn: "key-value composed-<node> for each node, and " + delivery.ControllerComposedBucket,
|
||||
Others: "a node-engine reads its own node's; the assigner reads the digests",
|
||||
Subjects: kvOf(delivery.ControllerComposedBucket), Writes: isControllerOrTheHandPath},
|
||||
{State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " +
|
||||
ConditionHistoryBucket + "); how often each was observed, when last and its newest evidence in the " +
|
||||
"controller's store, so a look that changes nothing writes no bucket", Others: "raise or clear only through observations the controller reads",
|
||||
Subjects: append(kvOf(ConditionsBucket), kvOf(ConditionHistoryBucket)...), Writes: isController},
|
||||
{State: "calls and their outcomes", Writer: "controller", KeptIn: "key-value " + CallsBucket,
|
||||
Others: "read by id", Subjects: kvOf(CallsBucket), Writes: isController},
|
||||
{State: "the hand-act log", Writer: "controller, through the verbs that act", KeptIn: "key-value " + HandActsBucket,
|
||||
Others: "—", Subjects: kvOf(HandActsBucket), Writes: isController},
|
||||
// What the healers did (novox/hq to-be 45 §7, Phase 3): the controller's alone, in its store — the
|
||||
// budgets and the mesh-wide brake are counted from it, so a controller restarting cannot reset them.
|
||||
{State: "the healers' acts and their brake", Writer: "controller (lease holder), each act begun before it is made",
|
||||
KeptIn: "the controller's store", Others: "read through healers; each act said as healer-acted and in its condition's tried"},
|
||||
{State: "stream definitions and bus permissions", Writer: "controller", KeptIn: "the bus", Others: "—",
|
||||
// A stream's definition, and a durable consumer's by the API that names it so. Not every
|
||||
// consumer create: a module watching its own bucket makes and deletes an ordered consumer on the
|
||||
// bucket's stream (ADR 0201), which defines nothing the mesh keeps.
|
||||
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
|
||||
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
|
||||
Writes: createsDeclared,
|
||||
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54)"},
|
||||
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
|
||||
Others: "the controller asks",
|
||||
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
|
||||
Writes: holdsSeatOf,
|
||||
Shared: "every machine holding the build seat answers the asks it took; each outcome names its ask"},
|
||||
// What a module keeps and who may read it (novox/hq ADR 0495 clauses 2 and 3, issue 1790): said by the module, kept by
|
||||
// the controller.
|
||||
{State: "a module's states announced", Writer: "the module itself, from its code, under its own name",
|
||||
KeptIn: "the bus (EVENTS)", Others: "the controller keeps the last of each and grants readers from it",
|
||||
Subjects: []string{"mesh.mod.*.event.states"}, Writes: ownAnnouncer},
|
||||
{State: "the states announced", Writer: "controller, from each module's announcement",
|
||||
KeptIn: "key-value " + StatesAnnouncedBucket, Others: "the composition of the bus's users reads it",
|
||||
Subjects: kvOf(StatesAnnouncedBucket), Writes: isController},
|
||||
{State: "a merge announced", Writer: "one announcer per forge (the hook, or the poll when the hook is absent — never both)",
|
||||
KeptIn: "the bus", Others: "—", Subjects: []string{"mesh.mod.*.event.pull.merged"}, Writes: ownModule},
|
||||
// A pull request's head, before it merges (novox/hq to-be 45 §9): the same one announcer.
|
||||
{State: "a pull request's head announced", Writer: "the forge's announcer, the one that announces its merges",
|
||||
KeptIn: "the bus", Others: "the controller asks the build seat to check it", Subjects: []string{"mesh.mod.*.event.pull.updated"},
|
||||
Writes: ownModule},
|
||||
{State: "a pull request's merge check", Writer: "the build seat's holder that ran it, said as the controller's `checked`",
|
||||
KeptIn: "the bus", Others: "the forge's holder sets it as the pull request's status"},
|
||||
{State: "a provider's standing", Writer: "the provider", KeptIn: "the provider's events",
|
||||
Others: "the controller keeps the newest word as a condition",
|
||||
Subjects: []string{"mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered",
|
||||
"mesh.mod.*.event.provisioner.retirement"},
|
||||
Writes: ownModule},
|
||||
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
|
||||
Others: "—"},
|
||||
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
|
||||
Others: "the build seat reads"},
|
||||
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
|
||||
// and delivered again or dropped only through its verb, with why.
|
||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
||||
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
|
||||
{State: "the assignments mirror", Writer: "controller (lease holder): the verbs that assign, and the sends that record what runs; " +
|
||||
"a mirror of its store only until the assignments are moved to the bus (novox/hq ADR 0408)",
|
||||
KeptIn: "key-value " + AssignmentsBucket + ", the record once moved", Others: "read through `reads` as mesh-controller." + MirrorAssignments,
|
||||
Subjects: kvOf(AssignmentsBucket), Writes: isController},
|
||||
// The chain (novox/hq ADR 0408): builds and rollouts beside the assignments above, which keep their bucket. Not
|
||||
// mirrors of the store but the record, written by the lease holder by compare-and-set and removed by nobody.
|
||||
{State: "the builds of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision",
|
||||
KeptIn: "key-value " + BuildsBucket + ", sixty-four revisions of each key, none removed",
|
||||
Others: "read through `reads` as mesh-controller." + ChainBuilds, Subjects: kvOf(BuildsBucket), Writes: isController},
|
||||
{State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision",
|
||||
KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed",
|
||||
Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController},
|
||||
// The grants issued (novox/hq ADR 0528 clause 2, issue 1394): the controller's, read by the assigner for its sixth rule.
|
||||
{State: "the grants issued", Writer: "controller (lease holder), once the bus's user list carrying a module's manifest at a fingerprint was sent",
|
||||
KeptIn: "key-value " + GrantsBucket + ", one key a module's fingerprint, none removed",
|
||||
Others: "read through `reads` as mesh-controller." + ControllerGrants, Subjects: kvOf(GrantsBucket), Writes: isController},
|
||||
// What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under
|
||||
// its own node's keys, never by the controller; the view and the watchman read it.
|
||||
{State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `<node>.>`",
|
||||
KeptIn: "key-value " + delivery.HaveBucket + ", twenty revisions of each key",
|
||||
Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys},
|
||||
// The contents of a declaration's files over 64 KiB (novox/hq ADR 0486 §2): the controller alone writes the bucket, each
|
||||
// object under the digest of its content; a node-engine reads one by its name, with no list and no write.
|
||||
{State: "a declaration's large files", Writer: "controller, each object under the sha256 digest of its content; the " +
|
||||
"operator's account on release 6's hand path alone (ADR 0539 clause 10)",
|
||||
KeptIn: "the object bucket " + DeclarationFilesBucket + "-<node> of each node, an object removed a day after the " +
|
||||
"node's latest declaration no longer names it",
|
||||
Others: "a node-engine reads an object of its own bucket by its name and may neither list the bucket nor write it",
|
||||
Subjects: []string{"$O.*.>"}, Writes: func(p Principal, pattern string) bool {
|
||||
return isControllerOrTheHandPath(p, pattern) || grantsOwnObjects(p, pattern)
|
||||
},
|
||||
Shared: "the grants part writes its own object bucket " + GrantsUserListObjects + ", the user list sealed to the bus node, and no other (novox/hq ADR 0547 clause 7)"},
|
||||
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
|
||||
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
|
||||
{State: "the predefined-keys mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
|
||||
Subjects: kvOf(PredefinedBucket), Writes: isController},
|
||||
{State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change",
|
||||
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages,
|
||||
Subjects: kvOf(DeclaredPackagesBucket), Writes: isController},
|
||||
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
|
||||
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
|
||||
// in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket.
|
||||
{State: "a module's files placed on a machine", Writer: "that machine's provisioner client",
|
||||
KeptIn: "the bus, the stream " + ProvAppliedStream + ", newest per module; and key-value " +
|
||||
ProvisionerAppliedBucket,
|
||||
Others: "the controller reads it at a walk's end and a gate; a failure is that machine's failed apply",
|
||||
Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner},
|
||||
}
|
||||
|
||||
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
||||
// another writer (to-be 45 §1): which state, whose, and the pattern that would make a second writer.
|
||||
func CheckWriters(p Principal, publish []string) error {
|
||||
var problems []string
|
||||
for _, pattern := range publish {
|
||||
for _, row := range WritersTable {
|
||||
if row.Writes == nil {
|
||||
continue
|
||||
}
|
||||
for _, subject := range row.Subjects {
|
||||
if !SubjectsOverlap(pattern, subject) || row.Writes(p, pattern) {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s may publish %s, which writes %s (%s), whose writer is %s",
|
||||
p.Username(), pattern, row.State, subject, row.Writer))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("a second writer would be granted (novox/hq to-be 45 §1, one writer per piece of state):\n %s",
|
||||
strings.Join(problems, "\n "))
|
||||
}
|
||||
|
||||
// SubjectsOverlap says some subject matches both patterns: `*` is one token, `>` one or more to the end.
|
||||
func SubjectsOverlap(a, b string) bool {
|
||||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; ; i++ {
|
||||
switch {
|
||||
case i == len(x) && i == len(y):
|
||||
return true
|
||||
case i == len(x) || i == len(y):
|
||||
return false
|
||||
case x[i] == ">" || y[i] == ">":
|
||||
return true
|
||||
case x[i] == "*" || y[i] == "*" || x[i] == y[i]:
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isControllerOrTheHandPath is the controller, or the operator's account (a person who may invoke every tool) writing
|
||||
// release 6's hand path: the one second writer ADR 0539 clauses 2 and 10 name, for a composition made by hand.
|
||||
func isControllerOrTheHandPath(p Principal, pattern string) bool {
|
||||
return isController(p, pattern) || (p.Kind == KindPerson && administers(p.Invokes))
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "node-build-go", Scope: ScopeNode, Decision: "row"}})
|
||||
got, ok := SeatNamed("node-build-go")
|
||||
if !ok || len(got.Accepts) == 0 || !got.PulledByKind || !got.Yields {
|
||||
if !ok || len(got.Accepts) == 0 || !got.PulledByKind {
|
||||
t.Fatalf("the Go build seat lost what it accepts when loaded from the store: %+v", got)
|
||||
}
|
||||
if got.Decision != "row" {
|
||||
|
||||
@@ -311,17 +311,6 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
if words, err = r.readsWords(m, words, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A build seat's holder gives way on its node, and its steps are told their part of the node's share
|
||||
// (novox/hq ADR 0553).
|
||||
yielding, yieldWords := r.yieldsOf(m, with)
|
||||
if len(yieldWords) > 0 {
|
||||
if words == nil {
|
||||
words = map[string]string{}
|
||||
}
|
||||
for k, v := range yieldWords {
|
||||
words[k] = v
|
||||
}
|
||||
}
|
||||
if len(words) > 0 {
|
||||
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
|
||||
if err != nil {
|
||||
@@ -340,9 +329,6 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
process[HealthField] = m.RuntimeHealth
|
||||
healthInto(process, m, with)
|
||||
}
|
||||
if yielding != nil {
|
||||
process["yields"] = yielding
|
||||
}
|
||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -498,54 +498,6 @@ func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
||||
if _, told := env[RuntimeOperatorAccount]; told {
|
||||
t.Error("a runtime of a module's own account is told the operator's account")
|
||||
}
|
||||
if _, yields := own["yields"]; yields {
|
||||
t.Errorf("telegram holds no build seat and yields: %v", own["yields"])
|
||||
}
|
||||
// The build seats' holders yield (ADR 0553): idle, under the node's one share from the node-engine's build-cpu-share,
|
||||
// and their steps are told it.
|
||||
builder := func(name, seat string) Manifest {
|
||||
b := aToolsModule(t, name, "tools/index.js")
|
||||
b.RunsAs, b.SecretsOwner = name, name
|
||||
b.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/" + name + "/broker"}}
|
||||
b.Claims = []Claim{{Name: seat}}
|
||||
return b
|
||||
}
|
||||
gob, img := builder("gob", "node-build-go"), builder("img", "node-build-image")
|
||||
with.Needed["gob"], with.Needed["img"] = map[string]string{"broker": "own"}, map[string]string{"broker": "own"}
|
||||
for _, c := range []struct {
|
||||
settings SettingsBy
|
||||
share string
|
||||
}{
|
||||
{nil, "50"},
|
||||
{SettingsBy{"mesh-host": {{From: "anchor", Values: map[string]any{"build-cpu-share": float64(75)}}}}, "75"},
|
||||
{SettingsBy{"mesh-host": {{From: "anchor", Values: map[string]any{"build-cpu-share": float64(0)}}}}, "50"},
|
||||
} {
|
||||
with.Settings = c.settings
|
||||
runtimes, err := Resolution{Node: "anchor", Modules: []Manifest{gob, img, telegram, goRuntime}}.ownRuntimes(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runtimes) != 3 {
|
||||
t.Fatalf("%d runtimes of their own, want gob's, img's and telegram's", len(runtimes))
|
||||
}
|
||||
for _, p := range runtimes {
|
||||
name := fmt.Sprint(p["name"])
|
||||
words := map[string]map[string]string{}
|
||||
_ = json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &words)
|
||||
mod := strings.TrimSuffix(name, "-runtime")
|
||||
if mod == "telegram" {
|
||||
if p["yields"] != nil || words[mod]["MESH_BUILD_CPU_SHARE"] != "" {
|
||||
t.Errorf("telegram yields under %v: %v %v", c.settings, p["yields"], words)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got := fmt.Sprint(p["yields"]); got != "map[cpu-share:"+c.share+" priority:idle]" ||
|
||||
words[mod]["MESH_BUILD_CPU_SHARE"] != c.share {
|
||||
t.Errorf("%s under %v yields %s with words %v; want share %s", name, c.settings, got, words[mod], c.share)
|
||||
}
|
||||
}
|
||||
}
|
||||
with.Settings = nil
|
||||
// Without the machine's runtime to run it from, it is refused in words.
|
||||
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil {
|
||||
t.Error("a module of its own account composed without a runtime program")
|
||||
|
||||
+3
-19
@@ -49,11 +49,6 @@ type Seat struct {
|
||||
// order Accepts lists them (novox/hq ADR 0525 §2, ADR 0536 §4): the build seats'. Compiled, never stored, like
|
||||
// Receives.
|
||||
PulledByKind bool
|
||||
// Yields says the seat's holder gives way to everything else on its node (novox/hq ADR 0553): its runtime runs at
|
||||
// idle priority in the node's one yielding slice, under the node's one processor share (buildcpu.ShareSetting) that
|
||||
// every yielding holder of the node shares. The build seats': a build must never slow the operator's desktop.
|
||||
// Compiled, never stored, like Receives.
|
||||
Yields bool
|
||||
// Serves carries each verb in full — name, description, schema — because a role's tools are the
|
||||
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
|
||||
Serves []Verb
|
||||
@@ -209,16 +204,6 @@ 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{
|
||||
@@ -271,11 +256,11 @@ var defaultSeats = append([]Seat{
|
||||
// its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace
|
||||
// node-build-agent, retired with build-agent (ADR 0524 §5).
|
||||
{Name: "node-build-go", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"),
|
||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||||
Decision: "novox/hq ADR 0525, ADR 0536"},
|
||||
{Name: "node-build-node", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"),
|
||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||||
Decision: "novox/hq ADR 0525, ADR 0536"},
|
||||
{Name: "node-build-image", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"),
|
||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
||||
Decision: "novox/hq ADR 0525, ADR 0536"},
|
||||
// **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and
|
||||
// every node and container asks them and nothing else. Replicated since ADR 0223: held on more
|
||||
// than one machine, each answering the same names from the same roster, and every machine's
|
||||
@@ -512,7 +497,6 @@ func UseSeats(s []Seat) {
|
||||
row.Paths = d.Paths
|
||||
row.Replicated = d.Replicated
|
||||
row.PulledByKind = d.PulledByKind
|
||||
row.Yields = d.Yields // never stored (ADR 0553)
|
||||
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
|
||||
}
|
||||
merged = append(merged, row)
|
||||
|
||||
@@ -324,9 +324,6 @@ func TestNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) {
|
||||
if !ok || s.Scope != ScopeNode || !s.PulledByKind || strings.Join(s.Accepts, ",") != "delivery,check,recheck" {
|
||||
t.Fatalf("%s = %+v", name, s)
|
||||
}
|
||||
if !s.Yields {
|
||||
t.Errorf("%s does not yield (ADR 0553)", name)
|
||||
}
|
||||
var verbs []string
|
||||
for _, v := range s.Serves {
|
||||
verbs = append(verbs, v.Name)
|
||||
@@ -335,14 +332,4 @@ func TestNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) {
|
||||
t.Errorf("%s serves %v", name, verbs)
|
||||
}
|
||||
}
|
||||
// The build seats are the seats that yield, and no other (ADR 0553 §1).
|
||||
var yielding []string
|
||||
for _, s := range Seats() {
|
||||
if s.Yields {
|
||||
yielding = append(yielding, s.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(yielding, ",") != "node-build-go,node-build-node,node-build-image" {
|
||||
t.Errorf("the seats that yield are %v", yielding)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +121,6 @@ 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
|
||||
@@ -150,7 +148,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", "node-key", "move-asks", "own"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "move-asks"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving the move was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -126,13 +126,6 @@ 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)},
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||
)
|
||||
|
||||
// yieldsOf is how the runtime of its own a module is served by gives way on this node (novox/hq ADR 0553), or nil when
|
||||
// it does not, and the word its steps size their threads from:
|
||||
//
|
||||
// - a module holding a seat that yields (Seat.Yields, the build seats) runs at idle priority under the node's one share
|
||||
// of its processors: the node-engine's own config build-cpu-share (buildcpu.ShareSetting) as this node's layers give
|
||||
// it, its default where none does. Every yielding module of the node carries the same share, so the node-engine's
|
||||
// one yielding slice has one quota (ADR 0553 §2, §3).
|
||||
// - its word: that share (buildcpu.ShareWord), which a step divides by its own module's capacity (ADR 0553 §4).
|
||||
//
|
||||
// Never a refusal: a share out of range is the default, so a mistyped value bounds the builds at the default and never
|
||||
// stops the node's whole composition.
|
||||
func (r Resolution) yieldsOf(m Manifest, with Rendering) (map[string]any, map[string]string) {
|
||||
if !yields(m) {
|
||||
return nil, nil
|
||||
}
|
||||
engine := Manifest{Module: engineModule}
|
||||
for _, e := range r.Modules {
|
||||
if e.Module == engineModule {
|
||||
engine = e
|
||||
}
|
||||
}
|
||||
share := buildcpu.DefaultPercent
|
||||
for _, s := range Effective(engine, with.Settings[engineModule]) {
|
||||
if s.Key == buildcpu.ShareSetting {
|
||||
if n, ok := percentOf(s.Value); ok {
|
||||
share = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{"priority": "idle", "cpu-share": share},
|
||||
map[string]string{buildcpu.ShareWord: strconv.Itoa(share)}
|
||||
}
|
||||
|
||||
// yields is whether a module claims a seat that yields.
|
||||
func yields(m Manifest) bool {
|
||||
for _, c := range m.Claims {
|
||||
if s, ok := SeatNamed(c.Name); ok && s.Yields {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// percentOf is a setting's value as a percent from 1 to 100: a number, or a string holding one.
|
||||
func percentOf(v any) (int, bool) {
|
||||
var n int
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
n = int(x)
|
||||
case int:
|
||||
n = x
|
||||
case string:
|
||||
var err error
|
||||
if n, err = strconv.Atoi(x); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return n, n >= 1 && n <= 100
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// placement-seed writes the catalogue's first placement files (novox/hq ADR 0549 §4, ADR 0555 §6, issue 1126) from the
|
||||
// answer of the controller's one read-only verb `placement-export`: one `placement/<node>.json` per node, each the
|
||||
// node's placement as the controller's own code read it from its own store, checked as the catalogue's check checks it
|
||||
// (delivery.CheckPlacements) before anything is written. The pull request it makes merges only when its composition check
|
||||
// composes every node equal to what it runs.
|
||||
//
|
||||
// placement-seed -catalogue <checkout of mesh-catalog> < export.json
|
||||
// placement-seed -catalogue <dir> -export export.json
|
||||
//
|
||||
// export.json is the verb's answer as it prints it ({"<node>": <placement>, ...}, or the verb's wrapping with it under
|
||||
// "answer"). It opens no network connection, bus or store; it writes only <dir>/placement/<node>.json, and refuses to
|
||||
// write over a placement file already there. Exit codes: 0 written; 1 the export cannot be read, is refused by the
|
||||
// check, or a file is there; 2 the flags are wrong.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
catalogue := flag.String("catalogue", "", "the checkout of the catalogue to write placement/<node>.json into")
|
||||
export := flag.String("export", "", "the verb's answer; standard input when absent")
|
||||
flag.Parse()
|
||||
if *catalogue == "" || flag.NArg() > 0 {
|
||||
fmt.Fprintln(os.Stderr, "usage: placement-seed -catalogue <dir> [-export <file>]")
|
||||
os.Exit(2)
|
||||
}
|
||||
in := io.Reader(os.Stdin)
|
||||
if *export != "" {
|
||||
f, err := os.Open(*export)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
in = f
|
||||
}
|
||||
written, err := Seed(in, *catalogue)
|
||||
for _, path := range written {
|
||||
fmt.Println(path)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "placement-seed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed reads the export and writes each node's placement file under root, answering the paths written. Nothing is
|
||||
// written when the export does not read, the check refuses it, or a placement file is already there.
|
||||
func Seed(export io.Reader, root string) ([]string, error) {
|
||||
raw, err := io.ReadAll(export)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
placements, err := read(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all := func(string) bool { return true }
|
||||
if refused := delivery.CheckPlacements(placements, all, all); len(refused) > 0 {
|
||||
return nil, fmt.Errorf("the export is refused by the catalogue's check: %v", refused)
|
||||
}
|
||||
nodes := make([]string, 0, len(placements))
|
||||
for node := range placements {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
files := map[string][]byte{}
|
||||
for _, node := range nodes {
|
||||
p := placements[node]
|
||||
p.Revision = "" // only the indexer writes a revision, into the key
|
||||
if _, err := delivery.EncodePlacement(p); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", node, err)
|
||||
}
|
||||
p.V = delivery.Version
|
||||
body, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil, fmt.Errorf("%s is already there; the seed is written once (ADR 0549 §4)", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
files[path] = append(body, '\n')
|
||||
}
|
||||
var written []string
|
||||
for _, node := range nodes {
|
||||
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return written, err
|
||||
}
|
||||
if err := os.WriteFile(path, files[path], 0o644); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written = append(written, path)
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
// read is the export as the verb prints it, or wrapped as a verb's answer (the controller's verbAnswer: its JSON under
|
||||
// "answer").
|
||||
func read(raw []byte) (map[string]delivery.Placement, error) {
|
||||
var wrapped struct {
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Answer) > 0 {
|
||||
raw = wrapped.Answer
|
||||
}
|
||||
var byNode map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &byNode); err != nil {
|
||||
return nil, fmt.Errorf("the export is not {<node>: <placement>}: %w", err)
|
||||
}
|
||||
if len(byNode) == 0 {
|
||||
return nil, errors.New("the export names no node")
|
||||
}
|
||||
out := map[string]delivery.Placement{}
|
||||
for node, body := range byNode {
|
||||
p, err := delivery.DecodePlacement(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", node, err)
|
||||
}
|
||||
if p.Node != node {
|
||||
return nil, fmt.Errorf("the export's placement of %s names the node %q", node, p.Node)
|
||||
}
|
||||
out[node] = p
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Issue 1126 (ADR 0549 §4, ADR 0555 §6): the seed writes one placement file per node of the export, as the catalogue's
|
||||
// check and the indexer read it, from the verb's answer bare or wrapped; it writes nothing for an export the check
|
||||
// refuses, and never over a file already there.
|
||||
func TestTheSeedWritesEachNodesPlacementFileOnce(t *testing.T) {
|
||||
export := `{"answer":{"ace":{"v":1,"node":"ace","revision":"x","decided":{"address":"10.77.0.2"},"modules":[{"module":"plex","ports":{"32400":32400}}]},` +
|
||||
`"novox":{"v":1,"node":"novox","decided":{"hub":true,"address":"10.77.0.1"},"modules":[]}}}`
|
||||
root := t.TempDir()
|
||||
written, err := Seed(strings.NewReader(export), root)
|
||||
if err != nil || len(written) != 2 {
|
||||
t.Fatalf("written %v, %v", written, err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "placement", "ace.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := delivery.DecodePlacement(raw)
|
||||
if err != nil || p.Node != "ace" || p.Revision != "" || p.HostPorts()["plex"][32400] != 32400 {
|
||||
t.Errorf("ace's file reads %+v, %v", p, err)
|
||||
}
|
||||
if _, err := Seed(strings.NewReader(export), root); err == nil || !strings.Contains(err.Error(), "already there") {
|
||||
t.Errorf("a second seed over the files: %v", err)
|
||||
}
|
||||
clash := `{"ace":{"v":1,"node":"ace","decided":{"address":"10.77.0.1"},"modules":[]},"novox":{"v":1,"node":"novox","decided":{"address":"10.77.0.1"},"modules":[]}}`
|
||||
other := t.TempDir()
|
||||
if _, err := Seed(strings.NewReader(clash), other); err == nil || !strings.Contains(err.Error(), "both have the address") {
|
||||
t.Errorf("an export the check refuses: %v", err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(filepath.Join(other, "placement")); len(entries) != 0 {
|
||||
t.Errorf("a refused export wrote %v", entries)
|
||||
}
|
||||
// What the controller's placement-export prints, frombus.Export per node, is what the seed takes: a node with no
|
||||
// module placed included.
|
||||
in := compose.Input{Nodes: []compose.Node{{Name: "solo", Place: compose.Place{Hub: true, Address: "10.77.0.9"}}}}
|
||||
exported, err := frombus.Export(in, "solo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer, _ := json.Marshal(map[string]any{"answer": map[string]delivery.Placement{"solo": exported}})
|
||||
if written, err := Seed(strings.NewReader(string(answer)), t.TempDir()); err != nil || len(written) != 1 {
|
||||
t.Errorf("Export's answer %s was not seeded: %v, %v", answer, written, err)
|
||||
}
|
||||
for _, bad := range []string{`[]`, `{}`, `{"ace":{"v":1,"node":"novox","modules":[]}}`} {
|
||||
if _, err := Seed(strings.NewReader(bad), t.TempDir()); err == nil {
|
||||
t.Errorf("%s was seeded", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@ 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"
|
||||
@@ -18,26 +16,6 @@ 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
|
||||
@@ -57,24 +35,6 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
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
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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,7 +2,6 @@ package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -79,16 +78,6 @@ 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
|
||||
@@ -172,15 +161,6 @@ 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)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
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(BusUsersStandIn)
|
||||
foreseen("the bus's user list")
|
||||
}
|
||||
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 "+
|
||||
|
||||
@@ -916,56 +916,6 @@ type Process struct {
|
||||
// Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its
|
||||
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
|
||||
Health *Health `json:"health,omitempty"`
|
||||
|
||||
// Yields says this process gives way to everything else on its machine (novox/hq ADR 0553, issue 1806): the
|
||||
// runtime of a build seat's holder, whose builds must never slow the operator's desktop. Absent: the machine's
|
||||
// defaults. For a process that stays up.
|
||||
Yields *Yields `json:"yields,omitempty"`
|
||||
}
|
||||
|
||||
// YieldIdle is the one priority a process yields at: the lowest there is.
|
||||
const YieldIdle = "idle"
|
||||
|
||||
// YieldSlice is the one slice the node-engine places every yielding process of its node in (novox/hq ADR 0553 §2): a
|
||||
// slice at the top of the hierarchy, beside the operator's user slice and the system's, so its idle weight and its
|
||||
// quota bind against everything else on the machine. No dash in its name: a dash makes a slice a child of the slice
|
||||
// named before it, where its weight would count only against its siblings.
|
||||
const YieldSlice = "meshyield.slice"
|
||||
|
||||
// Yields is how a process gives way, written by the node-engine (novox/hq ADR 0553).
|
||||
//
|
||||
// **Priority** "idle": the process runs in YieldSlice, whose CPUWeight is idle (it runs only on cycles nothing else
|
||||
// wants) and whose IOWeight is 1, with Nice=19 and the lowest best-effort IO level for every process it starts. The IO
|
||||
// controls bind only where the disk's scheduler honours them (bfq, or io.cost for the weight); under `none`, the
|
||||
// usual one for NVMe, they do nothing, and the CPU bound is what holds. **CPUShare** is the most of the machine's
|
||||
// processors the slice may take, in percent, shared by every yielding process of the node together: the node-engine
|
||||
// writes it as the slice's CPUQuota for the processors the machine has, so the bound is the kernel's. Every yielding
|
||||
// process of one node carries the same share: the composer gives the node's one value (ADR 0553 §3). 0 is no cap.
|
||||
type Yields struct {
|
||||
Priority string `json:"priority,omitempty"`
|
||||
CPUShare int `json:"cpu-share,omitempty"`
|
||||
}
|
||||
|
||||
func (y *Yields) problems(where string, staysUp bool) []string {
|
||||
if y == nil {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
if !staysUp {
|
||||
problems = append(problems, where+": yields is for a process that stays up")
|
||||
}
|
||||
switch y.Priority {
|
||||
case "", YieldIdle:
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("%s: yields priority %q is not one this host knows: idle", where, y.Priority))
|
||||
}
|
||||
if y.CPUShare < 0 || y.CPUShare > 100 {
|
||||
problems = append(problems, fmt.Sprintf("%s: yields cpu-share %d is not a percent of the machine, 0 to 100", where, y.CPUShare))
|
||||
}
|
||||
if y.Priority == "" && y.CPUShare == 0 {
|
||||
problems = append(problems, where+": yields says neither a priority nor a cpu-share")
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func (d *Process) Identity() string { return d.ID }
|
||||
@@ -998,7 +948,6 @@ func ProcessNameProblem(name string) string {
|
||||
|
||||
func (d *Process) validate(where string, _ bool) []string {
|
||||
problems := d.Health.problems(where, false, !d.RunOnce && d.Schedule == "")
|
||||
problems = append(problems, d.Yields.problems(where, !d.RunOnce && d.Schedule == "")...)
|
||||
if problem := ProcessNameProblem(d.Name); problem != "" {
|
||||
problems = append(problems, where+": "+problem)
|
||||
}
|
||||
|
||||
@@ -92,20 +92,6 @@ func TestTheModesAreExclusive(t *testing.T) {
|
||||
if problems := watching.validate("a process", false); len(problems) == 0 {
|
||||
t.Fatal("a scheduled process was given something to restart on, and it is never running")
|
||||
}
|
||||
|
||||
// A process yields only while it stays up, at a priority the host knows, within the machine (issue 1806).
|
||||
for name, bad := range map[string]func(*Process){
|
||||
"a step that yields": func(p *Process) { p.RunOnce = true; p.Yields = &Yields{Priority: YieldIdle} },
|
||||
"an unknown priority": func(p *Process) { p.Yields = &Yields{Priority: "low"} },
|
||||
"a share over the whole": func(p *Process) { p.Yields = &Yields{CPUShare: 150} },
|
||||
"a yield saying nothing": func(p *Process) { p.Yields = &Yields{} },
|
||||
} {
|
||||
p := aProcess()
|
||||
bad(p)
|
||||
if problems := p.validate("a process", false); len(problems) == 0 {
|
||||
t.Errorf("%s was accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A cadence that is not a cadence is refused near its author, rather than by a machine at the far
|
||||
@@ -132,11 +118,6 @@ func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
|
||||
if problems := every.validate("a process", false); len(problems) != 0 {
|
||||
t.Fatalf("a scheduled process was refused: %v", problems)
|
||||
}
|
||||
yielding := aProcess()
|
||||
yielding.Yields = &Yields{Priority: YieldIdle, CPUShare: 50}
|
||||
if problems := yielding.validate("a process", false); len(problems) != 0 {
|
||||
t.Fatalf("a daemon yielding at idle under half the machine was refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// **The one that cannot be escaped, only refused.**
|
||||
|
||||
+11
-54
@@ -166,9 +166,8 @@ 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, 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
|
||||
// Want is a node's want for a module: the fingerprint FP the node should run and the digest Composed of the one
|
||||
// composition of it the node may apply (ADR 0539 clause 1), or Removed for a module that left the node's modules (then
|
||||
// both are empty). When the module's newest fingerprint is not yet allowed on this node, Newest names it and Rule the
|
||||
// rule that holds the node back; both are empty when FP is the newest. Backup is the answer to a failed backup before
|
||||
// the apply, BackupAgain or BackupSkip (clause 16), empty when none was asked. Words say why in a sentence. The
|
||||
@@ -182,51 +181,23 @@ type Want struct {
|
||||
Rule int `json:"rule,omitempty"`
|
||||
Backup string `json:"backup,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// Put says the want was written by a put (ADR 0546), and Hold that the put holds it: the rules pass over it until it
|
||||
// is released. A put's want names no newest fingerprint or rule.
|
||||
Put bool `json:"put,omitempty"`
|
||||
Hold bool `json:"hold,omitempty"`
|
||||
// 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 || w.Self || w.Yes != "" {
|
||||
return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule, backup answer or put")
|
||||
if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" {
|
||||
return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule or backup answer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if w.Hold && !w.Put {
|
||||
return fmt.Errorf("delivery: only a put holds a want")
|
||||
}
|
||||
if w.Put && (w.Newest != "" || w.Rule != 0) {
|
||||
return fmt.Errorf("delivery: a put's want is held back by no rule")
|
||||
}
|
||||
if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil {
|
||||
return err
|
||||
}
|
||||
// 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 w.Composed == "" {
|
||||
return fmt.Errorf("delivery: the want of %s names no composition: a want names the digest of the one composition it allows (ADR 0539 clause 1)", w.FP)
|
||||
}
|
||||
if w.Yes != "" {
|
||||
if err := checkDigest(w.Yes, "the digest a want's yes allows"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDigest(w.Composed, "a want's composition digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
switch w.Backup {
|
||||
case "", BackupAgain, BackupSkip:
|
||||
@@ -328,14 +299,11 @@ 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, and a want that names no
|
||||
// composition unless it says the node composes its own (Self, ADR 0547 §2).
|
||||
// EncodeNodeWant is w as JSON at Version; it refuses a digest not of the sha256 form.
|
||||
func EncodeNodeWant(w NodeWant) ([]byte, error) {
|
||||
if err := w.check(); err != nil {
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.V = Version
|
||||
@@ -354,23 +322,12 @@ 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 := w.check(); err != nil {
|
||||
if err := checkDigest(w.Composed, "the node's own want's composition digest"); 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 {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 1297/B1a: a want key round-trips through its parser; a held key, a have key and a node's own key are refused,
|
||||
@@ -78,7 +77,6 @@ func TestAssignerValuesRoundTrip(t *testing.T) {
|
||||
{FP: fp2of('a'), Composed: hashOf('1')},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"},
|
||||
{Removed: true, Words: "the module left g14"},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Hold: true, Words: "put by the operator: the bus's user list"},
|
||||
}
|
||||
for _, w := range wants {
|
||||
data, err := EncodeWant(w)
|
||||
@@ -91,24 +89,6 @@ func TestAssignerValuesRoundTrip(t *testing.T) {
|
||||
t.Errorf("want %+v came back %+v, %v", w, got, err)
|
||||
}
|
||||
}
|
||||
at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)
|
||||
p := Put{FP: fp2of('a'), Hold: true, Why: "the bus's user list", By: "shanks.node-tools", At: at}.Said(at, PutAsked, "put asked")
|
||||
p = p.Said(at, PutAsked, "put asked").Said(at.Add(time.Second), PutWanted, "want written")
|
||||
p.Composed = hashOf('1')
|
||||
if len(p.Steps) != 2 || p.Step != PutWanted || PutEnded(p.Step) || !PutEnded(PutReleased) {
|
||||
t.Errorf("a put's steps are %+v at %s", p.Steps, p.Step)
|
||||
}
|
||||
pkey, _ := PutKey("novox", "nats")
|
||||
if n, m, err := ParsePutKey(pkey); err != nil || n != "novox" || m != "nats" {
|
||||
t.Errorf("the put key %s reads %s %s, %v", pkey, n, m, err)
|
||||
}
|
||||
pdata, err := EncodePut(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotP, err := DecodePut(pdata); err != nil || gotP.FP != p.FP || gotP.Step != PutWanted || len(gotP.Steps) != 2 || !gotP.Hold {
|
||||
t.Errorf("the put came back %+v, %v", gotP, err)
|
||||
}
|
||||
h := Held{Node: "ace", Words: "its health check failed: connection refused"}
|
||||
data, err := EncodeHeld(h)
|
||||
if err != nil {
|
||||
@@ -148,9 +128,6 @@ func TestAssignerValuesRefused(t *testing.T) {
|
||||
{FP: fp2of('a'), Newest: fp2of('b')},
|
||||
{FP: fp2of('a'), Newest: fp2of('a'), Rule: 3},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Hold: true},
|
||||
{FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3},
|
||||
{Removed: true, Put: true},
|
||||
} {
|
||||
if _, err := EncodeWant(w); err == nil {
|
||||
t.Errorf("EncodeWant(%+v) accepted", w)
|
||||
@@ -171,19 +148,6 @@ func TestAssignerValuesRefused(t *testing.T) {
|
||||
t.Errorf("EncodeDifference(%+v) accepted", d)
|
||||
}
|
||||
}
|
||||
for _, p := range []Put{
|
||||
{FP: fp2of('a'), Why: "w", Step: "walking"},
|
||||
{FP: fp2of('a'), Step: PutAsked},
|
||||
{FP: "src2:abc", Why: "w", Step: PutAsked},
|
||||
{FP: fp2of('a'), Why: "w", Step: PutWanted, Composed: "sha256:x"},
|
||||
} {
|
||||
if _, err := EncodePut(p); err == nil {
|
||||
t.Errorf("EncodePut(%+v) accepted", p)
|
||||
}
|
||||
}
|
||||
if _, _, err := ParsePutKey("ace.node"); err == nil {
|
||||
t.Errorf("a node's own key was read as a put")
|
||||
}
|
||||
if _, err := DecodeWant([]byte(`{"v":2,"fp":"` + fp2of('a') + `"}`)); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("a want of version 2 was read: %v", err)
|
||||
}
|
||||
@@ -298,20 +262,4 @@ func TestControllerGrant(t *testing.T) {
|
||||
if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil {
|
||||
t.Errorf("a grant naming an empty account was accepted")
|
||||
}
|
||||
g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
|
||||
for _, c := range []struct {
|
||||
g ControllerGrant
|
||||
node, module string
|
||||
names bool
|
||||
}{
|
||||
{g, "ace", "mesh-build-go", true},
|
||||
{g, "shanks", "mesh-mail", true},
|
||||
{g, "g14", "mesh-build-go", false},
|
||||
{g, "ace", "mesh-build", false},
|
||||
{ControllerGrant{}, "novox", "nats", true},
|
||||
} {
|
||||
if got := c.g.NamesNode(c.node, c.module); got != c.names {
|
||||
t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +86,6 @@ 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
|
||||
}
|
||||
@@ -111,20 +108,6 @@ 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 {
|
||||
|
||||
@@ -2,15 +2,11 @@ package buildrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
func goAsk(id string) delivery.StepAsk {
|
||||
@@ -26,10 +22,9 @@ 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}
|
||||
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)
|
||||
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()))
|
||||
}
|
||||
f.Answer = func(a delivery.StepAsk) delivery.Step {
|
||||
return delivery.Step{ID: a.ID, Node: "shanks", State: delivery.StepRefused, Reason: "go 1.27 installed"}
|
||||
@@ -53,74 +48,6 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
|
||||
if len(g.Asks()) != 0 {
|
||||
t.Fatal("a wait put an ask")
|
||||
}
|
||||
// Issue 734: an ask the build seat gave up on (DEAD_LETTERS keeps its copy with the ask's id) ends its wait as failed
|
||||
// with the words, though a node had taken it; under NotTakenBound one no node takes ends at the bound; a step written
|
||||
// first is answered as the node wrote it.
|
||||
dead := &DeadLetters{}
|
||||
lw := LostAskWaiter{Inner: g, Dead: dead}
|
||||
k = Asker{Publisher: g, Waiter: lw, Bound: time.Minute}
|
||||
g.Write(delivery.Step{ID: "mvl0frv18d1970", Node: "ace", State: delivery.StepRunning, Started: time.Now()})
|
||||
given, ok := GivenUpOf(map[string][]string{"Mesh-Dead-Msg-Id": {"mvl0frv18d1970"}, "Mesh-Dead-Deliveries": {"5"},
|
||||
"Mesh-Dead-Stream": {"SEAT_NODE_BUILD_GO"}, "Mesh-Dead-Gave-Up": {"2026-10-26T12:26:40Z"}})
|
||||
if !ok {
|
||||
t.Fatal("a dead letter's headers were not read")
|
||||
}
|
||||
go dead.Heard(given)
|
||||
step, err = k.Wait(context.Background(), goAsk("mvl0frv18d1970"))
|
||||
if err != nil || step.State != delivery.StepFailed || !IsLost(step) ||
|
||||
!strings.Contains(step.Reason, LostWords+" after 5 deliveries at 2026-10-26T12:26:40Z") {
|
||||
t.Fatalf("a given-up ask: %+v, %v", step, err)
|
||||
}
|
||||
if _, err := k.Passed(step, nil); err == nil || !strings.Contains(err.Error(), "on "+LostNode+" failed: "+LostWords) {
|
||||
t.Fatalf("a given-up ask's error: %v", err)
|
||||
}
|
||||
step, err = k.Wait(NotTakenBound(context.Background(), 20*time.Millisecond), goAsk("mvl0frv18d1971"))
|
||||
if err != nil || !IsNotTaken(step) || IsLost(step) {
|
||||
t.Fatalf("an ask no node took: %+v, %v", step, err)
|
||||
}
|
||||
g.Write(delivery.Step{ID: "mvl0frv18d1972", Node: "g14", State: delivery.StepPassed})
|
||||
if step, err = k.Wait(NotTakenBound(context.Background(), time.Minute), goAsk("mvl0frv18d1972")); err != nil || step.Node != "g14" {
|
||||
t.Fatalf("a step written: %+v, %v", step, err)
|
||||
}
|
||||
// Review round 1 of issue 734: a hearing the bus refuses at start is deaf and says so, never holds the start up, and
|
||||
// tries again on the next ask waited for; heard, it says so and hands the given-up asks on.
|
||||
fol := &refusingFollower{refuse: 1}
|
||||
var mu sync.Mutex
|
||||
var told []Hearing
|
||||
hearing := NewDeadLetterHearing(context.Background(), fol, []string{delivery.AskGo}, dead, nil,
|
||||
func(h Hearing) { mu.Lock(); told = append(told, h); mu.Unlock() })
|
||||
hearing.Wait()
|
||||
if why := hearing.Deaf(); !strings.Contains(why, "SEAT_NODE_BUILD_GO: permissions violation") {
|
||||
t.Fatalf("a refused hearing says %q", why)
|
||||
}
|
||||
k = Asker{Publisher: g, Waiter: LostAskWaiter{Inner: g, Dead: dead, Hearing: hearing}, Bound: time.Minute}
|
||||
g.Write(delivery.Step{ID: "mvl0frv18d1973", Node: "g14", State: delivery.StepPassed})
|
||||
if _, err := k.Wait(context.Background(), goAsk("mvl0frv18d1973")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hearing.Wait()
|
||||
mu.Lock()
|
||||
if hearing.Deaf() != "" || len(told) != 2 || told[0].Heard || !told[1].Heard || fol.calls() != 2 {
|
||||
t.Fatalf("after an ask: deaf %q, told %+v, followed %d times", hearing.Deaf(), told, fol.calls())
|
||||
}
|
||||
mu.Unlock()
|
||||
fol.heard(GivenUp{AskID: "mvl0frv18d1974", Deliveries: 5})
|
||||
step, err = k.Wait(context.Background(), goAsk("mvl0frv18d1974"))
|
||||
if err != nil || !IsLost(step) {
|
||||
t.Fatalf("an ask heard given up after the hearing came back: %+v, %v", step, err)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
h *Hearing
|
||||
want string
|
||||
}{{nil, health.StateUnhealthy}, {&told[0], health.StateUnhealthy}, {&told[1], health.StateHealthy}} {
|
||||
var raw []byte
|
||||
if row.h != nil {
|
||||
raw, _ = json.Marshal(row.h)
|
||||
}
|
||||
if a := HearingHealth(raw); a.State != row.want || a.Why == "" {
|
||||
t.Errorf("health of %+v = %+v, want %s", row.h, a, row.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Review (issue 734 path): an ask taken again by the same node (its build module replaced mid-run) is bounded from the
|
||||
@@ -142,26 +69,3 @@ func TestARedeliveryToTheSameNodeRestartsTheBound(t *testing.T) {
|
||||
t.Fatalf("step %+v, %v, want the redelivered run's pass", step, err)
|
||||
}
|
||||
}
|
||||
|
||||
// refusingFollower refuses the first `refuse` follows, as the bus does before the asker's grant is composed.
|
||||
type refusingFollower struct {
|
||||
mu sync.Mutex
|
||||
refuse int
|
||||
n int
|
||||
hand func(GivenUp)
|
||||
}
|
||||
|
||||
func (f *refusingFollower) Follow(_ context.Context, _ string, _ time.Time, heard func(GivenUp)) (func(), error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.n++
|
||||
if f.n <= f.refuse {
|
||||
return nil, errors.New("permissions violation")
|
||||
}
|
||||
f.hand = heard
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
func (f *refusingFollower) calls() int { f.mu.Lock(); defer f.mu.Unlock(); return f.n }
|
||||
|
||||
func (f *refusingFollower) heard(g GivenUp) { f.mu.Lock(); h := f.hand; f.mu.Unlock(); h(g) }
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
package buildrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
|
||||
// **An ask the build seat gave up on ends its step** (novox/hq issue 734). A build seat's worker offers an ask
|
||||
// delivery.AskMaxDeliver times; one handed out that often without an answer (a bus reload that drops every hand-out, a node
|
||||
// that dies taking it each time) is given up by the server, and the controller keeps a copy of it in DEAD_LETTERS under
|
||||
// mesh.events.dead.<seat stream>.<worker>, with the ask's message id (its ask id), how often it was handed out and when it
|
||||
// was given up. No build node writes a step for it, so an asker that waits for the step waits for ever: a try stays
|
||||
// running and holds a try place, a check's verdict stays running and its pull request waits.
|
||||
//
|
||||
// An asker of the build seats (the checker, the builder) hears those copies (DeadLetterHearing: an ordered consumer on
|
||||
// DEAD_LETTERS filtered to the build seats' streams, granted to a user of a seat pulled by kind, buscompose
|
||||
// DeadLetterReads) into DeadLetters, and LostAskWaiter ends the waiting step at once as failed on the node LostNode, its
|
||||
// reason saying LostWords; the asker decides what follows (the checker ends a try as error and asks a check again, the
|
||||
// builder asks its part of a build again).
|
||||
//
|
||||
// Belt and braces, where the asker says so in the context (NotTakenBound; the checker's tries): an ask no build node took
|
||||
// within a bound ends the same way, saying NotTakenWords. An ask waits in the stream as long as the backlog is otherwise
|
||||
// (ADR 0525 §9): a backlog is no fault.
|
||||
|
||||
// LostNode is the node a step ended for a lost ask names: no build node wrote it.
|
||||
const LostNode = "bus"
|
||||
|
||||
// The words of a step ended for its ask.
|
||||
const (
|
||||
LostWords = "its ask was given up by the build seat"
|
||||
NotTakenWords = "no build node took its ask"
|
||||
)
|
||||
|
||||
// The headers the controller keeps a given-up message's copy with in DEAD_LETTERS (mesh-controller internal/link
|
||||
// deadletters.go): the original message id (an ask's is its ask id), how often it was handed out, when it was given up.
|
||||
const (
|
||||
DeadHeaderMsgID = "Mesh-Dead-Msg-Id"
|
||||
DeadHeaderDeliveries = "Mesh-Dead-Deliveries"
|
||||
DeadHeaderGaveUp = "Mesh-Dead-Gave-Up"
|
||||
DeadHeaderStream = "Mesh-Dead-Stream"
|
||||
)
|
||||
|
||||
// DeadLettersStream is the stream the controller keeps given-up messages in, and DeadLetterFilter the subjects of one seat
|
||||
// stream's copies there (buscompose DeadLetterSubject).
|
||||
const DeadLettersStream = "DEAD_LETTERS"
|
||||
|
||||
// DeadLetterFilter is the subjects DEAD_LETTERS keeps a seat stream's given-up messages under.
|
||||
func DeadLetterFilter(stream string) string { return "mesh.events.dead." + stream + ".>" }
|
||||
|
||||
// SeatStream is the stream of a build seat's language (buscompose seatStreamName of node-build-<language>).
|
||||
func SeatStream(language string) string { return "SEAT_NODE_BUILD_" + strings.ToUpper(language) }
|
||||
|
||||
// GivenUp is one ask a build seat gave up on, as DEAD_LETTERS keeps it.
|
||||
type GivenUp struct {
|
||||
AskID string
|
||||
Stream string
|
||||
Deliveries int
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// GivenUpOf reads a DEAD_LETTERS copy's headers; false when it names no message id.
|
||||
func GivenUpOf(header map[string][]string) (GivenUp, bool) {
|
||||
get := func(k string) string {
|
||||
if v := header[k]; len(v) > 0 {
|
||||
return strings.TrimSpace(v[0])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
g := GivenUp{AskID: get(DeadHeaderMsgID), Stream: get(DeadHeaderStream)}
|
||||
if g.AskID == "" {
|
||||
return GivenUp{}, false
|
||||
}
|
||||
g.Deliveries, _ = strconv.Atoi(get(DeadHeaderDeliveries))
|
||||
g.At, _ = time.Parse(time.RFC3339Nano, get(DeadHeaderGaveUp))
|
||||
return g, true
|
||||
}
|
||||
|
||||
// Words are what a step ended for the given-up ask says.
|
||||
func (g GivenUp) Words() string {
|
||||
at := "at an unknown time"
|
||||
if !g.At.IsZero() {
|
||||
at = "at " + g.At.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return fmt.Sprintf("%s after %d deliveries %s (kept in %s); ask again", LostWords, g.Deliveries, at, DeadLettersStream)
|
||||
}
|
||||
|
||||
// keptGivenUp is how many given-up asks DeadLetters remembers for a waiter that starts after the copy was heard.
|
||||
const keptGivenUp = 1024
|
||||
|
||||
// DeadLetters holds the given-up asks heard, and hands each to the waiter of its ask. Safe for concurrent use.
|
||||
type DeadLetters struct {
|
||||
mu sync.Mutex
|
||||
heard map[string]GivenUp
|
||||
order []string
|
||||
watchers map[string][]chan GivenUp
|
||||
}
|
||||
|
||||
// Heard takes one given-up ask in.
|
||||
func (d *DeadLetters) Heard(g GivenUp) {
|
||||
d.mu.Lock()
|
||||
if d.heard == nil {
|
||||
d.heard, d.watchers = map[string]GivenUp{}, map[string][]chan GivenUp{}
|
||||
}
|
||||
if _, ok := d.heard[g.AskID]; !ok {
|
||||
d.order = append(d.order, g.AskID)
|
||||
if len(d.order) > keptGivenUp {
|
||||
delete(d.heard, d.order[0])
|
||||
d.order = d.order[1:]
|
||||
}
|
||||
}
|
||||
d.heard[g.AskID] = g
|
||||
ws := d.watchers[g.AskID]
|
||||
delete(d.watchers, g.AskID)
|
||||
d.mu.Unlock()
|
||||
for _, w := range ws {
|
||||
w <- g // buffered, one each
|
||||
}
|
||||
}
|
||||
|
||||
// watch answers a channel that gets the ask's given-up copy once (at once when it was heard already), and its cancel.
|
||||
func (d *DeadLetters) watch(askID string) (<-chan GivenUp, func()) {
|
||||
ch := make(chan GivenUp, 1)
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.heard == nil {
|
||||
d.heard, d.watchers = map[string]GivenUp{}, map[string][]chan GivenUp{}
|
||||
}
|
||||
if g, ok := d.heard[askID]; ok {
|
||||
ch <- g
|
||||
return ch, func() {}
|
||||
}
|
||||
d.watchers[askID] = append(d.watchers[askID], ch)
|
||||
return ch, func() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
ws := d.watchers[askID]
|
||||
for i, w := range ws {
|
||||
if w == ch {
|
||||
d.watchers[askID] = append(ws[:i], ws[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(d.watchers[askID]) == 0 {
|
||||
delete(d.watchers, askID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type notTakenKey struct{}
|
||||
|
||||
// NotTakenBound says, in ctx, that an ask no build node took within bound ends its step (a try's, never a check's).
|
||||
func NotTakenBound(ctx context.Context, bound time.Duration) context.Context {
|
||||
return context.WithValue(ctx, notTakenKey{}, bound)
|
||||
}
|
||||
|
||||
func notTakenBoundOf(ctx context.Context) time.Duration {
|
||||
d, _ := ctx.Value(notTakenKey{}).(time.Duration)
|
||||
return d
|
||||
}
|
||||
|
||||
// LostAskWaiter is an AskWaiter that ends an ask's step when the build seat gave the ask up (DeadLetters), or, under
|
||||
// NotTakenBound, when no build node took it within the bound: it hands the asker a failed step whose reason says why, on
|
||||
// the node "bus". Every step the build module writes is handed on as it is.
|
||||
type LostAskWaiter struct {
|
||||
Inner AskWaiter
|
||||
Dead *DeadLetters
|
||||
// Hearing, when set, is asked to hear DEAD_LETTERS again each time an ask is waited for while it cannot: an ask put is
|
||||
// the event that makes hearing matter.
|
||||
Hearing interface {
|
||||
Deaf() string
|
||||
Again()
|
||||
}
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (w LostAskWaiter) now() time.Time {
|
||||
if w.Now != nil {
|
||||
return w.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// WatchAsk is AskWaiter's.
|
||||
func (w LostAskWaiter) WatchAsk(ctx context.Context, module, askID string) (<-chan delivery.Step, error) {
|
||||
if w.Hearing != nil && w.Hearing.Deaf() != "" {
|
||||
w.Hearing.Again()
|
||||
}
|
||||
inner, err := w.Inner.WatchAsk(ctx, module, askID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dead <-chan GivenUp
|
||||
cancel := func() {}
|
||||
if w.Dead != nil {
|
||||
dead, cancel = w.Dead.watch(askID)
|
||||
}
|
||||
// The not-taken bound is a context's (ADR 0437 §8: no timer), ended when a node takes the ask.
|
||||
var notTaken <-chan struct{}
|
||||
bound := notTakenBoundOf(ctx)
|
||||
stopBound := func() {}
|
||||
if bound > 0 {
|
||||
var nt context.Context
|
||||
nt, stopBound = context.WithTimeout(context.Background(), bound)
|
||||
notTaken = nt.Done()
|
||||
}
|
||||
out := make(chan delivery.Step)
|
||||
go func() {
|
||||
defer close(out)
|
||||
defer cancel()
|
||||
defer func() { stopBound() }()
|
||||
send := func(s delivery.Step) bool {
|
||||
select {
|
||||
case out <- s:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
ended := func(reason string) delivery.Step {
|
||||
at := w.now().UTC()
|
||||
return delivery.Step{ID: askID, Node: LostNode, State: delivery.StepFailed, Reason: reason, Ended: at}
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case s, ok := <-inner:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if notTaken != nil {
|
||||
stopBound() // a node took it: its step is bounded by the asker from its start
|
||||
notTaken = nil
|
||||
}
|
||||
if !send(s) {
|
||||
return
|
||||
}
|
||||
case g := <-dead:
|
||||
dead = nil
|
||||
send(ended(g.Words()))
|
||||
return
|
||||
case <-notTaken:
|
||||
send(ended(fmt.Sprintf("%s within %s of its put; it was lost or waits behind a backlog; ask again", NotTakenWords, bound)))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsLost says whether a step was ended by LostAskWaiter because the build seat gave its ask up (LostWords).
|
||||
func IsLost(s delivery.Step) bool { return s.Node == LostNode && strings.Contains(s.Reason, LostWords) }
|
||||
|
||||
// IsNotTaken says whether a step was ended by LostAskWaiter because no build node took its ask within the bound.
|
||||
func IsNotTaken(s delivery.Step) bool {
|
||||
return s.Node == LostNode && strings.Contains(s.Reason, NotTakenWords)
|
||||
}
|
||||
|
||||
// **The asker hears DEAD_LETTERS, tries again when it cannot, and says whether it hears** (issue 734). The read is
|
||||
// granted with the asker's seat traffic (buscompose DeadLetterReads); an asker started before its grant was composed is
|
||||
// refused, and an asker that only tried once would stay deaf until it starts again, silently. DeadLetterHearing tries in
|
||||
// the background, never holding the start up, and again on an event (Again): the asker's reconnect to the bus, and every
|
||||
// ask waited for while it is deaf (LostAskWaiter.Hearing); no timer. Each change of whether it hears is said (Said), so
|
||||
// the module's health tool says it: a deaf asker is not healthy.
|
||||
|
||||
// Hearing is what a DeadLetterHearing says: whether it hears every build seat stream it follows, and why not.
|
||||
type Hearing struct {
|
||||
Heard bool `json:"heard"`
|
||||
Streams []string `json:"streams"`
|
||||
Why string `json:"why,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HearingKey is the key a module keeps its Hearing under, in its own state (the checker's and the builder's hearing).
|
||||
const HearingKey = "dead-letters"
|
||||
|
||||
// DeadLetterFollower starts following one seat stream's copies in DEAD_LETTERS from since, handing each given-up ask to
|
||||
// heard; it answers the stop, or why it cannot.
|
||||
type DeadLetterFollower interface {
|
||||
Follow(ctx context.Context, stream string, since time.Time, heard func(GivenUp)) (func(), error)
|
||||
}
|
||||
|
||||
// JetStreamDeadLetters is the bus side of a DeadLetterFollower: an ordered consumer on DEAD_LETTERS filtered to the
|
||||
// stream's copies, which resumes where it was after a reconnect and, reset before it heard any, starts at since again.
|
||||
type JetStreamDeadLetters struct{ JS jetstream.JetStream }
|
||||
|
||||
// Follow is DeadLetterFollower's.
|
||||
func (j JetStreamDeadLetters) Follow(ctx context.Context, stream string, since time.Time, heard func(GivenUp)) (func(), error) {
|
||||
cons, err := j.JS.OrderedConsumer(ctx, DeadLettersStream, jetstream.OrderedConsumerConfig{
|
||||
FilterSubjects: []string{DeadLetterFilter(stream)}, DeliverPolicy: jetstream.DeliverByStartTimePolicy, OptStartTime: &since})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cc, err := cons.Consume(func(m jetstream.Msg) {
|
||||
if g, ok := GivenUpOf(m.Headers()); ok {
|
||||
heard(g)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cc.Stop, nil
|
||||
}
|
||||
|
||||
// DeadLetterHearing follows the build seats' given-up asks into Dead. Make it with NewDeadLetterHearing.
|
||||
type DeadLetterHearing struct {
|
||||
follower DeadLetterFollower
|
||||
streams []string
|
||||
dead *DeadLetters
|
||||
logf func(string, ...any)
|
||||
said func(Hearing)
|
||||
ctx context.Context
|
||||
since time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
stops map[string]func()
|
||||
why map[string]string
|
||||
trying bool
|
||||
again bool
|
||||
told *Hearing
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewDeadLetterHearing starts hearing the given-up asks of each language's build seat stream, in the background. said
|
||||
// is told each change of whether it hears (nil tells nothing); ctx ends it.
|
||||
func NewDeadLetterHearing(ctx context.Context, follower DeadLetterFollower, languages []string, dead *DeadLetters,
|
||||
logf func(string, ...any), said func(Hearing)) *DeadLetterHearing {
|
||||
h := &DeadLetterHearing{follower: follower, dead: dead, logf: logf, said: said, ctx: ctx, since: time.Now(),
|
||||
stops: map[string]func(){}, why: map[string]string{}}
|
||||
for _, l := range languages {
|
||||
s := SeatStream(l)
|
||||
h.streams = append(h.streams, s)
|
||||
h.why[s] = "not tried yet"
|
||||
}
|
||||
if h.logf == nil {
|
||||
h.logf = func(string, ...any) {}
|
||||
}
|
||||
h.Again()
|
||||
return h
|
||||
}
|
||||
|
||||
// Deaf says why a stream is not heard, or "" when every one is.
|
||||
func (h *DeadLetterHearing) Deaf() string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.deafLocked()
|
||||
}
|
||||
|
||||
func (h *DeadLetterHearing) deafLocked() string {
|
||||
var out []string
|
||||
for _, s := range h.streams {
|
||||
if why, deaf := h.why[s]; deaf {
|
||||
out = append(out, s+": "+why)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
return DeadLettersStream + " is not heard, so an ask a build seat gives up on is waited for until its bound: " +
|
||||
strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// Again tries, in the background, every stream not heard yet; a call while it tries makes it try once more after.
|
||||
func (h *DeadLetterHearing) Again() {
|
||||
h.mu.Lock()
|
||||
if h.trying {
|
||||
h.again = true
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.trying = true
|
||||
h.mu.Unlock()
|
||||
h.wg.Add(1)
|
||||
go func() {
|
||||
defer h.wg.Done()
|
||||
for {
|
||||
h.try()
|
||||
h.mu.Lock()
|
||||
if !h.again || h.ctx.Err() != nil {
|
||||
h.trying = false
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.again = false
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait returns when no try runs.
|
||||
func (h *DeadLetterHearing) Wait() { h.wg.Wait() }
|
||||
|
||||
// Stop ends every stream's following.
|
||||
func (h *DeadLetterHearing) Stop() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for s, stop := range h.stops {
|
||||
stop()
|
||||
delete(h.stops, s)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeadLetterHearing) try() {
|
||||
for _, s := range h.streams {
|
||||
h.mu.Lock()
|
||||
_, deaf := h.why[s]
|
||||
h.mu.Unlock()
|
||||
if !deaf || h.ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
stream := s
|
||||
stop, err := h.follower.Follow(h.ctx, stream, h.since, func(g GivenUp) {
|
||||
h.logf("the ask %s on %s was given up after %d deliveries at %s: the wait for its step ends", g.AskID, g.Stream,
|
||||
g.Deliveries, g.At.Format(time.RFC3339))
|
||||
h.dead.Heard(g)
|
||||
})
|
||||
h.mu.Lock()
|
||||
if err != nil {
|
||||
h.why[stream] = err.Error()
|
||||
h.logf("the asks %s gives up on are not heard (%s of %s); tried again at the next reconnect or ask: %v",
|
||||
stream, DeadLetterFilter(stream), DeadLettersStream, err)
|
||||
} else {
|
||||
delete(h.why, stream)
|
||||
h.stops[stream] = stop
|
||||
h.logf("the asks %s gives up on are heard from %s", stream, DeadLettersStream)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
h.tell()
|
||||
}
|
||||
|
||||
// tell says the hearing when it changed.
|
||||
func (h *DeadLetterHearing) tell() {
|
||||
h.mu.Lock()
|
||||
why := h.deafLocked()
|
||||
now := Hearing{Heard: why == "", Streams: append([]string(nil), h.streams...), Why: why, At: time.Now().UTC()}
|
||||
same := h.told != nil && h.told.Heard == now.Heard && h.told.Why == now.Why
|
||||
if !same {
|
||||
h.told = &now
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !same && h.said != nil {
|
||||
h.said(now)
|
||||
}
|
||||
}
|
||||
|
||||
// HearingState is the name of the state an asker keeps its Hearing in (`<module>_hearing`, key HearingKey), which its
|
||||
// health tool reads.
|
||||
const HearingState = "hearing"
|
||||
|
||||
// HearingKeeper is where a Hearing is kept: a jetstream.KeyValue, or a process.AwaitedBucket for a state that may not be
|
||||
// on the bus at the part's start (issue 1790), which keeps the latest until it appears and answers process.ErrNotYet.
|
||||
type HearingKeeper interface {
|
||||
Put(ctx context.Context, key string, value []byte) (uint64, error)
|
||||
}
|
||||
|
||||
// SayHearing is a DeadLetterHearing's said that keeps each Hearing in kv under HearingKey; nil kv keeps nothing and
|
||||
// says so once. A write kv keeps for later (process.ErrNotYet) is not an error: the awaited bucket said it waits.
|
||||
func SayHearing(ctx context.Context, kv HearingKeeper, logf func(string, ...any)) func(Hearing) {
|
||||
if kv == nil {
|
||||
if logf != nil {
|
||||
logf("whether %s is heard is not kept: the state %s is not there, so the health tool cannot say it", DeadLettersStream, HearingState)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return func(h Hearing) {
|
||||
data, err := json.Marshal(h)
|
||||
if err == nil {
|
||||
_, err = kv.Put(ctx, HearingKey, data)
|
||||
}
|
||||
if err != nil && !errors.Is(err, process.ErrNotYet) && logf != nil {
|
||||
logf("whether %s is heard could not be kept: %v", DeadLettersStream, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HearingHealth is the health answer of an asker's kept Hearing (raw, empty when none is kept): healthy while it hears
|
||||
// DEAD_LETTERS, unhealthy saying why while it does not or has not said.
|
||||
func HearingHealth(raw []byte) health.Answer {
|
||||
if len(raw) == 0 {
|
||||
return health.Unhealthy("it has not said whether it hears " + DeadLettersStream + " (issue 734): it is starting, or its state " + HearingState + " is not there")
|
||||
}
|
||||
var h Hearing
|
||||
if err := json.Unmarshal(raw, &h); err != nil {
|
||||
return health.Unhealthy("what it keeps of hearing " + DeadLettersStream + " is not a hearing: " + err.Error())
|
||||
}
|
||||
if !h.Heard {
|
||||
return health.Unhealthy(h.Why + " (since " + h.At.Format(time.RFC3339) + "; tried again at its next reconnect or ask)")
|
||||
}
|
||||
return health.Healthy("it hears " + DeadLettersStream + " for " + strings.Join(h.Streams, ", "))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1791/1921 S9: a member of a group that merged stays in the candidate, marked merged, with its head, so the heads hash
|
||||
// the Approve covers holds until every member merged; a candidate whose every member merged is refused.
|
||||
func TestACandidateKeepsAMergedMemberAndRefusesOneWhoseEveryMemberMerged(t *testing.T) {
|
||||
a := Head{Repository: "novox/a", Number: 1, Head: strings.Repeat("1", 40)}
|
||||
b := Head{Repository: "novox/b", Number: 2, Head: strings.Repeat("2", 40)}
|
||||
hash, _ := HeadsHash([]Head{a, b})
|
||||
c := Candidate{Group: "feat/x", HeadsHash: hash, Pulls: []CandidatePull{{Head: a, Modules: []string{}, Merged: true}, {Head: b, Modules: []string{}}}}
|
||||
raw, err := EncodeCandidate(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeCandidate(raw)
|
||||
if err != nil || !back.Pulls[0].Merged || back.Pulls[1].Merged || back.HeadsHash != hash {
|
||||
t.Fatalf("read back %+v, %v", back, err)
|
||||
}
|
||||
c.Pulls[1].Merged = true
|
||||
if _, err := EncodeCandidate(c); err == nil {
|
||||
t.Error("a candidate whose every member merged was written")
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,6 @@ type Composition struct {
|
||||
Words string `json:"words,omitempty"`
|
||||
SDK string `json:"sdk,omitempty"`
|
||||
Machines []string `json:"machines,omitempty"`
|
||||
// FPSet is the candidate's fingerprint set (Candidate.FPSet) the composition judged: a carry keeps a composition that
|
||||
// passed while the candidate's set is the same, and composes again when a fingerprint changed (novox/hq ADR 0548 §5).
|
||||
// Absent in a value written before it, which a carry composes again.
|
||||
FPSet string `json:"fpset,omitempty"`
|
||||
}
|
||||
|
||||
// Took is the time of each part of a check in integer milliseconds (ADR 0447 clause 3): waiting in the queue, cloning,
|
||||
@@ -170,14 +166,6 @@ type Verdict struct {
|
||||
Composition *Composition `json:"composition,omitempty"`
|
||||
// OverBound names the levels whose running went over their bound; it does not change the State (ADR 0453 clause 9).
|
||||
OverBound []string `json:"over-bound,omitempty"`
|
||||
// Beside is the commit of each sibling repository the check read beside the repositories it merged (ADR 0484 §4), by
|
||||
// repository: a move of one puts the verdict out of date as a trunk move does, and it is carried (novox/hq ADR 0548 §6).
|
||||
// A repository is in Trunk or here, never both. Absent in a value written before it.
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
// CarriedFrom names, of a carried verdict, the trunk and sibling commits it was judged on before the moves it was
|
||||
// carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages
|
||||
// whose digest the moves changed. Absent from a verdict judged whole, and from one still running.
|
||||
CarriedFrom map[string]string `json:"carried-from,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Composition) check() error {
|
||||
@@ -215,33 +203,6 @@ func (v Verdict) check() error {
|
||||
return fmt.Errorf("delivery: the verdict's trunk head of %s is empty", repository)
|
||||
}
|
||||
}
|
||||
for repository, commit := range v.Beside {
|
||||
if commit == "" {
|
||||
return fmt.Errorf("delivery: the verdict's sibling commit of %s is empty", repository)
|
||||
}
|
||||
if _, ok := v.Trunk[repository]; ok {
|
||||
return fmt.Errorf("delivery: the verdict names %s as a trunk it merged and as a sibling it read", repository)
|
||||
}
|
||||
}
|
||||
if len(v.CarriedFrom) > 0 {
|
||||
if v.State == VerdictRunning {
|
||||
return fmt.Errorf("delivery: a running verdict is carried from nothing: carried-from is written with its end")
|
||||
}
|
||||
moved := false
|
||||
for repository, commit := range v.CarriedFrom {
|
||||
now, inTrunk := v.Trunk[repository]
|
||||
if !inTrunk {
|
||||
now = v.Beside[repository]
|
||||
}
|
||||
if commit == "" || now == "" {
|
||||
return fmt.Errorf("delivery: the verdict's carried-from names %s, which it judged on no commit of", repository)
|
||||
}
|
||||
moved = moved || commit != now
|
||||
}
|
||||
if !moved {
|
||||
return fmt.Errorf("delivery: the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried")
|
||||
}
|
||||
}
|
||||
for level, r := range v.Levels {
|
||||
if err := checkLevel(level); err != nil {
|
||||
return err
|
||||
|
||||
@@ -29,12 +29,7 @@ func verdictSamples() map[string]Verdict {
|
||||
failTest.Levels = map[string]LevelResult{LevelUnit: {Run: 1}}
|
||||
failMachine := passVerdict()
|
||||
failMachine.State, failMachine.Failed = VerdictFail, FailedFirst{Machine: "ace", Words: "path /etc/x is owned by two modules"}
|
||||
carried := passVerdict()
|
||||
carried.Beside = map[string]string{"novox/mesh-catalog": "cccc"}
|
||||
carried.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999", "novox/mesh-control": "bbbb", "novox/mesh-catalog": "cccc"}
|
||||
carried.Composition = &Composition{State: CompositionPass, SDK: carried.SDK, Machines: []string{"ace"}, FPSet: "fps1:00"}
|
||||
return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine,
|
||||
"carried": carried}
|
||||
return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine}
|
||||
}
|
||||
|
||||
func keptSample() Kept {
|
||||
@@ -253,14 +248,7 @@ func TestVerdictCarriesSkippedCompositionAndOverBound(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.Beside, v.CarriedFrom = map[string]string{"novox/mesh-catalog": "cccc"}, map[string]string{"novox/mesh-catalog": "cbcb"}
|
||||
v.Composition.FPSet = "fps1:ab"
|
||||
data, err = EncodeVerdict(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range []string{`"skipped":4`, `"composition":{"state":"pass"`, `"over-bound":["integration"]`, `"beside":{"novox/mesh-catalog":"cccc"}`,
|
||||
`"carried-from":{"novox/mesh-catalog":"cbcb"}`, `"fpset":"fps1:ab"`} {
|
||||
for _, field := range []string{`"skipped":4`, `"composition":{"state":"pass"`, `"over-bound":["integration"]`} {
|
||||
if !strings.Contains(string(data), field) {
|
||||
t.Errorf("the value lacks %s: %s", field, data)
|
||||
}
|
||||
@@ -307,13 +295,6 @@ func TestVerdictNewFieldsChecked(t *testing.T) {
|
||||
"a not-run composition naming machines": {func(v *Verdict) { v.Composition = &Composition{State: CompositionNotRun, Machines: []string{"ace"}} }, []string{"composition", "not run"}},
|
||||
"an unknown over-bound level": {func(v *Verdict) { v.OverBound = []string{"e2e"} }, []string{"e2e"}},
|
||||
"an over-bound level named twice": {func(v *Verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, []string{"over-bound", "unit"}},
|
||||
// ADR 0548 §5, §6: the siblings read and the commits a verdict was carried from.
|
||||
"a sibling with no commit": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-catalog": ""} }, []string{"sibling", "mesh-catalog"}},
|
||||
"a trunk named as a sibling": {func(v *Verdict) { v.Beside = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"trunk", "sibling", "mesh-sdk"}},
|
||||
"carried from an unjudged repo": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/other": "1111"} }, []string{"carried-from", "novox/other"}},
|
||||
"carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}},
|
||||
"carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}},
|
||||
"a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}},
|
||||
}
|
||||
for name, c := range cases {
|
||||
v := passVerdict()
|
||||
@@ -338,7 +319,7 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("the new reader of an old value: %v", err)
|
||||
}
|
||||
if v.Composition != nil || v.OverBound != nil || v.Beside != nil || v.CarriedFrom != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 {
|
||||
if v.Composition != nil || v.OverBound != nil || v.Levels[LevelUnit].Skipped != 0 || v.Levels[LevelUnit].Run != 12 {
|
||||
t.Errorf("an old value grew fields: %+v", v)
|
||||
}
|
||||
again, err := EncodeVerdict(v)
|
||||
@@ -349,9 +330,6 @@ func TestVerdictOldAndNewReadersAgree(t *testing.T) {
|
||||
n.Levels[LevelUnit] = LevelResult{Run: 12, Kept: 301, Skipped: 4}
|
||||
n.Composition = &Composition{State: CompositionNotRun}
|
||||
n.OverBound = []string{LevelUnit}
|
||||
n.Beside = map[string]string{"novox/mesh-catalog": "cccc"}
|
||||
n.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999"}
|
||||
n.Composition.FPSet = "fps1:00"
|
||||
data, err := EncodeVerdict(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+6
-91
@@ -34,12 +34,9 @@
|
||||
// **The release 4 layouts** (issues 1356 to 1359; ADR 0453 clauses 4, 6 and 16): the new fingerprint form (fingerprint2.go),
|
||||
// the indexer's manifests by digest and the new fields of its modules and candidates (manifests.go, source.go), the
|
||||
// builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the
|
||||
// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte. The bus module's have on the
|
||||
// node holding mesh-broker is the other: its user list's handing carries a sealed box of every loaded user's hash, some
|
||||
// kilobytes (handed.go).
|
||||
// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte.
|
||||
//
|
||||
// Standard library only, and this module's own asks, declaration and seal packages (a composition holds declaration
|
||||
// resources; the adoption handing is a sealed box, handed.go).
|
||||
// Standard library only, and this module's own asks and declaration packages (a composition holds declaration resources).
|
||||
package delivery
|
||||
|
||||
import (
|
||||
@@ -48,9 +45,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -99,10 +94,6 @@ 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.
|
||||
@@ -219,15 +210,12 @@ 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 + "." + HaveNodeWord, nil
|
||||
return node + ".node", nil
|
||||
}
|
||||
|
||||
// ParseHaveKey is the inverse of HaveKey and HaveNodeKey: isNode is true for `<node>.node`, with module empty.
|
||||
@@ -343,48 +331,6 @@ type Have struct {
|
||||
Phase Phase `json:"phase"`
|
||||
Health Health `json:"health"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// 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
|
||||
// alone and with no want (novox/hq ADR 0547 §8): where it was taken from, what that source holds, and what the file
|
||||
// holds now. The bus reloads the file itself when its digest changes (the module's entrypoint), so Digest is the list
|
||||
// the bus loads. Wanted differs from Digest only while a write failed, and Error says why.
|
||||
type UserListWritten struct {
|
||||
// Source is the key the list was taken from: the controller's composition of the bus module for this node
|
||||
// (`<node>.<module>.<fp>`), or the grants part's `user-list` once it took over.
|
||||
Source string `json:"source"`
|
||||
Revision uint64 `json:"revision"`
|
||||
// Wanted is the digest of the list the source holds, `sha256:<hex>`.
|
||||
Wanted string `json:"wanted"`
|
||||
// Digest is the digest of the file on the machine after the write, `sha256:<hex>`; empty when there is none.
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Users int `json:"users"`
|
||||
At time.Time `json:"at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Follows is which list the node follows now (FollowController or FollowGrants, novox/hq ADR 0547 clause 8), empty
|
||||
// from a node-engine before the switch.
|
||||
Follows string `json:"follows,omitempty"`
|
||||
// Key is the bus node's sealing key, its public half as SPKI PEM: the key the grants part seals its list to
|
||||
// (clause 7). Public, as the vault holds it.
|
||||
Key string `json:"key,omitempty"`
|
||||
// Unwatched says why the bus node does not watch the grants part's key now (its watch refused or ended, and tried
|
||||
// again), empty while it does or where it need not (ADR 0547 clause 8): it then keeps the last list it followed.
|
||||
Unwatched string `json:"unwatched,omitempty"`
|
||||
// Handed is the hashes of the users of the list on the machine, sealed to the vault, for its adoption of the
|
||||
// accounts it holds no value for (ADR 0547 clause 9, handed.go); nil while the vault's key is not known.
|
||||
Handed *AccountsHanded `json:"handed,omitempty"`
|
||||
}
|
||||
|
||||
// HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole:
|
||||
@@ -399,9 +345,6 @@ 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
|
||||
@@ -450,18 +393,8 @@ 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, held or waiting-yes", h.Phase)
|
||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed or held", h.Phase)
|
||||
}
|
||||
if h.Composed != "" {
|
||||
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
|
||||
@@ -537,30 +470,12 @@ func DecodePulse(data []byte) (Pulse, error) {
|
||||
}
|
||||
|
||||
// SameHave says whether two haves are the same value, so a writer writes only when they differ.
|
||||
func SameHave(a, b Have) bool {
|
||||
ua, ub := a.UserList, b.UserList
|
||||
a.UserList, b.UserList = nil, 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 {
|
||||
return true
|
||||
}
|
||||
x, y := *ua, *ub
|
||||
x.At, y.At = time.Time{}, time.Time{}
|
||||
x.Handed, y.Handed = nil, nil
|
||||
return x == y && ua.At.Equal(ub.At) && sameHanded(ua.Handed, ub.Handed)
|
||||
}
|
||||
func SameHave(a, b Have) bool { return a == b }
|
||||
|
||||
// SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing.
|
||||
func SameHaveNode(a, b HaveNode) bool {
|
||||
return a.V == b.V && 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) &&
|
||||
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record))
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile)
|
||||
}
|
||||
|
||||
// SamePresence says whether two presences are the same value.
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -61,7 +54,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)
|
||||
}
|
||||
@@ -101,7 +94,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)
|
||||
}
|
||||
@@ -127,7 +120,6 @@ 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
|
||||
@@ -153,130 +145,10 @@ func TestRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.V = Version
|
||||
if !SameHave(out, in) {
|
||||
if out != in {
|
||||
t.Fatalf("got %+v want %+v", out, in)
|
||||
}
|
||||
})
|
||||
t.Run("have with the user list", func(t *testing.T) {
|
||||
at := time.Date(2026, 10, 25, 21, 40, 0, 0, time.UTC)
|
||||
in := Have{FP: "f1", Phase: PhaseRunning, Health: HealthOK, UserList: &UserListWritten{Source: "novox.nats.f1",
|
||||
Revision: 7, Wanted: "sha256:ab", Digest: "sha256:ab", Users: 93, At: at}}
|
||||
b, err := EncodeHave(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hasV1(t, b)
|
||||
out, err := DecodeHave(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.V = Version
|
||||
if !reflect.DeepEqual(out, in) || !out.UserList.At.Equal(at) {
|
||||
t.Fatalf("got %+v want %+v", out, in)
|
||||
}
|
||||
// And with the hashes handed to the vault (ADR 0547 clause 9): sealed to its key, opened by it alone, every
|
||||
// listed user with a bcrypt hash once, and refused to another key.
|
||||
hash := "$2a$11$" + strings.Repeat("a", 53)
|
||||
list := []byte("{ user: \"controller\", password: \"" + hash + "\", permissions: {\n" +
|
||||
"{ user: \"node.ace\", password: \"" + hash + "\", permissions: {\n" +
|
||||
"{ user: \"controller\", password: \"" + hash + "\" }\n{ user: \"nkey\", password: \"plain\" }\n")
|
||||
accounts := HandedAccountsOf(list)
|
||||
if len(accounts) != 2 || accounts[0].User != "controller" || accounts[1].User != "node.ace" || accounts[1].Hash != hash {
|
||||
t.Fatalf("handed accounts: %+v", accounts)
|
||||
}
|
||||
vault, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := seal.GenerateKeyPair()
|
||||
handed, err := HandAccounts(accounts, vault.PublicKey, "sha256:ab", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.UserList.Handed = &handed
|
||||
b, err = EncodeHave(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), "node.ace") || strings.Contains(string(b), hash) {
|
||||
t.Fatalf("a user or a hash is in the clear: %s", b)
|
||||
}
|
||||
out, err = DecodeHave(b)
|
||||
if err != nil || !SameHave(out, in) {
|
||||
t.Fatalf("handed round trip: %v %+v", err, out.UserList)
|
||||
}
|
||||
opened, err := OpenHanded(*out.UserList.Handed, vault.PublicKey, vault.PrivateKey)
|
||||
if err != nil || !reflect.DeepEqual(opened, accounts) {
|
||||
t.Fatalf("opened %+v, %v", opened, err)
|
||||
}
|
||||
if _, err := OpenHanded(*out.UserList.Handed, other.PublicKey, other.PrivateKey); err == nil {
|
||||
t.Fatal("a handing opened for another key")
|
||||
}
|
||||
if _, err := HandAccounts(accounts, "", "sha256:ab", at); err == nil {
|
||||
t.Fatal("handed with no vault key")
|
||||
}
|
||||
})
|
||||
// The grants part's list sealed to the bus node (novox/hq ADR 0547 clause 7): the key's value round-trips and refuses
|
||||
// what the bus node could not follow, and the object opens with the node's key alone, to the digest the key names.
|
||||
t.Run("the grants part's sealed user list", func(t *testing.T) {
|
||||
in := Have{FP: "f1", Phase: PhaseRunning, Health: HealthOK, UserList: &UserListWritten{Source: GrantsUserListSource,
|
||||
Revision: 3, Wanted: "sha256:ab", Digest: "sha256:ab", Users: 2, Follows: FollowGrants, Key: "PEM"}}
|
||||
b, err := EncodeHave(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := DecodeHave(b); err != nil || out.UserList.Follows != FollowGrants || out.UserList.Key != "PEM" {
|
||||
t.Fatalf("got %+v, %v", out.UserList, err)
|
||||
}
|
||||
list := []byte("accounts { MESH { users = [ { user: \"controller\" } ] } }\n")
|
||||
digest := UserListDigest(list)
|
||||
node, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := seal.GenerateKeyPair()
|
||||
g := GrantsUserList{Follow: FollowGrants, Digest: digest, Object: UserListObjectName(digest),
|
||||
SealedTo: SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: 1}
|
||||
raw, err := EncodeGrantsUserList(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeGrantsUserList(raw)
|
||||
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
|
||||
t.Fatalf("decoded %+v, %v", back, err)
|
||||
}
|
||||
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object},
|
||||
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
|
||||
if _, err := EncodeGrantsUserList(bad); err == nil {
|
||||
t.Errorf("%+v was written", bad)
|
||||
}
|
||||
}
|
||||
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowController}); err != nil {
|
||||
t.Errorf("a switch with no list yet refused: %v", err)
|
||||
}
|
||||
object, err := SealUserList(list, node.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(object, []byte("controller")) {
|
||||
t.Fatal("the object carries the list in the clear")
|
||||
}
|
||||
opener := func(priv string) func(seal.Box) ([]byte, error) {
|
||||
return func(b seal.Box) ([]byte, error) { s, err := seal.Open(b, priv); return []byte(s), err }
|
||||
}
|
||||
if got, err := OpenUserList(object, digest, opener(node.PrivateKey)); err != nil || !bytes.Equal(got, list) {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
if _, err := OpenUserList(object, digest, opener(other.PrivateKey)); err == nil {
|
||||
t.Error("another key opened the list")
|
||||
}
|
||||
if _, err := OpenUserList(object, "sha256:00", opener(node.PrivateKey)); err == nil {
|
||||
t.Error("a list of another digest was taken")
|
||||
}
|
||||
if _, err := SealUserList(list, "not a key"); err == nil {
|
||||
t.Error("a list was sealed to no key")
|
||||
}
|
||||
})
|
||||
t.Run("have node", func(t *testing.T) {
|
||||
in := HaveNode{Windows: json.RawMessage(`{"a":1}`), Busy: json.RawMessage(`true`)}
|
||||
b, err := EncodeHaveNode(in)
|
||||
@@ -344,7 +216,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 {
|
||||
@@ -367,27 +239,10 @@ 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
|
||||
@@ -411,7 +266,6 @@ 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) {
|
||||
@@ -421,43 +275,6 @@ 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
|
||||
ua.UserList = &UserListWritten{Source: "k", Revision: 1, Wanted: "sha256:a", Digest: "sha256:a", Users: 2, At: at}
|
||||
ub.UserList = &UserListWritten{Source: "k", Revision: 1, Wanted: "sha256:a", Digest: "sha256:a", Users: 2, At: at.In(time.Local)}
|
||||
if !SameHave(ua, ub) || SameHave(ua, a) {
|
||||
t.Error("equal user lists differ, or one said and one not are the same")
|
||||
}
|
||||
ub.UserList.Revision = 2
|
||||
if SameHave(ua, ub) {
|
||||
t.Error("different user lists same")
|
||||
}
|
||||
// A handing is compared by what it says: one said and one not differ, as do two to other keys.
|
||||
ub.UserList.Revision = 1
|
||||
ua.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at}
|
||||
if SameHave(ua, ub) {
|
||||
t.Error("a handing said and one not are the same")
|
||||
}
|
||||
ub.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at.In(time.Local)}
|
||||
if !SameHave(ua, ub) {
|
||||
t.Error("equal handings differ")
|
||||
}
|
||||
ub.UserList.Handed.To = "sha256:j"
|
||||
if SameHave(ua, ub) {
|
||||
t.Error("handings to other keys same")
|
||||
}
|
||||
n := HaveNode{V: 1, Busy: json.RawMessage(`true`)}
|
||||
if !SameHaveNode(n, HaveNode{V: 1, Busy: json.RawMessage(`true`)}) || SameHaveNode(n, HaveNode{V: 1}) {
|
||||
t.Error("have node")
|
||||
@@ -476,7 +293,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 {
|
||||
@@ -487,7 +304,7 @@ func testHaveRemoved(t *testing.T) { // 1370/AC3
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.V = Version
|
||||
if !SameHave(out, in) || out.Phase != "removed" {
|
||||
if out != in || out.Phase != "removed" {
|
||||
t.Errorf("round trip: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,9 @@ const oddManifest = "{\n\t\"module\": \"a\",\n \"z\": 1, \"a\": [ \"x\",\"y\"
|
||||
|
||||
func mergedWithInputs() Forge {
|
||||
return Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", Root: "t0",
|
||||
Trees: map[string]string{"a": "t1", "b": "t2"},
|
||||
Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}},
|
||||
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
|
||||
Trees: map[string]string{"a": "t1", "b": "t2"},
|
||||
Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}}
|
||||
}
|
||||
|
||||
// 1442/AC1
|
||||
@@ -22,7 +21,7 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`, `"placements":{"ace":`, `"placements-read":true`} {
|
||||
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`} {
|
||||
if !strings.Contains(string(data), frag) {
|
||||
t.Errorf("the encoding lacks %s: %s", frag, data)
|
||||
}
|
||||
@@ -48,9 +47,6 @@ 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)
|
||||
|
||||
@@ -123,15 +123,6 @@ func sampleMerge() Merge {
|
||||
}}
|
||||
}
|
||||
|
||||
func sampleTurn() Turn {
|
||||
at := time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC)
|
||||
return Turn{Repository: "novox/mesh-sdk", Waiting: []TurnWait{
|
||||
{Group: "feat/x", Heads: mustHash(gateHeads()), Ask: "merge.feat/x." + mustHash(gateHeads()), Approved: at, Since: at,
|
||||
WaitsFor: "feat/y.0123456789ab", WaitsSince: at.Add(-time.Minute), Step: "the check ask of novox/mesh-sdk#40"},
|
||||
{Group: "fix/1.2", Heads: "0123456789ac", Approved: at.Add(time.Minute), Since: at.Add(time.Minute)},
|
||||
}}
|
||||
}
|
||||
|
||||
func sampleAsk() Ask {
|
||||
key, _ := MergeAskKey("feat/x", gateHeads())
|
||||
return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{
|
||||
@@ -190,24 +181,7 @@ func TestGateValuesRoundTrip(t *testing.T) { // 1201/AC4
|
||||
t.Errorf("owed %s round trip: %+v %v", o.Act, got, err)
|
||||
}
|
||||
}
|
||||
tu := sampleTurn()
|
||||
data, err = EncodeTurn(tu)
|
||||
hasV("turn", data, err)
|
||||
tu.V = Version
|
||||
if got, err := DecodeTurn(data); err != nil || !SameTurn(got, tu) {
|
||||
t.Errorf("turn round trip: %+v %v", got, err)
|
||||
}
|
||||
for _, field := range []string{`"waits-for":"feat/y.0123456789ab"`, `"waits-since":`, `"approved":`, `"step":`} {
|
||||
if !strings.Contains(string(data), field) {
|
||||
t.Errorf("the turn lacks %s: %s", field, data)
|
||||
}
|
||||
}
|
||||
// ADR 0548 §3: a turn holds off every check but those of the heads that wait in it, whoever else waits there.
|
||||
if !TurnHolds(tu, "fedcba987654") || TurnHolds(tu, tu.Waiting[0].Heads) || TurnHolds(tu, tu.Waiting[1].Heads) || TurnHolds(Turn{}, "fedcba987654") {
|
||||
t.Error("TurnHolds: a turn holds other heads' checks and never the check of heads waiting in it")
|
||||
}
|
||||
for name, dec := range map[string]func([]byte) error{
|
||||
"turn": func(b []byte) error { _, err := DecodeTurn(b); return err },
|
||||
"merge": func(b []byte) error { _, err := DecodeMerge(b); return err },
|
||||
"ask": func(b []byte) error { _, err := DecodeAsk(b); return err },
|
||||
"answer": func(b []byte) error { _, err := DecodeAnswer(b); return err },
|
||||
@@ -291,27 +265,6 @@ func TestGateValuesRefused(t *testing.T) { // 1201/AC5
|
||||
}
|
||||
_, err = EncodeOwed(Owed{Act: OwedNote, Text: "line"})
|
||||
wantErr("note without ref", err, "ref")
|
||||
|
||||
for name, c := range map[string]struct {
|
||||
edit func(*Turn)
|
||||
words []string
|
||||
}{
|
||||
"no delivery": {func(t *Turn) { t.Waiting = nil }, []string{"no delivery", "deleted"}},
|
||||
"a repository in capitals": {func(t *Turn) { t.Repository = "novox/Mesh-SDK" }, []string{"lowercase"}},
|
||||
"no repository": {func(t *Turn) { t.Repository = "" }, []string{"repository"}},
|
||||
"no group": {func(t *Turn) { t.Waiting[0].Group = "" }, []string{"group"}},
|
||||
"a bad heads hash": {func(t *Turn) { t.Waiting[0].Heads = "xyz" }, []string{"xyz"}},
|
||||
"the same heads twice": {func(t *Turn) { t.Waiting[1].Heads = t.Waiting[0].Heads }, []string{"twice"}},
|
||||
"no Approve time": {func(t *Turn) { t.Waiting[1].Approved = time.Time{} }, []string{"Approve"}},
|
||||
"out of the Approve's order": {func(t *Turn) { t.Waiting[1].Approved = t.Waiting[0].Approved.Add(-time.Hour) }, []string{"order"}},
|
||||
"waits for no verdict key": {func(t *Turn) { t.Waiting[0].WaitsFor = "feat/y" }, []string{"verdict key"}},
|
||||
"a step and no check": {func(t *Turn) { t.Waiting[1].Step = "x" }, []string{"names no check"}},
|
||||
} {
|
||||
tu := sampleTurn()
|
||||
c.edit(&tu)
|
||||
_, err = EncodeTurn(tu)
|
||||
wantErr("turn with "+name, err, c.words...)
|
||||
}
|
||||
_, err = EncodeOwed(Owed{Act: OwedStatus, State: "success"})
|
||||
wantErr("status without context", err, "context")
|
||||
}
|
||||
@@ -344,8 +297,7 @@ func TestGateSame(t *testing.T) { // 1201/AC6
|
||||
func TestGateBuckets(t *testing.T) {
|
||||
if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" ||
|
||||
OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" ||
|
||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 ||
|
||||
TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 {
|
||||
MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
}
|
||||
@@ -418,21 +370,6 @@ func TestKeysEscapeAndRoundTrip(t *testing.T) { // 1323/AC1
|
||||
if r, n, h, a, err := ParseOwedKey(ok); err != nil || r != repo || n != 3 || h != "aaaa" || a != "view" {
|
||||
t.Errorf("owed round trip %q = %q %d %q %q %v", repo, r, n, h, a, err)
|
||||
}
|
||||
// ADR 0548 §3: a repository's turn key is <owner>.<name>, lowercase, each part escaped.
|
||||
tk, err := TurnsKey(repo)
|
||||
if err != nil || strings.Count(tk, ".") != 1 {
|
||||
t.Errorf("turns key for repository %q = %q, %v", repo, tk, err)
|
||||
} else if r, err := ParseTurnsKey(tk); err != nil || r != strings.ToLower(repo) {
|
||||
t.Errorf("turns round trip %q = %q, %v", repo, r, err)
|
||||
}
|
||||
}
|
||||
if k, err := TurnsKey("novox/Mesh-SDK"); err != nil || k != "novox.mesh-sdk" {
|
||||
t.Errorf("turns key = %q, %v; want novox.mesh-sdk", k, err)
|
||||
}
|
||||
for _, bad := range []string{"", "novox", "novox.", ".x", "a.b.c", "Novox.mesh-sdk", "novox.a=zz"} {
|
||||
if _, err := ParseTurnsKey(bad); err == nil {
|
||||
t.Errorf("parse turns key %q accepted", bad)
|
||||
}
|
||||
}
|
||||
if EscapeName("a.b") != "a=2eb" || EscapeName("a=b") != "a=3db" || EscapeName("a+b") != "a=2bb" {
|
||||
t.Errorf("escapes: %q %q %q", EscapeName("a.b"), EscapeName("a=b"), EscapeName("a+b"))
|
||||
|
||||
@@ -73,20 +73,3 @@ func DecodeControllerGrant(data []byte) (ControllerGrant, error) {
|
||||
|
||||
// SameControllerGrant says whether two grants are the same value.
|
||||
func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// NamesNode says whether the grant names module's accounts on node: its own `<node>.<module>` or a process's
|
||||
// `<node>.<module>.<process>`. A grant that names no account (a module that does not speak on the bus) names every
|
||||
// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR
|
||||
// 0546 clause 4).
|
||||
func (g ControllerGrant) NamesNode(node, module string) bool {
|
||||
if len(g.Accounts) == 0 {
|
||||
return true
|
||||
}
|
||||
own := node + "." + module
|
||||
for _, a := range g.Accounts {
|
||||
if a == own || strings.HasPrefix(a, own+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the vault adopts, once and before `take-over`, the
|
||||
// bcrypt hashes of the bus accounts it holds no value for (the controller's own, the node accounts, the enrolled
|
||||
// devices) from the list the bus has loaded. The node-engine of the node holding mesh-broker is the one part that has
|
||||
// that list, so it hands the hashes in the bus module's have, beside what it says of the list it wrote: sealed to the
|
||||
// vault's own key, which it learns from the vault's answer to `node-key`, so that of every reader of the have bucket
|
||||
// the vault alone can open them. The have says in the clear only how many users it hands, from which list and to which
|
||||
// key; the users' names stay sealed, as the list itself is (ADR 0547 clause 7: it names every account).
|
||||
//
|
||||
// It hands every user of the loaded list; the vault takes from it only the users it holds no value for, and never
|
||||
// replaces a hash it made from a value it holds, so the node-engine never needs to read the vault's state (which the
|
||||
// grants part alone is to read, clause 9).
|
||||
|
||||
// AccountsHanded is the hashes of the loaded list's users, sealed to the vault, in UserListWritten.
|
||||
type AccountsHanded struct {
|
||||
// From is the digest of the list on the machine the hashes are taken from, `sha256:<hex>` (UserListWritten.Digest).
|
||||
From string `json:"from"`
|
||||
// To is the fingerprint of the vault's key the box is sealed to (KeyFingerprint).
|
||||
To string `json:"to"`
|
||||
// Users is how many users the box holds.
|
||||
Users int `json:"users"`
|
||||
// Box is the JSON of []HandedAccount, sealed to the vault's key (ADR 0206).
|
||||
Box seal.Box `json:"box"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list
|
||||
// carries for it.
|
||||
type HandedAccount struct {
|
||||
User string `json:"user"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// KeyFingerprint is a public key's fingerprint as a handing names it: `sha256:<hex>` of its PEM, without the space
|
||||
// around it.
|
||||
func KeyFingerprint(publicPEM string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM)))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// listedAccount is one user of a bus user list as the composer writes it (buscompose), with its password's hash.
|
||||
var listedAccount = regexp.MustCompile(`\{\s*user:\s*"([^"]+)",\s*password:\s*"([^"]+)"`)
|
||||
|
||||
// HandedAccountsOf is every user of a bus user list that carries a bcrypt hash, in the list's order. A user listed
|
||||
// twice is handed once, as the list first names it.
|
||||
func HandedAccountsOf(list []byte) []HandedAccount {
|
||||
var out []HandedAccount
|
||||
seen := map[string]bool{}
|
||||
for _, m := range listedAccount.FindAllSubmatch(list, -1) {
|
||||
user, hash := string(m[1]), string(m[2])
|
||||
if seen[user] || !IsBcrypt(hash) {
|
||||
continue
|
||||
}
|
||||
seen[user] = true
|
||||
out = append(out, HandedAccount{User: user, Hash: hash})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bcryptHash is the form of a bcrypt hash: version, cost, and 53 characters of salt and digest.
|
||||
var bcryptHash = regexp.MustCompile(`^\$2[abxy]?\$[0-9]{2}\$[./A-Za-z0-9]{53}$`)
|
||||
|
||||
// IsBcrypt says whether s has the form of a bcrypt hash.
|
||||
func IsBcrypt(s string) bool { return bcryptHash.MatchString(s) }
|
||||
|
||||
// HandAccounts seals the accounts to the vault's public key (SPKI PEM) as the handing of the list whose digest is from.
|
||||
func HandAccounts(accounts []HandedAccount, vaultPEM, from string, at time.Time) (AccountsHanded, error) {
|
||||
if strings.TrimSpace(vaultPEM) == "" {
|
||||
return AccountsHanded{}, errors.New("delivery: no vault key to seal the handed accounts to")
|
||||
}
|
||||
body, err := json.Marshal(accounts)
|
||||
if err != nil {
|
||||
return AccountsHanded{}, err
|
||||
}
|
||||
box, err := seal.Seal(string(body), vaultPEM)
|
||||
if err != nil {
|
||||
return AccountsHanded{}, fmt.Errorf("delivery: the handed accounts could not be sealed to the vault's key: %w", err)
|
||||
}
|
||||
return AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: len(accounts), Box: box, At: at.UTC()}, nil
|
||||
}
|
||||
|
||||
// OpenHanded opens a handing with the vault's private key (PEM), refusing one sealed to another key than publicPEM's
|
||||
// and a box that does not hold as many users as it says.
|
||||
func OpenHanded(h AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) {
|
||||
if h.To != KeyFingerprint(publicPEM) {
|
||||
return nil, fmt.Errorf("delivery: the handing is sealed to the key %s, not to this one (%s)", h.To,
|
||||
KeyFingerprint(publicPEM))
|
||||
}
|
||||
plain, err := seal.Open(h.Box, privatePEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delivery: the handing does not open: %w", err)
|
||||
}
|
||||
var out []HandedAccount
|
||||
if err := json.Unmarshal([]byte(plain), &out); err != nil {
|
||||
return nil, fmt.Errorf("delivery: the handing holds no accounts: %w", err)
|
||||
}
|
||||
if len(out) != h.Users {
|
||||
return nil, fmt.Errorf("delivery: the handing says %d users and holds %d", h.Users, len(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sameHanded says whether two handings say the same: the same list, key, count and box, whatever zone their time is in.
|
||||
func sameHanded(a, b *AccountsHanded) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
x, y := *a, *b
|
||||
x.At, y.At = time.Time{}, time.Time{}
|
||||
return x == y && a.At.Equal(b.At)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user