Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4fac090e | ||
|
|
dff0ce2abb | ||
|
|
c816666f83 | ||
|
|
2d717777b1 | ||
|
|
f913918a60 | ||
|
|
38a0bbca44 | ||
|
|
79ad8b539c | ||
|
|
90a5fc5797 | ||
|
|
362dddae40 | ||
|
|
9169830210 | ||
|
|
8c2ce57eee | ||
|
|
69270a30d5 | ||
|
|
d7a00e1f26 | ||
|
|
2ee75954ad | ||
|
|
959f57294a | ||
|
|
59b4c7910b | ||
|
|
5758e9fb57 | ||
|
|
c21d8fd969 | ||
|
|
bbfda29b49 | ||
|
|
3ed41f02d5 | ||
|
|
09a5eb5783 | ||
|
|
a4ed4e6742 | ||
|
|
d7a008bee5 | ||
|
|
feadebd331 | ||
|
|
6f483725e9 | ||
|
|
fdbb3d5784 | ||
|
|
ea8c50136c | ||
|
|
b2a27ed5ce | ||
|
|
5d26c658f5 | ||
|
|
621984614d | ||
|
|
0ef7c0caa4 | ||
|
|
67354f8e01 | ||
|
|
71be15f530 | ||
|
|
a6c6d936d5 | ||
|
|
cad8250aaa | ||
|
|
790d1c82d0 | ||
|
|
8696aa9ba4 | ||
|
|
db4eb3bbf4 | ||
|
|
da41823855 | ||
|
|
8af6e72669 | ||
|
|
62cddf4efc | ||
|
|
19c204273b | ||
|
|
a20bdf7952 |
+46
-13
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -24,7 +25,7 @@ type StepFunc func(ctx context.Context, args map[string]any) (any, error)
|
||||
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
|
||||
type AskSteps struct {
|
||||
Fetch, Build, Copy, Digest, Test StepFunc
|
||||
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
|
||||
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing
|
||||
// (an image or an archive names none).
|
||||
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
|
||||
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
|
||||
@@ -42,14 +43,30 @@ type AskSteps struct {
|
||||
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
|
||||
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
|
||||
if ask.Build != nil {
|
||||
return steps.build(ctx, *ask.Build)
|
||||
job := *ask.Build
|
||||
job.Fetch = withPackages(job.Fetch, ask.Packages)
|
||||
return steps.build(ctx, job)
|
||||
}
|
||||
if ask.Check != nil {
|
||||
return steps.check(ctx, *ask.Check)
|
||||
job := *ask.Check
|
||||
job.Fetch = withPackages(job.Fetch, ask.Packages)
|
||||
return steps.check(ctx, job)
|
||||
}
|
||||
return nil, Refuse("the ask %s holds no job", ask.ID)
|
||||
}
|
||||
|
||||
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
|
||||
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
|
||||
// fetches nothing, is left as it is.
|
||||
func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any {
|
||||
if len(packages) == 0 || fetch == nil {
|
||||
return fetch
|
||||
}
|
||||
out := maps.Clone(fetch)
|
||||
out["packages"] = maps.Clone(packages)
|
||||
return out
|
||||
}
|
||||
|
||||
func raw(v any) (json.RawMessage, error) {
|
||||
if r, ok := v.(json.RawMessage); ok {
|
||||
return r, nil
|
||||
@@ -90,19 +107,32 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
|
||||
return r, f, nil
|
||||
}
|
||||
|
||||
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
|
||||
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
|
||||
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
|
||||
func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) {
|
||||
if want == nil || s.Toolchain == nil {
|
||||
return nil, nil
|
||||
}
|
||||
tc, err := s.Toolchain(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc.Version != want.Version || tc.Recipe != want.Recipe {
|
||||
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
|
||||
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
|
||||
want.Version, want.Recipe, tc.Version, tc.Recipe)}
|
||||
}
|
||||
return &tc, nil
|
||||
}
|
||||
|
||||
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
|
||||
answer := delivery.BuildAnswer{}
|
||||
if job.Toolchain != nil && s.Toolchain != nil {
|
||||
tc, err := s.Toolchain(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
|
||||
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
|
||||
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
|
||||
}
|
||||
answer.Toolchain = &tc
|
||||
tc, err := s.onToolchain(ctx, job.Toolchain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer.Toolchain = tc
|
||||
var f fetched
|
||||
defer func() { s.release(f.Workspace) }()
|
||||
for _, a := range job.Artifacts {
|
||||
@@ -164,6 +194,9 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
|
||||
}
|
||||
|
||||
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
|
||||
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, f, err := s.fetch(ctx, job.Fetch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -74,15 +74,21 @@ func TestRunAskBuilds(t *testing.T) {
|
||||
steps := r.steps()
|
||||
var released []string
|
||||
steps.Release = func(ws string) error { released = append(released, ws); return nil }
|
||||
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
||||
// Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were.
|
||||
a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
|
||||
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})
|
||||
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
|
||||
out, err := RunAsk(context.Background(), steps, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, put := a.Build.Fetch["packages"]; put {
|
||||
t.Error("the ask's fetch arguments were changed")
|
||||
}
|
||||
got := strings.Join(r.calls, "\n")
|
||||
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
|
||||
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
|
||||
fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"}
|
||||
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
|
||||
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
||||
if got != want {
|
||||
@@ -91,13 +97,13 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
|
||||
if len(released) != 1 || released[0] != "/w/1" {
|
||||
t.Errorf("released %v, want the one workspace", released)
|
||||
}
|
||||
a := out.(delivery.BuildAnswer)
|
||||
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
|
||||
t.Fatalf("answer %+v", a)
|
||||
answer := out.(delivery.BuildAnswer)
|
||||
if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" {
|
||||
t.Fatalf("answer %+v", answer)
|
||||
}
|
||||
}
|
||||
|
||||
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
|
||||
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again.
|
||||
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
|
||||
s := r.steps()
|
||||
@@ -106,10 +112,16 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
|
||||
}
|
||||
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
|
||||
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
||||
var ref *Refusal
|
||||
if !errors.As(err, &ref) || len(r.calls) != 0 {
|
||||
var dec *Declining
|
||||
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 {
|
||||
t.Fatalf("err %v, calls %v", err, r.calls)
|
||||
}
|
||||
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
|
||||
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
|
||||
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
|
||||
if !errors.As(err, &dec) || len(r.calls) != 0 {
|
||||
t.Fatalf("a check ask: err %v, calls %v", err, r.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
|
||||
|
||||
@@ -538,6 +538,28 @@ func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does
|
||||
// before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left
|
||||
// alone until the next pull's wait.
|
||||
func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) {
|
||||
result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks})
|
||||
step.Ended = now().UTC()
|
||||
step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result
|
||||
if err := writeEnd(p.Put, key, step); 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: %s", ask.ID, p.Node, d.Text)
|
||||
_ = 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
|
||||
@@ -625,6 +647,11 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
|
||||
})
|
||||
step = ph.end() // its end keeps the phase it ended in
|
||||
close(done)
|
||||
var dec *Declining
|
||||
if errors.As(err, &dec) && ctx.Err() == nil {
|
||||
p.declineRun(msg, ask, key, step, dec)
|
||||
return
|
||||
}
|
||||
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.
|
||||
return
|
||||
|
||||
@@ -230,6 +230,21 @@ func TestPullDropsWhatIsNoAsk(t *testing.T) {
|
||||
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 })
|
||||
// A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds.
|
||||
sit, sitSteps := NewFakeAsks(), newSteps()
|
||||
toolchain := testAsk(delivery.AskDelivery)
|
||||
_ = sit.PublishAsk(toolchain)
|
||||
s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}}
|
||||
}}
|
||||
go s2.Pull(ctx)
|
||||
waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 })
|
||||
sk, _ := delivery.AskStepKey("ace", toolchain.ID)
|
||||
ss, _ := sitSteps.get(sk)
|
||||
if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") {
|
||||
t.Fatalf("the sitting-out step = %+v", ss)
|
||||
}
|
||||
}
|
||||
|
||||
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
|
||||
|
||||
@@ -16,3 +16,14 @@ func (r *Refusal) Error() string { return r.Text }
|
||||
|
||||
// Refuse is a Refusal with its text formatted.
|
||||
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
|
||||
|
||||
// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined),
|
||||
// the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a
|
||||
// toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority
|
||||
// sits out and never fails a build.
|
||||
type Declining struct {
|
||||
Text string
|
||||
Lacks []string // what this node lacks, in words the operator reads
|
||||
}
|
||||
|
||||
func (d *Declining) Error() string { return d.Text }
|
||||
|
||||
@@ -2,9 +2,12 @@ package buildstep
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -27,6 +30,10 @@ type FetchRequest struct {
|
||||
Base string `json:"base,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
|
||||
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
|
||||
// the repository names, checksum-verified, and names them in its log.
|
||||
Packages delivery.PackageVersions `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
|
||||
@@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any {
|
||||
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
|
||||
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
|
||||
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
|
||||
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any {
|
||||
if len(r.Paths) > 0 {
|
||||
args["paths"] = append([]string(nil), r.Paths...)
|
||||
}
|
||||
if len(r.Packages) > 0 {
|
||||
args["packages"] = map[string]any{}
|
||||
for p, v := range r.Packages {
|
||||
args["packages"].(map[string]any)[p] = v
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
|
||||
default:
|
||||
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
|
||||
}
|
||||
packages, err := PackagesArg(args["packages"])
|
||||
if err != nil {
|
||||
return FetchRequest{}, err
|
||||
}
|
||||
r.Packages = packages
|
||||
return r, r.Check()
|
||||
}
|
||||
|
||||
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
|
||||
// nil for none.
|
||||
func PackagesArg(v any) (delivery.PackageVersions, error) {
|
||||
out := delivery.PackageVersions{}
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case delivery.PackageVersions:
|
||||
maps.Copy(out, v)
|
||||
case map[string]string:
|
||||
maps.Copy(out, v)
|
||||
case map[string]any:
|
||||
for p, version := range v {
|
||||
s, ok := version.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
out[p] = s
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, out.Check()
|
||||
}
|
||||
|
||||
// Check is what ParseFetchRequest refuses.
|
||||
func (r FetchRequest) Check() error {
|
||||
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
|
||||
|
||||
@@ -10,11 +10,15 @@ import (
|
||||
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
|
||||
// clause 1). The request survives the trip through a tool call's arguments.
|
||||
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
|
||||
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
|
||||
Dir: ".", Paths: []string{"package.json", "src"}}
|
||||
Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
|
||||
args := req.Args()
|
||||
// A tool call's arguments arrive as JSON: lists of any.
|
||||
// A tool call's arguments arrive as JSON: lists of any, objects of any.
|
||||
args["paths"] = []any{"package.json", "src"}
|
||||
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
|
||||
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
|
||||
}
|
||||
got, err := ParseFetchRequest(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
||||
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
||||
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
||||
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
||||
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
|
||||
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
|
||||
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
|
||||
} {
|
||||
args := map[string]any{}
|
||||
for k, v := range ok {
|
||||
|
||||
@@ -25,6 +25,8 @@ type ModuleTree struct {
|
||||
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
|
||||
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
|
||||
Paths map[string]string `json:"paths,omitempty"`
|
||||
// Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2).
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
var lockFiles = []string{"go.sum", "package-lock.json"}
|
||||
@@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
|
||||
mt.Locks = map[string]string{}
|
||||
}
|
||||
mt.Locks[lock] = sha256Hex(raw)
|
||||
if lock == "go.sum" {
|
||||
mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2)
|
||||
}
|
||||
}
|
||||
}
|
||||
trees[mdir] = mt
|
||||
|
||||
@@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
|
||||
writeTreeFile(t, root, "module.json", `{"root":1}`)
|
||||
writeTreeFile(t, root, "go.sum", "rootsum")
|
||||
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
|
||||
writeTreeFile(t, root, "modules/a/go.sum", "asum")
|
||||
asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n"
|
||||
writeTreeFile(t, root, "modules/a/go.sum", asum)
|
||||
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
|
||||
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
|
||||
merge := strings.Repeat("c", 40)
|
||||
@@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]ModuleTree{
|
||||
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
|
||||
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
|
||||
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
|
||||
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf(asum), "package-lock.json": hexOf("alock")},
|
||||
Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names
|
||||
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
@@ -219,6 +219,10 @@ type LeaseHolder struct {
|
||||
// HandedBy is the credential that wrote it (the operator's), and Why its reason.
|
||||
HandedBy string `json:"handed-by,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
// Ask is the operator's ask whose warrant handed it (novox/hq ADR 0582 clause 3: "with the why, the ask and the
|
||||
// time"), and At when it was written. Neither turns the lease: LeaseHandedAway reads HandedTo alone.
|
||||
Ask string `json:"ask,omitempty"`
|
||||
At time.Time `json:"at,omitzero"`
|
||||
}
|
||||
|
||||
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
|
||||
|
||||
@@ -3,6 +3,7 @@ package buscompose
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -96,6 +97,9 @@ func engineBeyond(user, g string) bool {
|
||||
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
|
||||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
|
||||
// And, read, the controller's mirror's key of its node, until the lease write (novox/hq ADR 0565 clause 6).
|
||||
strings.Contains(g, "KV_"+DeclaredPackagesBucket) && !strings.HasPrefix(g, "$KV.") &&
|
||||
(!strings.Contains(g, "$KV.") || strings.HasSuffix(g, "$KV."+DeclaredPackagesBucket+"."+node)) ||
|
||||
// And its own health report (issue 774).
|
||||
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
|
||||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
|
||||
@@ -158,8 +162,11 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
|
||||
intended := beyondTheControllers(user)
|
||||
for g := range was {
|
||||
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
|
||||
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
|
||||
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
|
||||
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7). And it no longer hears
|
||||
// the subject, where the grants part stops publishing at the lease write (ADR 0571 clause 10, issue 774).
|
||||
node := strings.SplitN(user, ".", 2)[0]
|
||||
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+node+".") &&
|
||||
!strings.HasPrefix(g, "mesh.assignment."+node+".") {
|
||||
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
|
||||
}
|
||||
}
|
||||
@@ -413,6 +420,156 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
|
||||
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
|
||||
// is the one above (AC2: the controller's golden lists, unchanged).
|
||||
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
|
||||
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
|
||||
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
users, err := Users(Records{Nodes: []string{"ace", "g14"},
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
|
||||
NodeLogins: map[string]delivery.NodeLogin{
|
||||
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
|
||||
// A node that rotated before and rotates no more logs in as its second login alone.
|
||||
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNode {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms[u.Username()] = p
|
||||
}
|
||||
}
|
||||
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
|
||||
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
|
||||
}
|
||||
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
|
||||
own := func(p Permissions, login string) Permissions {
|
||||
swap := func(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
|
||||
AllowResponses: p.AllowResponses}
|
||||
}
|
||||
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
|
||||
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
|
||||
}
|
||||
for login, p := range perms {
|
||||
node, _ := delivery.LoginNode(login)
|
||||
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
|
||||
t.Errorf("%s's inbox: %v", login, p.Subscribe)
|
||||
}
|
||||
}
|
||||
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
|
||||
get := func(key string) string {
|
||||
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
|
||||
}
|
||||
confirm := func(login string) string {
|
||||
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
|
||||
}
|
||||
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
|
||||
confirm("node.g14--b"): true, confirm("node.g14"): false} {
|
||||
if MayPublish(perms["node.g14--b"], subject) != want {
|
||||
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
|
||||
}
|
||||
}
|
||||
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
|
||||
t.Error("a node reads node-logins while the vault does not rotate")
|
||||
}
|
||||
// And nothing but the grants part names node-logins among its reads.
|
||||
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
|
||||
t.Error("node-logins' named readers")
|
||||
}
|
||||
})
|
||||
// 774/S40/AC1 (ADR 0587 §4, §6): from the lease write an enrolling node's user may publish the redeem call in its
|
||||
// own name and nothing else, and subscribe its own inbox alone; before it, the controller's subject beside.
|
||||
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
|
||||
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||
for _, handed := range []bool{true, false} {
|
||||
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, LeaseHandedAway: handed,
|
||||
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var lab *Principal
|
||||
for i := range users {
|
||||
if users[i].Username() == "enrol.lab" {
|
||||
lab = &users[i]
|
||||
}
|
||||
}
|
||||
if lab == nil {
|
||||
t.Fatal("no user enrol.lab")
|
||||
}
|
||||
perms, err := PermissionsFor(*lab)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
||||
if !handed {
|
||||
want = []string{"mesh.control.enrol", "mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
|
||||
}
|
||||
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
|
||||
perms.AllowResponses {
|
||||
t.Errorf("handed %v: enrol.lab publishes %v, subscribes %v, answers %v", handed, perms.Publish, perms.Subscribe,
|
||||
perms.AllowResponses)
|
||||
}
|
||||
}
|
||||
// No holder serving redeem and the lease handed: the user may enrol nowhere, refused by name.
|
||||
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab", LeaseHandedAway: true}); err == nil {
|
||||
t.Error("an enrolment user that may enrol nowhere was composed")
|
||||
}
|
||||
})
|
||||
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
|
||||
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
|
||||
// however wide its grant; and its bucket is read by the grants part alone besides it.
|
||||
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
|
||||
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
|
||||
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
|
||||
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
|
||||
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
|
||||
{Module: "mesh-vault", Holds: []Seat{vault}},
|
||||
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
|
||||
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
|
||||
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
|
||||
{Module: "shop", Invokes: calls[:2]},
|
||||
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := u.Username()
|
||||
for _, verb := range []string{"enrolment-token", "node-account"} {
|
||||
enrols := name == "ace.mesh-enrolment"
|
||||
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
|
||||
t.Errorf("%s may call %s: %v", name, verb, !enrols)
|
||||
}
|
||||
}
|
||||
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
|
||||
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
|
||||
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
|
||||
}
|
||||
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
|
||||
t.Errorf("%s writes the enrolments", name)
|
||||
}
|
||||
}
|
||||
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
|
||||
t.Error("the enrolments' named readers")
|
||||
}
|
||||
})
|
||||
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(
|
||||
@@ -558,6 +715,9 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
}{
|
||||
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
|
||||
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
|
||||
// ADR 0582 clause 3 (774/S3): the write names the ask and the time, which turn nothing.
|
||||
{"handed to mesh-grants with its ask and time", `{"handed-to":"mesh-grants","handed-by":"person.jochen","why":"w",` +
|
||||
`"ask":"lease-write-1","at":"2026-11-02T10:00:00Z"}`, true, nil, true},
|
||||
{"expired or deleted", ``, false, nil, false},
|
||||
{"unreadable", ``, false, errors.New("refused"), false},
|
||||
{"not a holder", `[1]`, true, nil, false},
|
||||
@@ -633,6 +793,48 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
|
||||
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
|
||||
}
|
||||
}
|
||||
// ADR 0582 clause 3 (issue 774, 774/S2): the lease write's cut takes from the controller the lease's key and every
|
||||
// update, purge and delete of its bucket, and gives the grants part the key holder alone; without it nothing
|
||||
// changes (the golden lists above are composed without it).
|
||||
lease := "$KV." + LeaseBucket + ".holder"
|
||||
for _, cut := range []bool{false, true} {
|
||||
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: GrantsModule}}},
|
||||
LeaseCut: cut})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
kind Kind
|
||||
subject string
|
||||
may bool
|
||||
}{
|
||||
{KindController, lease, !cut},
|
||||
{KindController, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, !cut},
|
||||
{KindController, "$JS.API.STREAM.PURGE.KV_" + LeaseBucket, !cut},
|
||||
{KindController, "$JS.API.STREAM.DELETE.KV_" + LeaseBucket, !cut},
|
||||
{KindController, "$JS.API.STREAM.MSG.DELETE.KV_" + LeaseBucket, !cut},
|
||||
// It still reads the lease, and keeps its other buckets.
|
||||
{KindController, "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder", true},
|
||||
{KindController, "$KV." + CallsBucket + ".x", true},
|
||||
{KindController, "$JS.API.STREAM.UPDATE.KV_" + CallsBucket, true},
|
||||
{KindModule, lease, cut},
|
||||
{KindModule, "$KV." + LeaseBucket + ".other", false},
|
||||
{KindModule, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, false},
|
||||
} {
|
||||
for _, u := range users {
|
||||
if u.Kind != c.kind || (c.kind == KindModule && u.Module != GrantsModule) {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("cut %v: %s: %v", cut, u.Username(), err)
|
||||
}
|
||||
if MayPublish(perms, c.subject) != c.may {
|
||||
t.Errorf("cut %v: %s may publish %s: %v, want %v", cut, u.Username(), c.subject, !c.may, c.may)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
|
||||
healthChecks(t)
|
||||
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
|
||||
@@ -740,7 +942,9 @@ func healthChecks(t *testing.T) {
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
|
||||
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
|
||||
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
|
||||
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
|
||||
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
|
||||
// ADR 0582 clause 3 (issue 774, 774/S2): the lease's bucket's expiry, read and set by its tool.
|
||||
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
|
||||
}
|
||||
mayNot := map[string][]string{
|
||||
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
|
||||
@@ -783,8 +987,13 @@ func healthChecks(t *testing.T) {
|
||||
// outside the bus module).
|
||||
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
|
||||
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
|
||||
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
|
||||
// It updates the lease's bucket alone, and neither purges nor deletes it nor writes its key (ADR 0582 clause 3).
|
||||
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
|
||||
"$JS.API.STREAM.PURGE.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
|
||||
"$KV.mesh-controller_lease.holder"},
|
||||
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
|
||||
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
|
||||
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
|
||||
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
|
||||
}
|
||||
@@ -815,12 +1024,27 @@ func healthChecks(t *testing.T) {
|
||||
MayPublish(perms, "$KV.mesh-host_health.b")) {
|
||||
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
|
||||
}
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
|
||||
// ADR 0565 clause 6: and it reads the controller's mirror's key of its own node alone, and writes none of it.
|
||||
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
|
||||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a") ||
|
||||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.b") ||
|
||||
MayPublish(perms, "$KV.mesh-controller_declared-packages.a")) {
|
||||
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
|
||||
}
|
||||
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
|
||||
t.Errorf("%s writes the assigner's policies", u.Username())
|
||||
}
|
||||
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
|
||||
// the grants part's state.
|
||||
if MaySubscribe(perms, "mesh.assignment.a.restic") {
|
||||
t.Errorf("%s hears mesh.assignment", u.Username())
|
||||
}
|
||||
}
|
||||
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
|
||||
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
|
||||
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
|
||||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
|
||||
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
|
||||
}
|
||||
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
|
||||
// long as the log (ADR 0297).
|
||||
@@ -878,7 +1102,10 @@ func healthChecks(t *testing.T) {
|
||||
// It hears its watchdogs' words on core subjects (Limits 3, 11).
|
||||
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
|
||||
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
|
||||
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
|
||||
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak",
|
||||
// ADR 0582 clauses 4, 6 (issue 774, 774/S22): the lease write's events, and ASSIGNMENTS deleted.
|
||||
"mesh.seat.mesh-grants.event.lease-altered", "mesh.seat.mesh-grants.event.removal-failed",
|
||||
"mesh.seat.mesh-grants.event.lease-handed", "$JS.EVENT.ADVISORY.STREAM.DELETED.ASSIGNMENTS"} {
|
||||
if !MaySubscribe(perms, subject) {
|
||||
t.Errorf("the watchman may not hear %s", subject)
|
||||
}
|
||||
|
||||
@@ -74,9 +74,11 @@ func engineGrants(p Principal) (pub, sub []string) {
|
||||
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
|
||||
pub = append(pub, own...)
|
||||
}
|
||||
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
|
||||
// And its node's declared packages, its own key alone (ADR 0565 clause 6); and, read, the controller's mirror's key
|
||||
// of its node, which the node seat's verb packages compares it with until the lease write ends the mirror.
|
||||
if safeSubject.MatchString(p.Node) {
|
||||
pub = append(pub, EnginePackagesGrants(p.Node)...)
|
||||
pub = append(pub, readGrants(DeclaredPackagesBucket, p.Node)...)
|
||||
}
|
||||
return append(pub, tp...), append(sub, ts...)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func WatchmanDeadLetterGrants(sources []string) []string {
|
||||
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
|
||||
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
|
||||
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
|
||||
// connections (S9), the provisioner clients' reports (S21), and the dead letters kept. Never the bus's API audit
|
||||
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
|
||||
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
|
||||
// them (the lead, 2026-10-29).
|
||||
func WatchmanHears() []string {
|
||||
@@ -129,6 +129,12 @@ func WatchmanHears() []string {
|
||||
"mesh.mod.nats.event.bus.connection-closed",
|
||||
ProvAppliedSubjects,
|
||||
deadLetterPrefix + ">",
|
||||
// And the lease write's (ADR 0582 clauses 4, 6; issue 774): the seat mesh-grants' three events, and the stream
|
||||
// ASSIGNMENTS deleted, which with the controller's consumer deleted clears a removal that failed.
|
||||
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseHanded,
|
||||
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseAltered,
|
||||
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventRemovalFailed,
|
||||
"$JS.EVENT.ADVISORY.STREAM.DELETED." + AssignmentsStream,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package buscompose
|
||||
|
||||
import "slices"
|
||||
|
||||
// **The lease write's grants** (novox/hq ADR 0582 clause 3, issue 774). Composed with Records.LeaseCut, the list takes
|
||||
// from the controller its write of the lease's key and every update, purge and delete of the lease's bucket, and gives
|
||||
// the grants part the key `holder`, which it creates naming itself and deletes at a hand-back (clause 5). Whatever the
|
||||
// records, the bus module's tools account may read and update the lease's bucket, for the tool that removes and
|
||||
// restores its expiry; no other bucket's.
|
||||
|
||||
// leaseStream is the lease bucket's stream.
|
||||
var leaseStream = "KV_" + LeaseBucket
|
||||
|
||||
// LeaseHolderWrite is the grants part's write of the lease's one key, from the cut.
|
||||
func LeaseHolderWrite() string { return "$KV." + LeaseBucket + "." + leaseKey }
|
||||
|
||||
// LeaseCutDenied are the controller's subjects of the lease's bucket that its `$JS.API.>` reaches and the cut refuses:
|
||||
// the bucket's update (its expiry), purge and delete, and a message's delete (the key's last value).
|
||||
func LeaseCutDenied() []string {
|
||||
return []string{"$JS.API.STREAM.UPDATE." + leaseStream, "$JS.API.STREAM.PURGE." + leaseStream,
|
||||
"$JS.API.STREAM.DELETE." + leaseStream, "$JS.API.STREAM.MSG.DELETE." + leaseStream}
|
||||
}
|
||||
|
||||
// LeaseExpiryGrants are the bus module's tools account's read and update of the lease's bucket, for its expiry.
|
||||
func LeaseExpiryGrants() []string {
|
||||
return []string{"$JS.API.STREAM.INFO." + leaseStream, "$JS.API.STREAM.UPDATE." + leaseStream}
|
||||
}
|
||||
|
||||
// writesTheLease is the controller holding the lease, or, from the cut, the grants part writing its key holder alone.
|
||||
func writesTheLease(p Principal, pattern string) bool {
|
||||
if p.Kind == KindController {
|
||||
return true
|
||||
}
|
||||
return p.Kind == KindModule && p.Module == GrantsModule && p.LeaseCut && pattern == LeaseHolderWrite()
|
||||
}
|
||||
|
||||
// setsLeaseExpiry is the bus module's tools account updating the lease's bucket: the writers table's one exemption from
|
||||
// stream definitions being the controller's beside ASSIGNMENTS' removal (ADR 0582 clause 3).
|
||||
func setsLeaseExpiry(p Principal, pattern string) bool {
|
||||
return p.Kind == KindProcess && len(p.Removes) > 0 && slices.Contains(LeaseExpiryGrants(), pattern)
|
||||
}
|
||||
+131
-10
@@ -22,6 +22,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -201,6 +202,9 @@ type Principal struct {
|
||||
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
|
||||
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
|
||||
Removes []string
|
||||
// LeaseCut is Records.LeaseCut on the controller and the grants part (novox/hq ADR 0582 clause 3): the controller
|
||||
// loses its write of the lease, and the grants part may write the key `holder`.
|
||||
LeaseCut bool
|
||||
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
|
||||
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
|
||||
DeadLetterSources []string
|
||||
@@ -250,6 +254,26 @@ type Principal struct {
|
||||
// stream and name. Created only, never updated or deleted.
|
||||
CreatesConsumers []ModuleConsumer
|
||||
|
||||
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
|
||||
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
|
||||
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
|
||||
// vault tells the login that confirms a rotation from the one it replaces (§3).
|
||||
Login string
|
||||
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
|
||||
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
|
||||
// §3). Set while the vault's holder serves rotate-node.
|
||||
RotatesItsLogin bool
|
||||
|
||||
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
|
||||
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
|
||||
// enrolment subject from it, which is served no more from the lease write (§6).
|
||||
Redeems bool
|
||||
LeaseHandedAway bool
|
||||
|
||||
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
|
||||
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
|
||||
VaultEnrols bool
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||
@@ -300,6 +324,22 @@ func ControllerOnly() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587
|
||||
// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired.
|
||||
var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb},
|
||||
{Seat: VaultSeat, Verb: delivery.NodeAccountVerb}}
|
||||
|
||||
// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the
|
||||
// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them).
|
||||
func EnrolmentOnly() []string {
|
||||
var out []string
|
||||
for _, v := range VerbsTheEnrolmentAsksTheVault {
|
||||
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>",
|
||||
"mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
|
||||
func MayPublish(perms Permissions, subject string) bool {
|
||||
for _, d := range perms.PublishDeny {
|
||||
@@ -377,6 +417,33 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
|
||||
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
|
||||
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
|
||||
|
||||
// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node
|
||||
// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a
|
||||
// node-engine confirms a switch with, in its new login's name (§3).
|
||||
var (
|
||||
VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb}
|
||||
VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb}
|
||||
)
|
||||
|
||||
// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol.<node> (novox/hq ADR 0587 §4,
|
||||
// §6).
|
||||
var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem}
|
||||
|
||||
// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its
|
||||
// own name.
|
||||
func EnrolmentRedeemCall(p Principal) string {
|
||||
return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username())
|
||||
}
|
||||
|
||||
// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched,
|
||||
// and no other (novox/hq ADR 0583 §1 insight).
|
||||
func NodeLoginsReads(node string) []string {
|
||||
if !safeSubject.MatchString(node) {
|
||||
return nil
|
||||
}
|
||||
return readGrants(delivery.NodeLoginsBucket, node)
|
||||
}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
|
||||
@@ -525,6 +592,9 @@ func (p Principal) Username() string {
|
||||
// never the module's own name alone, which is a different account with a different secret.
|
||||
return p.Node + "." + p.Module + "." + p.Process
|
||||
case KindNode:
|
||||
if p.Login != "" {
|
||||
return p.Login
|
||||
}
|
||||
return "node." + p.Node
|
||||
case KindController:
|
||||
return "controller"
|
||||
@@ -584,7 +654,14 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
|
||||
//
|
||||
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
|
||||
func (p Principal) inbox() string {
|
||||
if p.Kind == KindNode {
|
||||
return "_INBOX.node." + p.Node + ".>"
|
||||
}
|
||||
return "_INBOX." + p.Username() + ".>"
|
||||
}
|
||||
|
||||
// Permissions is what a principal may publish and subscribe, and whether it may answer.
|
||||
type Permissions struct {
|
||||
@@ -668,6 +745,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
// And the lease's bucket's expiry, read and set, by the bus module's tool the grants part calls at the lease
|
||||
// write and its hand-back (novox/hq ADR 0582 clauses 3, 5): the one bucket whose stream it may update.
|
||||
pub = append(pub, LeaseExpiryGrants()...)
|
||||
sort.Strings(pub)
|
||||
if err := CheckWriters(p, pub); err != nil {
|
||||
return Permissions{}, err
|
||||
@@ -801,6 +881,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if bucket == delivery.HaveBucket {
|
||||
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
|
||||
}
|
||||
if bucket == LeaseBucket && p.LeaseCut {
|
||||
continue // the lease write took it (novox/hq ADR 0582 clause 3): a controller started fails on the lease
|
||||
}
|
||||
pub = append(pub, "$KV."+bucket+".>")
|
||||
}
|
||||
// **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the
|
||||
@@ -880,7 +963,20 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
"enrolment: a token is issued for a node record, and that record's name is " +
|
||||
"the token's id")
|
||||
}
|
||||
pub = []string{"mesh.control.enrol"}
|
||||
// **From the lease write a node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6): its
|
||||
// user calls redeem in its own name, while the seat's holder serves it, and the controller's enrolment subject is
|
||||
// kept only while the controller holds the lease.
|
||||
pub = nil
|
||||
if !p.LeaseHandedAway {
|
||||
pub = append(pub, "mesh.control.enrol")
|
||||
}
|
||||
if p.Redeems {
|
||||
pub = append(pub, EnrolmentRedeemCall(p))
|
||||
}
|
||||
if len(pub) == 0 {
|
||||
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: the lease is handed away and "+
|
||||
"no holder of %s serves %s", p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
|
||||
}
|
||||
sub = []string{p.inbox()}
|
||||
|
||||
case KindNode:
|
||||
@@ -994,6 +1090,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
|
||||
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
|
||||
}
|
||||
// And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other
|
||||
// key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3).
|
||||
if p.RotatesItsLogin {
|
||||
pub = append(pub, NodeLoginsReads(p.Node)...)
|
||||
pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+
|
||||
CallerToken(p.Username()))
|
||||
}
|
||||
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
|
||||
ep, es := engineGrants(p)
|
||||
pub = append(pub, ep...)
|
||||
@@ -1026,6 +1129,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...)
|
||||
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
|
||||
pub = append(pub, GrantsUserListWrites()...)
|
||||
// And, from the lease write's cut, the lease's one key, which it creates naming itself and deletes at a
|
||||
// hand-back (novox/hq ADR 0582 clauses 3, 5): never the bucket's other keys or its stream.
|
||||
if p.LeaseCut {
|
||||
pub = append(pub, LeaseHolderWrite())
|
||||
}
|
||||
}
|
||||
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
|
||||
if p.Module == ViewModule {
|
||||
@@ -1061,10 +1169,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
sub = append(sub, announcing(announced...)...)
|
||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
|
||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
||||
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it,
|
||||
// since the assignments stream's direct get went with the stream (research 072 row 33).
|
||||
// Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause
|
||||
// 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33),
|
||||
// and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause
|
||||
// 10, issue 774).
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
|
||||
|
||||
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
||||
@@ -1226,10 +1334,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
|
||||
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
|
||||
// that varies is the module, so the pattern is the machine's own assignments.
|
||||
sub = append(sub, "mesh.assignment."+p.Node+".*")
|
||||
// Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the
|
||||
// grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants
|
||||
// part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the
|
||||
// one token that varies is the module, so the pattern is the machine's own assignments.
|
||||
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
|
||||
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
|
||||
// name and no other's, on core NATS like the host's.
|
||||
@@ -1408,6 +1516,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
// What the controller alone may publish is denied to everybody else whose grant reaches it.
|
||||
var deny []string
|
||||
// And from the lease write's cut, the lease's bucket to the controller, whose `$JS.API.>` reaches it (novox/hq ADR
|
||||
// 0582 clause 3): a controller started by mistake fails on the lease instead of taking it back.
|
||||
if p.Kind == KindController && p.LeaseCut {
|
||||
deny = append(deny, LeaseCutDenied()...)
|
||||
}
|
||||
if p.Kind != KindController {
|
||||
for _, only := range ControllerOnly() {
|
||||
for _, a := range pub {
|
||||
@@ -1421,6 +1534,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1).
|
||||
if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) {
|
||||
for _, only := range EnrolmentOnly() {
|
||||
if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) {
|
||||
deny = append(deny, only)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Permissions{
|
||||
Publish: pub,
|
||||
PublishDeny: deny,
|
||||
|
||||
@@ -104,7 +104,12 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA
|
||||
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
|
||||
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
|
||||
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
|
||||
delivery.EngineDataRead: {health.DiskLoadModule}}
|
||||
delivery.EngineDataRead: {health.DiskLoadModule},
|
||||
// ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its
|
||||
// machine principal (NodeLoginsReads), never through `reads`.
|
||||
delivery.NodeLoginsRead: {GrantsModule},
|
||||
// ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part.
|
||||
delivery.EnrolmentsRead: {GrantsModule}}
|
||||
|
||||
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
|
||||
// that names none.
|
||||
|
||||
+58
-8
@@ -2,10 +2,12 @@ package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Every user the composed file should contain, derived from what the mesh knows.
|
||||
@@ -129,7 +131,8 @@ type Records struct {
|
||||
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).
|
||||
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
|
||||
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
|
||||
Enrolling []string
|
||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||
People map[string][]string
|
||||
@@ -145,9 +148,22 @@ type Records struct {
|
||||
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
|
||||
// Nil where its manifest was not read.
|
||||
Engine *Declared
|
||||
// LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part
|
||||
// reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The
|
||||
// controller's composer never sets it. It is not LeaseCut: the cut is composed before the holder is written, so the
|
||||
// grants part may write it, and outlasts the holder at a hand-back; the two are true together only in between.
|
||||
LeaseHandedAway bool
|
||||
// NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one
|
||||
// rotating, each composed as a user of the node's permissions. A node absent logs in as `node.<n>` alone.
|
||||
NodeLogins map[string]delivery.NodeLogin
|
||||
// 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
|
||||
// LeaseCut composes the list of the lease write (novox/hq ADR 0582 clause 3, issue 774): the controller may neither
|
||||
// write the lease's key nor update, purge or delete its bucket, and the grants part may write the key `holder`.
|
||||
// Set by the grants part from the write's second step until a hand-back composes without it (clause 5); the
|
||||
// controller's composer never sets it.
|
||||
LeaseCut bool
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
@@ -157,7 +173,7 @@ type Records struct {
|
||||
// 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)}}
|
||||
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes), LeaseCut: r.LeaseCut}}
|
||||
|
||||
served := servedReadsOf(r)
|
||||
logs := DeclaredLogs(r)
|
||||
@@ -175,10 +191,14 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
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), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
|
||||
// **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too,
|
||||
// each with the node's permissions.
|
||||
for _, login := range nodeLoginsOf(r, node) {
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, Login: login, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
|
||||
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
|
||||
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)})
|
||||
}
|
||||
// **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] {
|
||||
@@ -246,6 +266,7 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
out[len(out)-1].LeaseCut = r.LeaseCut
|
||||
grants = len(out) - 1
|
||||
}
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
@@ -269,7 +290,8 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
}
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node})
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: servesVerb(r, EnrolmentRedeem),
|
||||
LeaseHandedAway: r.LeaseHandedAway})
|
||||
}
|
||||
for _, person := range sortedNames(r.People) {
|
||||
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
|
||||
@@ -280,6 +302,13 @@ func Users(r Records) ([]Principal, error) {
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
// The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1).
|
||||
if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) {
|
||||
for i := range out {
|
||||
out[i].VaultEnrols = true
|
||||
}
|
||||
}
|
||||
|
||||
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
|
||||
if grants >= 0 {
|
||||
out[grants].CreatesConsumers = AccountConsumers(out)
|
||||
@@ -379,6 +408,27 @@ func asksSeatOf(r Records) []Seat {
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
|
||||
// node-logins key says is in use and the one rotating, in that order; `node.<n>` alone when the key is absent or does
|
||||
// not hold (a key naming another node's login is no login of this node's).
|
||||
func nodeLoginsOf(r Records, node string) []string {
|
||||
l, ok := r.NodeLogins[node]
|
||||
if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) {
|
||||
return []string{""}
|
||||
}
|
||||
login := func(name string) string {
|
||||
if name == delivery.FirstLogin(node) {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
out := []string{login(l.Login)}
|
||||
if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) {
|
||||
out = append(out, login(l.Rotating))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||
const VaultSeat = "mesh-vault"
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func createsDeclared(p Principal, pattern string) bool {
|
||||
if isController(p, pattern) {
|
||||
return true
|
||||
}
|
||||
if removesAssignmentsStream(p, pattern) {
|
||||
if removesAssignmentsStream(p, pattern) || setsLeaseExpiry(p, pattern) {
|
||||
return true
|
||||
}
|
||||
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
|
||||
@@ -96,6 +96,19 @@ func ownModule(p Principal, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it.
|
||||
func moduleNamed(module string) func(Principal, string) bool {
|
||||
return func(p Principal, _ string) bool {
|
||||
switch p.Kind {
|
||||
case KindModule, KindProcess:
|
||||
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 {
|
||||
@@ -158,7 +171,8 @@ var WritersTable = []WriterRow{
|
||||
// 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},
|
||||
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: writesTheLease,
|
||||
Shared: "from the lease write's cut the controller writes it no more and the grants part writes its key holder alone (novox/hq ADR 0582 clause 3)"},
|
||||
// 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",
|
||||
@@ -188,7 +202,7 @@ var WritersTable = []WriterRow{
|
||||
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); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"},
|
||||
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); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33), and updates the lease's bucket for its expiry alone (novox/hq ADR 0582 clause 3)"},
|
||||
{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"},
|
||||
@@ -217,6 +231,16 @@ var WritersTable = []WriterRow{
|
||||
Writes: ownModule},
|
||||
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
|
||||
Others: "—"},
|
||||
// The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a
|
||||
// compare-and-set at the revision read; the grants part reads them as a named reader.
|
||||
{State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set",
|
||||
KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry",
|
||||
Others: "the grants part reads them (a named reader) and composes each enrol.<node> user from them",
|
||||
Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)},
|
||||
// Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it.
|
||||
{State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket,
|
||||
Others: "the grants part reads every key, each node's engine its own node's key alone",
|
||||
Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)},
|
||||
{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,
|
||||
@@ -288,7 +312,8 @@ var WritersTable = []WriterRow{
|
||||
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,
|
||||
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages +
|
||||
", and each node's engine its node's key, until the lease write (ADR 0565 clause 6)",
|
||||
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
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **What still depends on the controller, and the lease write's check** (novox/hq ADR 0571 §13, ADR 0582 clause 2,
|
||||
// issue 774): every declaration of a manifest other than the controller's on the controller (a read of one of its
|
||||
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, a use of its seat), judged
|
||||
// against the named, temporary list of what another accepted record keeps until the lease write. The grants part's
|
||||
// grants_lease_check runs it over every manifest the indexer holds; ManifestRefusals runs it over a repository's
|
||||
// manifests, in the test ADR 0571 §13 names in mesh-catalog, mesh-delivery and mesh-tools. A read of the lease key is no
|
||||
// dependency: it names the lease's holder, whoever that is, and parts read it to learn that the write happened.
|
||||
|
||||
// ControllerLease is the controller's lease as a reader names it (`<module>.<state>`): no dependency.
|
||||
const ControllerLease = ControllerSeatName + ".lease"
|
||||
|
||||
// The kinds of a dependency on the controller, as a manifest declares them.
|
||||
const (
|
||||
DependsRead = "read"
|
||||
DependsInvoke = "invoke"
|
||||
DependsConsume = "consume"
|
||||
DependsUses = "uses"
|
||||
DependsWaits = "waits"
|
||||
)
|
||||
|
||||
// The phases of the lease write's check (ADR 0571 §13): before the lease write's own delivery the whole named list is
|
||||
// allowed; that delivery drops every entry but the three kept to the write itself, and its check runs with those alone.
|
||||
const (
|
||||
PhaseBefore = "before-delivery"
|
||||
PhaseWrite = "write"
|
||||
)
|
||||
|
||||
// Dependency is one declaration of a manifest other than the controller's on the controller: a read of one of its
|
||||
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, or a send to its seat.
|
||||
type Dependency struct {
|
||||
Module string `json:"module"`
|
||||
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
|
||||
By string `json:"by,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
What string `json:"what"`
|
||||
// Kept is the record that keeps it until the lease write, from the named list; empty when none does.
|
||||
Kept string `json:"kept,omitempty"`
|
||||
// ToTheWrite says it is one of the three kept to the write itself, which the lease write's own delivery keeps.
|
||||
ToTheWrite bool `json:"to_the_write,omitempty"`
|
||||
}
|
||||
|
||||
// KeptDependency is one entry of the named, temporary list ADR 0571 §13 allows: a read, invoke or consume another
|
||||
// accepted record keeps until the lease write, with that record. ToTheWrite marks the three kept to the write itself.
|
||||
type KeptDependency struct {
|
||||
Module, Kind, What, Record string
|
||||
ToTheWrite bool
|
||||
}
|
||||
|
||||
// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. It
|
||||
// holds the three kept to the write itself and nothing else: the lease write's own delivery dropped every other entry
|
||||
// (ADR 0571 §13; issue 774), so its check runs with these alone in either phase. The third, the bus node agent's
|
||||
// following of the controller's user list (ADR 0571 §8, ADR 0547 §8), is no manifest's declaration but the
|
||||
// node-engine's own, so no manifest matches it; it stays named here so the list reads as the record has it.
|
||||
var KeepList = []KeptDependency{
|
||||
{Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true},
|
||||
{Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true},
|
||||
{Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true},
|
||||
}
|
||||
|
||||
// EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved.
|
||||
// The controller's `grants` is one: ADR 0571 §9 has no manifest read it from L7's delivery, which changes ADR 0567 §10
|
||||
// and ADR 0547 §5's shadow compare, so neither the assigner's nor the grants part's read of it is kept.
|
||||
var EndedBy = map[string]string{
|
||||
DependsRead + " mesh-controller.grants": "ADR 0571 §9: no manifest reads the controller's grants; the grants part's key is the one read",
|
||||
DependsInvoke + " seat:mesh-controller.hand-act": "ADR 0565 §11: every recorder calls mesh-hand-acts",
|
||||
DependsInvoke + " seat:mesh-controller.warranted": "ADR 0565 §11: every recorder calls mesh-hand-acts",
|
||||
DependsInvoke + " seat:mesh-controller.root-free": "ADR 0571 §11: each machine's engine answers root-free (seat:node-engine.root-free@machine)",
|
||||
DependsInvoke + " seat:mesh-controller.held": "ADR 0571 §2: held is the seat mesh-artifact-sweep's",
|
||||
}
|
||||
|
||||
// keptFor is the list's entry for one dependency, or nil.
|
||||
func keptFor(d Dependency) *KeptDependency {
|
||||
for i, k := range KeepList {
|
||||
if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What {
|
||||
return &KeepList[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// onController says whether a name `<owner>.<rest>` is the controller's.
|
||||
func onController(name string) bool { return strings.HasPrefix(name, ControllerSeatName+".") }
|
||||
|
||||
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
|
||||
// it is the console's grant to every tool, judged by no record as a dependency.
|
||||
func invokesController(invoke string) bool {
|
||||
if seat, _, ok := SeatInvoke(invoke); ok {
|
||||
return seat == ControllerSeatName
|
||||
}
|
||||
return onController(invoke)
|
||||
}
|
||||
|
||||
// Dependencies are every declaration on the controller in the manifests given, by module, each with the list's entry
|
||||
// that keeps it. The controller's own manifest is not judged, nor a read of its lease key: it names the lease's holder,
|
||||
// whoever that is, and parts read it to learn that the write happened (ADR 0571 §13).
|
||||
func Dependencies(manifests map[string]Manifest) []Dependency {
|
||||
var out []Dependency
|
||||
add := func(module, by, kind, what string) {
|
||||
d := Dependency{Module: module, By: by, Kind: kind, What: what}
|
||||
if k := keptFor(d); k != nil {
|
||||
d.Kept, d.ToTheWrite = k.Record, k.ToTheWrite
|
||||
}
|
||||
if !slices.Contains(out, d) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
for name, m := range manifests {
|
||||
if name == ControllerSeatName || m.Module == ControllerSeatName {
|
||||
continue
|
||||
}
|
||||
for _, r := range m.Reads {
|
||||
if onController(r) && r != ControllerLease {
|
||||
add(name, "", DependsRead, r)
|
||||
}
|
||||
}
|
||||
for _, i := range m.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, "", DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
for _, c := range m.Consumes {
|
||||
if onController(c) {
|
||||
add(name, "", DependsConsume, c)
|
||||
}
|
||||
}
|
||||
for _, w := range m.Waits {
|
||||
if onController(w) {
|
||||
add(name, "", DependsWaits, w)
|
||||
}
|
||||
}
|
||||
for _, u := range m.Uses {
|
||||
if u == ControllerSeatName {
|
||||
add(name, "", DependsUses, u)
|
||||
}
|
||||
}
|
||||
for _, a := range m.BusAccounts() {
|
||||
by := "process " + a.ID
|
||||
if a.Tools {
|
||||
by = "tools " + a.ID
|
||||
}
|
||||
for _, i := range a.Bus.Invokes {
|
||||
if invokesController(i) {
|
||||
add(name, by, DependsInvoke, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
if a.Module != b.Module {
|
||||
return a.Module < b.Module
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
if a.What != b.What {
|
||||
return a.What < b.What
|
||||
}
|
||||
return a.By < b.By
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Verdict is the lease write's check in one phase.
|
||||
type Verdict struct {
|
||||
Phase string `json:"phase"`
|
||||
Passes bool `json:"passes"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
// Coverage is how much of the mesh's manifests the dependencies were derived from: the manifests read, the modules the
|
||||
// indexer holds (-1 when its keys could not be listed, which Unread then names), and each manifest that could not be
|
||||
// read.
|
||||
type Coverage struct {
|
||||
Read, Indexed int
|
||||
Unread []Unread
|
||||
}
|
||||
|
||||
// Unread is one manifest, or one listing of them, that could not be read, and why.
|
||||
type Unread struct {
|
||||
Input string `json:"input"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Judge runs the lease write's check over the dependencies (ADR 0571 §13) in one phase: before the lease write's own
|
||||
// delivery, every dependency must be on the named list; at the write, only the three kept to the write itself may stay.
|
||||
// It passes only over every module the indexer holds: no manifest read, fewer read than the indexer holds, or one that
|
||||
// could not be read fails it, since what was not read could hide a dependency. A lease already handed away leaves
|
||||
// nothing to write.
|
||||
func Judge(deps []Dependency, phase string, cov Coverage, handedAway bool, leaseSays string) Verdict {
|
||||
v := Verdict{Phase: phase, Reasons: []string{}}
|
||||
if handedAway {
|
||||
v.Reasons = append(v.Reasons, "there is nothing to write: "+leaseSays)
|
||||
}
|
||||
switch {
|
||||
case cov.Read == 0:
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("no manifest was read from the indexer (it holds %d modules): with nothing read, nothing on the controller can be judged absent", max(cov.Indexed, 0)))
|
||||
case cov.Indexed >= 0 && cov.Read < cov.Indexed:
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("only %d of the %d modules the indexer holds had their manifest read, so what the others declare on the controller is not known", cov.Read, cov.Indexed))
|
||||
}
|
||||
for _, d := range deps {
|
||||
who := d.Module
|
||||
if d.By != "" {
|
||||
who += " (" + d.By + ")"
|
||||
}
|
||||
switch {
|
||||
case d.Kept == "":
|
||||
why := fmt.Sprintf("%s declares %s %s, which no record keeps until the lease write (ADR 0571 §13)", who, d.Kind, d.What)
|
||||
if ended := EndedBy[d.Kind+" "+d.What]; ended != "" {
|
||||
why += "; ended by " + ended
|
||||
}
|
||||
v.Reasons = append(v.Reasons, why)
|
||||
case phase == PhaseWrite && !d.ToTheWrite:
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, kept by %s only until the lease write's own delivery, which drops it", who, d.Kind, d.What, d.Kept))
|
||||
}
|
||||
}
|
||||
for _, g := range cov.Unread {
|
||||
v.Reasons = append(v.Reasons, fmt.Sprintf("%s could not be read (%s), so what it declares on the controller is not known", g.Input, g.Why))
|
||||
}
|
||||
v.Passes = len(v.Reasons) == 0
|
||||
return v
|
||||
}
|
||||
|
||||
// ManifestRefusals is the lease write's check over a repository's manifests, by module name, in one phase (PhaseBefore
|
||||
// or PhaseWrite): each reason the check gives, none when it passes. Every manifest given counts as read: the caller reads
|
||||
// them from its own tree, and none given fails it.
|
||||
func ManifestRefusals(manifests map[string]Manifest, phase string) []string {
|
||||
return Judge(Dependencies(manifests), phase, Coverage{Read: len(manifests), Indexed: -1}, false, "").Reasons
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 774 (ADR 0571 §13, ADR 0582 clause 2): the lease write's check judges every manifest's dependency on the
|
||||
// controller against the named list, at the write (the three kept to it alone) and before its delivery. Moved with the
|
||||
// judge from mesh-grants' leasecheck_test.go (TestTheLeaseCheckJudgesEveryDependencyOnTheController's manifest rows and
|
||||
// TestTheKeepListNamesItsRecordsAndThreeToTheWrite), whose manifests were mesh-grants' fixtures; the conditions row is
|
||||
// mesh-conditions' manifest at mesh-catalog ce3bedd5 (module.json:31-32).
|
||||
func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) {
|
||||
toTheWrite := 0
|
||||
for _, k := range KeepList {
|
||||
if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" {
|
||||
t.Errorf("entry %+v names no record, module or declaration", k)
|
||||
}
|
||||
if k.ToTheWrite {
|
||||
toTheWrite++
|
||||
}
|
||||
}
|
||||
if toTheWrite != 3 || len(KeepList) != 3 {
|
||||
t.Errorf("%d entries, %d kept to the write itself: ADR 0571 §13 keeps the three alone from the write's delivery", len(KeepList), toTheWrite)
|
||||
}
|
||||
|
||||
type want struct {
|
||||
deps []string // "<module> <kind> <what> <kept by>", kept by the record or "unkept"
|
||||
write string // a reason the check at the write names, or "" when it passes
|
||||
before string // a reason the check before the delivery names, or "" when it passes
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
manifests map[string]string
|
||||
want want
|
||||
}{
|
||||
// 774/S4/AC2.
|
||||
{"a read of the lease alone is no dependency, and the controller's own manifest is not judged",
|
||||
map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`,
|
||||
"mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`},
|
||||
want{}},
|
||||
// 774/S4/AC1, 774/S30/AC1: the keeper's read of the controller's conditions put back is refused in either phase,
|
||||
// since the lease write's delivery dropped its entry (it was kept by ADR 0564 §9 before).
|
||||
{"the keeper's read of the controller's conditions, put back, is refused",
|
||||
map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`},
|
||||
want{deps: []string{"mesh-conditions read mesh-controller.conditions unkept"},
|
||||
write: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write",
|
||||
before: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write"}},
|
||||
// 774/S30/AC1: the three kept to the write pass in either phase.
|
||||
{"the artifact sweep's collect is kept to the write itself",
|
||||
map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","version":"1","invokes":["seat:mesh-controller.collect"]}`},
|
||||
want{deps: []string{"artifact-store-tools invoke seat:mesh-controller.collect kept by ADR 0571 §4, §13"}}},
|
||||
{"the builder's consume of artifacts-gone is kept to the write itself",
|
||||
map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`},
|
||||
want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}},
|
||||
{"a read no record keeps fails both phases, named",
|
||||
map[string]string{"mod-u": `{"module":"mod-u","version":"1","reads":["mesh-controller.declared-packages"]}`},
|
||||
want{deps: []string{"mod-u read mesh-controller.declared-packages unkept"},
|
||||
write: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write",
|
||||
before: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write"}},
|
||||
{"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none",
|
||||
map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
|
||||
`"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`,
|
||||
"node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`},
|
||||
want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept",
|
||||
"mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"},
|
||||
write: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps until the lease write (ADR 0571 §13); ended by ADR 0571 §11",
|
||||
before: "node-tools declares waits mesh-controller.checked"}},
|
||||
{"a process's own invoke of the controller is its module's dependency, named by the process",
|
||||
map[string]string{"mod-p": `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},{"id":"worker","type":"process","run":["./w"],"user":"mod-p",` +
|
||||
`"bus":{"invokes":["seat:mesh-controller.held"]}}]}`},
|
||||
want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"},
|
||||
write: "mod-p (process worker) declares invoke seat:mesh-controller.held", before: "mod-p (process worker)"}},
|
||||
{"no manifest fails both phases", map[string]string{}, want{write: "no manifest was read", before: "no manifest was read"}},
|
||||
} {
|
||||
manifests := map[string]Manifest{}
|
||||
for name, raw := range row.manifests {
|
||||
m, err := Decode([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %s: %v", row.name, name, err)
|
||||
}
|
||||
manifests[name] = m
|
||||
}
|
||||
var deps []string
|
||||
for _, d := range Dependencies(manifests) {
|
||||
kept := "unkept"
|
||||
if d.Kept != "" {
|
||||
kept = "kept by " + d.Kept
|
||||
}
|
||||
deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept)
|
||||
}
|
||||
if !slices.Equal(deps, row.want.deps) {
|
||||
t.Errorf("%s: dependencies %q, want %q", row.name, deps, row.want.deps)
|
||||
}
|
||||
for phase, reason := range map[string]string{PhaseWrite: row.want.write, PhaseBefore: row.want.before} {
|
||||
got := ManifestRefusals(manifests, phase)
|
||||
if (reason == "") != (len(got) == 0) || !slices.ContainsFunc(got, func(r string) bool { return strings.Contains(r, reason) }) && reason != "" {
|
||||
t.Errorf("%s: at %s refused %q, want %q", row.name, phase, got, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
|
||||
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
|
||||
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
|
||||
t.Run("the seat mesh-grants promises the lease write", grantsSeatPromisesTheLeaseWrite)
|
||||
seat, ok := SeatNamed(DeliverySeat)
|
||||
if !ok {
|
||||
t.Fatal("the delivery seat is not in the set")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package catalogue
|
||||
|
||||
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
|
||||
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
|
||||
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
|
||||
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
|
||||
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
|
||||
|
||||
// EnrolmentSeat is the seat mesh-enrolment.
|
||||
const EnrolmentSeat = "mesh-enrolment"
|
||||
|
||||
// The seat's six verbs (ADR 0587 §1, §4-§8).
|
||||
const (
|
||||
EnrolmentVerbIssue = "issue"
|
||||
EnrolmentVerbRedeem = "redeem"
|
||||
EnrolmentVerbConfirm = "confirm"
|
||||
EnrolmentVerbRevoke = "revoke"
|
||||
EnrolmentVerbTokens = "tokens"
|
||||
EnrolmentVerbAgain = "again"
|
||||
)
|
||||
|
||||
// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole
|
||||
// content except enrolment-expired, which names the node and the enrolment-issued event that carried it.
|
||||
const (
|
||||
EnrolmentIssuedEvent = "enrolment-issued"
|
||||
NodeEnrolledEvent = "node-enrolled"
|
||||
EnrolmentRefusedEvent = "enrolment-refused"
|
||||
EnrolmentRevokedEvent = "enrolment-revoked"
|
||||
EnrolmentExpiredEvent = "enrolment-expired"
|
||||
)
|
||||
|
||||
// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one.
|
||||
var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent,
|
||||
EnrolmentExpiredEvent}
|
||||
|
||||
// enrolmentDecision is the record that made the seat.
|
||||
const enrolmentDecision = "novox/hq ADR 0587"
|
||||
|
||||
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
|
||||
// the seat is new with its holder, so no holder of before serves fewer.
|
||||
func enrolmentVerbs() []Verb {
|
||||
str := func(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
return []Verb{
|
||||
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
|
||||
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
|
||||
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
|
||||
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
|
||||
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
|
||||
"The token itself is read only through tokens.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the new machine's name in the mesh",
|
||||
"why": "why it joins, shown to the operator with the ask",
|
||||
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
|
||||
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
|
||||
// enters placement with it and the hub peers it before redeem.
|
||||
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
|
||||
}, []string{"node", "why", "overlay_key"})},
|
||||
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
|
||||
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
|
||||
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
|
||||
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
|
||||
"it finishes what is not done and answers the same; with other keys it is refused.",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false,
|
||||
"required": []string{"node", "public_key", "sealing_key", "proof"},
|
||||
"properties": map[string]any{
|
||||
"node": str("the node the token was issued for"),
|
||||
"public_key": str("the identity key's public half, base64"),
|
||||
"overlay_key": str("the private network key's public half"),
|
||||
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
|
||||
"serving_key": str("the serving key's public half, which the certificate binds"),
|
||||
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
|
||||
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
|
||||
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
|
||||
}}},
|
||||
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
|
||||
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
|
||||
"enrolment.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
|
||||
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
|
||||
"it, records the act as a hand act and says enrolment-revoked.",
|
||||
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
|
||||
[]string{"node", "why"})},
|
||||
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
|
||||
"the bucket's fill against its cap, and whether issuing is on and why (the lease). For a node at live, its " +
|
||||
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
|
||||
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
|
||||
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
|
||||
nil, "history")},
|
||||
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
|
||||
"and nothing more.",
|
||||
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
|
||||
}
|
||||
}
|
||||
@@ -744,12 +744,9 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package catalogue
|
||||
|
||||
// **The lease write, a verb of the seat `mesh-grants`** (novox/hq ADR 0582 clause 1, issue 774): the grants part
|
||||
// holds the seat once for the mesh and, on the operator's warrant, hands the controller's lease to itself and back.
|
||||
// It serves two verbs and says three events, none carrying a secret:
|
||||
//
|
||||
// - lease-handed: the lease names mesh-grants, written on the operator's warrant (clause 3);
|
||||
// - lease-altered: the lease key or its bucket's expiry differs from what mesh-grants wrote (clause 4);
|
||||
// - removal-failed: the day-later removal of the controller's consumer or stream failed (clause 6).
|
||||
|
||||
// GrantsSeat is the seat the grants part (mesh-grants) holds.
|
||||
const GrantsSeat = "mesh-grants"
|
||||
|
||||
// The seat's verbs.
|
||||
const (
|
||||
GrantsVerbLeaseWrite = "lease-write"
|
||||
GrantsVerbLeaseHandBack = "lease-hand-back"
|
||||
)
|
||||
|
||||
// The seat's events.
|
||||
const (
|
||||
GrantsEventLeaseHanded = "lease-handed"
|
||||
GrantsEventLeaseAltered = "lease-altered"
|
||||
GrantsEventRemovalFailed = "removal-failed"
|
||||
)
|
||||
|
||||
// grantsDecision is the record that made the seat.
|
||||
const grantsDecision = "novox/hq ADR 0582"
|
||||
|
||||
// grantsEvents are the seat's events, in the order the write meets them.
|
||||
func grantsEvents() []string {
|
||||
return []string{GrantsEventLeaseHanded, GrantsEventLeaseAltered, GrantsEventRemovalFailed}
|
||||
}
|
||||
|
||||
// grantsVerbs are the seat's verbs (ADR 0582 clauses 1, 2, 3, 5).
|
||||
func grantsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: GrantsVerbLeaseWrite, Description: "Hand the controller's lease to mesh-grants, kept for good. Refused, " +
|
||||
"naming the check, until the lease check at phase write passes and every condition it does not judge reads " +
|
||||
"green. Otherwise the operator is asked once; on the warrant the checks are read again, the controller's " +
|
||||
"service is stopped, the bus's user list is composed without the controller's write on the lease, the lease's " +
|
||||
"expiry is removed and the key holder names mesh-grants, said as lease-handed and recorded as a hand act. A no " +
|
||||
"or an expiry of the ask acts on nothing.",
|
||||
Input: schema(map[string]string{"why": "why the lease is handed now, shown to the operator and kept"},
|
||||
[]string{"why"})},
|
||||
{Name: GrantsVerbLeaseHandBack, Description: "Give the lease back to the controller, for a day after the write: " +
|
||||
"asked and warranted the same way, it restores the lease's fifteen-second expiry, deletes the key holder, " +
|
||||
"gives the controller its rights back once the list is loaded, and starts the controller. Refused once the " +
|
||||
"day-later removal ran: the controller would start without its consumer and stream.",
|
||||
Input: schema(map[string]string{"why": "why the lease goes back, shown to the operator and kept"},
|
||||
[]string{"why"})},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774, 774/S1/AC2): its holder serves lease-write and
|
||||
// lease-hand-back, both a condition of holding, and nothing else; the seat says lease-handed, lease-altered and
|
||||
// removal-failed. A case of TestTheDeliverySeatPromisesChecks, the seat-verb test, within the test budget.
|
||||
func grantsSeatPromisesTheLeaseWrite(t *testing.T) {
|
||||
seat, ok := SeatNamed(GrantsSeat)
|
||||
if !ok {
|
||||
t.Fatal("the seat mesh-grants is not in the set")
|
||||
}
|
||||
if seat.Scope != ScopeMesh || seat.Delivers != "" {
|
||||
t.Errorf("mesh-grants is at scope %q delivering %q, want mesh and nothing", seat.Scope, seat.Delivers)
|
||||
}
|
||||
if got, want := VerbNames(seat.Serves), []string{"lease-write", "lease-hand-back"}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mesh-grants serves %v, want %v", got, want)
|
||||
}
|
||||
if want := []string{"lease-handed", "lease-altered", "removal-failed"}; !reflect.DeepEqual(seat.Emits, want) {
|
||||
t.Errorf("mesh-grants says %v, want %v", seat.Emits, want)
|
||||
}
|
||||
for _, v := range seat.Serves {
|
||||
if v.Optional {
|
||||
t.Errorf("%s is optional: the holder ships with the seat, so every verb is a condition of holding", v.Name)
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
if req, _ := v.Input["required"].([]string); len(props) != 1 || !reflect.DeepEqual(req, []string{"why"}) {
|
||||
t.Errorf("%s takes %v requiring %v, want only a why", v.Name, props, req)
|
||||
}
|
||||
}
|
||||
|
||||
claim := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeMesh, Serves: serves}}}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
m Manifest
|
||||
refuse string // empty: held
|
||||
}{
|
||||
{"both verbs", claim("lease-write", "lease-hand-back"), ""},
|
||||
{"without the hand-back", claim("lease-write"), "lease-hand-back"},
|
||||
{"a verb the seat does not promise", claim("lease-write", "lease-hand-back", "lease-take"), "lease-take"},
|
||||
{"at node scope", Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeNode,
|
||||
Serves: []string{"lease-write", "lease-hand-back"}}}}, "scope"},
|
||||
} {
|
||||
err := CanHold(c.m, seat)
|
||||
switch {
|
||||
case c.refuse == "" && err != nil:
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)):
|
||||
t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.refuse)
|
||||
}
|
||||
}
|
||||
|
||||
// No other seat changed: the set is the one before it with mesh-grants added beside mesh-hand-acts.
|
||||
var names []string
|
||||
for _, s := range DefaultSeats() {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
at := strings.Index(strings.Join(names, " "), HandActsSeat+" "+GrantsSeat+" ")
|
||||
if at < 0 {
|
||||
t.Errorf("mesh-grants does not follow mesh-hand-acts in the seat set: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,10 @@ func placementVerbs() []Verb {
|
||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
||||
"layer, mesh-wide-values, values)",
|
||||
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
|
||||
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
|
||||
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
|
||||
"on the same warrant)",
|
||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
||||
return []Verb{
|
||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
||||
@@ -52,11 +55,17 @@ func placementVerbs() []Verb {
|
||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
||||
"caller, as the runtime proves it.",
|
||||
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
|
||||
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
|
||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
||||
"properties": map[string]any{
|
||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
||||
"changes": changes,
|
||||
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
|
||||
// Approve is the proposal's; the holder writes it with no ask of its own.
|
||||
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
|
||||
"warrant {node, address, ask, warrant}, naming the change's node and address",
|
||||
"required": []string{"node", "address", "ask", "warrant"}},
|
||||
}}},
|
||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
||||
|
||||
+57
-6
@@ -233,13 +233,59 @@ var defaultSeats = append([]Seat{
|
||||
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
||||
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
||||
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
||||
"kind node-certificate signs the certificate of the calling machine's internal name for the serving key " +
|
||||
"its record states, built from bus state alone and asked by that machine's node-engine only; kind " +
|
||||
"enrolment signs an enrolment reply under its own context, for a caller the adoption record names. " +
|
||||
"Answers the certificate or the signature and the signer's fingerprint, and says signed; never a key.",
|
||||
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
|
||||
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
|
||||
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
|
||||
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
|
||||
"and the signer's fingerprint, and says signed; never a key.",
|
||||
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
||||
"node": "the machine, for a node certificate: the caller's own",
|
||||
"reply": "the enrolment reply's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
|
||||
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
|
||||
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
|
||||
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
|
||||
// manifest names for these two.
|
||||
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
|
||||
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
|
||||
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
|
||||
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
|
||||
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
|
||||
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
|
||||
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
|
||||
Input: withObject(schema(map[string]string{"node": "the node the token is for",
|
||||
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
|
||||
"sealed": "\"true\": answer the token sealed to the operator key",
|
||||
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
|
||||
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
|
||||
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
|
||||
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
|
||||
"address": "the joining node's tunnel address",
|
||||
"key": "the joining machine's own tunnel public key, as given at issue",
|
||||
"range": "the private network's range, a prefix"})},
|
||||
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
|
||||
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
|
||||
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
|
||||
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
|
||||
Input: schema(map[string]string{"node": "the joining node",
|
||||
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
|
||||
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
|
||||
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
|
||||
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
|
||||
// node last; the state node-logins says where each node stands.
|
||||
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
|
||||
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
|
||||
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
|
||||
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
|
||||
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
|
||||
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
|
||||
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
|
||||
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
|
||||
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
|
||||
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
|
||||
"without it.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
|
||||
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
|
||||
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
|
||||
}},
|
||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
||||
@@ -302,6 +348,11 @@ var defaultSeats = append([]Seat{
|
||||
// policy and the bus step at the assigner, and the hand-act log.
|
||||
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
|
||||
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
|
||||
// The lease write (novox/hq ADR 0582 clause 1, grants_seat.go): the grants part hands the controller's lease to
|
||||
// itself and back on the operator's warrant.
|
||||
{Name: GrantsSeat, Scope: ScopeMesh, Decision: grantsDecision, Emits: grantsEvents(), Serves: grantsVerbs()},
|
||||
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
|
||||
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
|
||||
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
||||
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
||||
Serves: artifactSweepVerbs()},
|
||||
|
||||
@@ -31,6 +31,7 @@ func parsed(t *testing.T, m Manifest) error {
|
||||
}
|
||||
|
||||
func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) {
|
||||
t.Run("the node over the mesh over the default", theNodeOverTheMeshOverTheDefault)
|
||||
m := notifier()
|
||||
for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} {
|
||||
file := map[string]any{}
|
||||
@@ -49,7 +50,7 @@ func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNodeOverTheMeshOverTheDefault(t *testing.T) {
|
||||
func theNodeOverTheMeshOverTheDefault(t *testing.T) {
|
||||
m := notifier()
|
||||
layers := []Layer{
|
||||
{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}},
|
||||
|
||||
@@ -42,7 +42,6 @@ const (
|
||||
StreamMostDeliveries = 10
|
||||
)
|
||||
|
||||
|
||||
// MeshStreamNames are the mesh's own streams by name. Written here as well as in the broker, which this
|
||||
// package cannot import; a test keeps them agreeing.
|
||||
var MeshStreamNames = []string{"CONTROL", "NODES", "EVENTS", "DEAD_LETTER_NOTICES", "DEAD_LETTERS", "PROV_APPLIED", "CHECKS_READY", "CHECKS_OTHER"}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The vault's provision is one the controller itself dereferences — every minted credential is
|
||||
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
||||
@@ -65,6 +68,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
||||
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
||||
// 774/S37/AC1: the enrolment verbs mesh-enrolment asks (ADR 0587 §3, §5).
|
||||
"enrolment-token": {"node", "expires", "sealed", "drop", "tunnel"},
|
||||
"node-account": {"node", "sealing_key", "keys_digest", "retire"},
|
||||
"sign": {"kind", "node", "serving_key", "reply"},
|
||||
// 774/S38: the rotation verbs (ADR 0583 §2, §3, §5).
|
||||
"rotate-node": {"node", "why", "act"},
|
||||
"node-account-confirm": {},
|
||||
"abandon": {"node"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
@@ -89,6 +100,14 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
if props["value"] != nil {
|
||||
t.Errorf("%s takes a value in the clear", name)
|
||||
}
|
||||
// The first tunnel the token carries (ADR 0587 §3, insight of 2026-11-02): an object of the hub's key, its endpoint
|
||||
// and the node's address.
|
||||
if name == "enrolment-token" {
|
||||
tunnel, _ := props["tunnel"].(map[string]any)
|
||||
if tunnel["type"] != "object" || !slices.Equal(tunnel["required"].([]string), []string{"address", "hub_endpoint", "hub_key", "key", "range"}) {
|
||||
t.Errorf("enrolment-token's tunnel is %v", tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The holders `issued` names (issue 749) are a list of {node, module}.
|
||||
for _, verb := range seat.Serves {
|
||||
@@ -105,7 +124,8 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(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"}}}}
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
|
||||
"rotate-node", "node-account-confirm", "abandon"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
||||
@@ -596,6 +598,14 @@ var ControllerVerbs = []Verb{
|
||||
//
|
||||
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
||||
// name is refused when the verb is called, never passed over.
|
||||
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
|
||||
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
|
||||
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
|
||||
inner["description"], inner["additionalProperties"] = description, false
|
||||
s["properties"].(map[string]any)[name] = inner
|
||||
return s
|
||||
}
|
||||
|
||||
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
||||
isSwitch := map[string]bool{}
|
||||
for _, s := range switches {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -25,6 +26,30 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) {
|
||||
|
||||
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
||||
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||
// 774/S34/AC1: the seat mesh-enrolment is held by a module serving its six verbs, and a seventh is refused.
|
||||
enrolment, ok := SeatNamed(EnrolmentSeat)
|
||||
if !ok || enrolment.Scope != ScopeMesh || len(enrolment.Emits) != 5 {
|
||||
t.Fatalf("mesh-enrolment: in the set %v, %+v", ok, enrolment)
|
||||
}
|
||||
six := []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}
|
||||
// The tunnel key is given at issue (the operator, 2026-11-02 01:05).
|
||||
if issue := enrolment.Serves[0]; issue.Name != "issue" || !slices.Contains(issue.Input["required"].([]string), "overlay_key") {
|
||||
t.Errorf("issue requires %v", issue.Input["required"])
|
||||
}
|
||||
holder := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "mesh-enrolment", Tools: serves,
|
||||
Claims: []Claim{{Name: EnrolmentSeat, Scope: ScopeMesh, Serves: serves}}}
|
||||
}
|
||||
if err := CanHold(holder(six...), enrolment); err != nil {
|
||||
t.Errorf("the holder serving the six verbs was refused: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(six[:5]...), enrolment); err == nil || !strings.Contains(err.Error(), "does not serve again") {
|
||||
t.Errorf("a holder missing again: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(append(six, "mint")...), enrolment); err == nil || !strings.Contains(err.Error(), "mint") {
|
||||
t.Errorf("a holder saying it serves mint: %v", err)
|
||||
}
|
||||
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
||||
|
||||
+147
-45
@@ -223,9 +223,56 @@ func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest,
|
||||
// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the
|
||||
// read: that is the bus's, not the module's.
|
||||
func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
b, err := readBase(ctx, r, mesh, s)
|
||||
b.refuseOwn(refuse)
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
|
||||
}
|
||||
|
||||
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
|
||||
// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
|
||||
type base struct {
|
||||
in compose.Input
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
|
||||
// read: every scope holds it.
|
||||
ownRefused [][2]string
|
||||
}
|
||||
|
||||
// refuseOwn hands refuse each module placed on the composing node whose manifest is refused.
|
||||
func (b base) refuseOwn(refuse func(module string, err error)) {
|
||||
if refuse == nil {
|
||||
return
|
||||
}
|
||||
for _, r := range b.ownRefused {
|
||||
refuse(r[0], errors.New(r[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over
|
||||
// one base never see each other's builds.
|
||||
func (b base) cloned() compose.Input {
|
||||
in := b.in
|
||||
in.Catalogue = maps.Clone(b.in.Catalogue)
|
||||
in.Nodes = slices.Clone(b.in.Nodes)
|
||||
for i := range in.Nodes {
|
||||
in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept)
|
||||
}
|
||||
in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes)
|
||||
in.OwnSecrets = slices.Clone(b.in.OwnSecrets)
|
||||
return in
|
||||
}
|
||||
|
||||
// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
|
||||
func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
var b base
|
||||
placements, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
return b, fmt.Errorf("frombus: placement: %w", err)
|
||||
}
|
||||
if len(s.Placements) > 0 {
|
||||
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
|
||||
@@ -238,7 +285,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
continue
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
given[node] = *p
|
||||
}
|
||||
@@ -246,14 +293,14 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
|
||||
return b, 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)
|
||||
return b, 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)
|
||||
return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
}
|
||||
|
||||
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
|
||||
@@ -279,18 +326,6 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
||||
}
|
||||
}
|
||||
parse := parser(mesh)
|
||||
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)
|
||||
}
|
||||
man, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
|
||||
}
|
||||
return man, nil
|
||||
}
|
||||
named := func(field *map[string]string, module, why string) {
|
||||
if *field == nil {
|
||||
*field = map[string]string{}
|
||||
@@ -309,7 +344,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
raw, err := r.Manifest(ctx, m.Manifest)
|
||||
if err != nil {
|
||||
// Not read is no refusal of the module: a bus that does not answer is waited out by the caller.
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
|
||||
return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
|
||||
}
|
||||
// One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read
|
||||
// follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused
|
||||
@@ -318,8 +353,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
man, why := taken(name, raw, m)
|
||||
if why != "" {
|
||||
named(&in.Refused, name, why)
|
||||
if refuse != nil && ownPlaced[name] {
|
||||
refuse(name, errors.New(why))
|
||||
if ownPlaced[name] {
|
||||
b.ownRefused = append(b.ownRefused, [2]string{name, why})
|
||||
}
|
||||
} else if aside := man.SetAside(); aside != "" {
|
||||
named(&in.SetAside, name, aside)
|
||||
@@ -332,11 +367,11 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
for _, name := range delivery.ProvidedModules() {
|
||||
raw, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
return b, err
|
||||
}
|
||||
var private catalogue.Manifest
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
|
||||
return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
|
||||
}
|
||||
in.Catalogue[name] = private
|
||||
}
|
||||
@@ -357,13 +392,13 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
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)
|
||||
return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
}
|
||||
} else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 {
|
||||
// What a provider elsewhere serves from its settings is told to every consumer: the composing node reads
|
||||
// those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks).
|
||||
if values, err = r.Served(ctx, node, m.Module, keys); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
|
||||
return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
|
||||
strings.Join(keys, ", "), err)
|
||||
}
|
||||
}
|
||||
@@ -436,14 +471,35 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
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",
|
||||
return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
b.in, b.placements, b.records, b.modules = in, placements, records, modules
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
|
||||
// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
|
||||
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
var err error
|
||||
parse := parser(mesh)
|
||||
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)
|
||||
}
|
||||
man, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
|
||||
}
|
||||
return man, nil
|
||||
}
|
||||
if !s.Every {
|
||||
if _, ok := records[s.Node]; !ok {
|
||||
if _, ok := b.records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
if err := fromBuilds(ctx, r, &in, s, placements[s.Node], modules, read, parse, refuse, wanted); err != nil {
|
||||
if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||
@@ -545,10 +601,16 @@ func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[str
|
||||
// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until
|
||||
// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub).
|
||||
func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) {
|
||||
return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) })
|
||||
}
|
||||
|
||||
// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it
|
||||
// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out.
|
||||
// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3).
|
||||
func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) {
|
||||
for _, m := range sortedKeys(held) {
|
||||
in = stubbed(in, node, m, "")
|
||||
}
|
||||
compose1 := func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }
|
||||
// Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed.
|
||||
for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ {
|
||||
plans, err := compose1(in)
|
||||
@@ -585,7 +647,7 @@ func plansHeldOf(in compose.Input, node string, held map[string]string) (map[str
|
||||
}
|
||||
in = stubbed(in, node, module, leave)
|
||||
}
|
||||
return nil, fmt.Errorf("frombus: the plans of %s do not compose with %d module(s) held: %s", node, len(held),
|
||||
return nil, fmt.Errorf("frombus: what %s composes does not compose with %d module(s) held: %s", node, len(held),
|
||||
strings.Join(sortedKeys(held), ", "))
|
||||
}
|
||||
|
||||
@@ -597,15 +659,8 @@ func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
|
||||
|
||||
// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
|
||||
func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
|
||||
resolved, err := compose.Resolve(in, node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
rendered, err := compose.Render(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
gens, err := compose.Generators(in)
|
||||
// One composition for the three, so each node of the mesh is resolved once (issue 1954).
|
||||
resolved, rendered, gens, err := compose.Composing(in, node, mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
|
||||
}
|
||||
@@ -667,7 +722,8 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
|
||||
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
|
||||
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
|
||||
// (issue 1126).
|
||||
func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
|
||||
refuse func(module string, err error), wanted map[string]string) error {
|
||||
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
|
||||
@@ -710,6 +766,24 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if b.Manifest == "" {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
|
||||
}
|
||||
// A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest.
|
||||
keeper, keeps := r.(takenInKeeper)
|
||||
if keeps && !addressed {
|
||||
address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
|
||||
addressed = true
|
||||
}
|
||||
// Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did.
|
||||
key, keyed := takenInKey(module, fp, address, *b)
|
||||
keyed = keyed && addressErr == nil && address != ""
|
||||
if keeps && keyed {
|
||||
if raw, ok := keeper.takenIn(key); ok {
|
||||
resolved, err := parse(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
src, err := read(module, b.Manifest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
@@ -727,6 +801,9 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if resolved, err = parse(raw); err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
|
||||
}
|
||||
if keeps && keyed {
|
||||
keeper.keepTakenIn(key, raw)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
// A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one
|
||||
@@ -760,7 +837,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
|
||||
continue // nothing is built of it: its manifest is what it composes from
|
||||
}
|
||||
b, err := r.Build(ctx, pm.Module, m.FP2)
|
||||
b, err := build(ctx, pm.Module, m.FP2)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
|
||||
if refuse == nil {
|
||||
@@ -795,7 +872,7 @@ func fromBuilds(ctx context.Context, r Reader, in *compose.Input, s Scope, p del
|
||||
}
|
||||
for _, module := range sortedKeys(keptAt) {
|
||||
fp := keptAt[module]
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
b, err := build(ctx, module, fp)
|
||||
switch {
|
||||
case err != nil:
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
|
||||
@@ -1057,9 +1134,34 @@ func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map
|
||||
if err != nil {
|
||||
return nil, nil, held, err
|
||||
}
|
||||
items, unplaced, err = compose.DataItems(in, s.Node)
|
||||
for m := range held {
|
||||
delete(items, m)
|
||||
}
|
||||
// Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's
|
||||
// template) holds that module and never ends the read.
|
||||
items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) {
|
||||
var err error
|
||||
items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node)
|
||||
return items, err
|
||||
})
|
||||
return items, unplaced, held, err
|
||||
}
|
||||
|
||||
// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its
|
||||
// data never refuses the rest (its stub keeps its data for the backups the plans compose).
|
||||
func withoutHeldData(in compose.Input, node string) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
n := &out.Nodes[i]
|
||||
if n.Name != node || len(n.Held) == 0 {
|
||||
continue
|
||||
}
|
||||
kept := maps.Clone(n.Kept)
|
||||
for m := range n.Held {
|
||||
if man, ok := kept[m]; ok {
|
||||
man.Data = nil
|
||||
kept[m] = man
|
||||
}
|
||||
}
|
||||
n.Kept = kept
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -29,16 +33,25 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
// refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds
|
||||
// backup too, and never ends the read.
|
||||
refused string
|
||||
// dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build,
|
||||
// an item composition refuses), not one only a plan meets.
|
||||
dataHeld bool
|
||||
// dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item.
|
||||
dataOnly bool
|
||||
}{
|
||||
{name: "every module composes"},
|
||||
{name: "a built manifest that cannot be used",
|
||||
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
|
||||
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup"},
|
||||
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true},
|
||||
{name: "a plan that refuses",
|
||||
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
|
||||
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
|
||||
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`,
|
||||
refused: `"shows" is none of them`},
|
||||
refused: `"shows" is none of them`, dataHeld: true},
|
||||
// ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read.
|
||||
{name: "a data item its composition refuses",
|
||||
bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`,
|
||||
why: "nope", dataHeld: true, dataOnly: true},
|
||||
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true,
|
||||
refused: `"shows" is none of them`},
|
||||
} {
|
||||
@@ -65,6 +78,13 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
|
||||
}
|
||||
if row.dataOnly {
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
switch {
|
||||
case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")):
|
||||
@@ -99,6 +119,18 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
|
||||
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
|
||||
}
|
||||
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
|
||||
// and where the time went is said by phase.
|
||||
keptAt := FPs()
|
||||
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
|
||||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
|
||||
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
|
||||
}
|
||||
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
|
||||
t.Errorf("%s: the phases are said as %q", row.name, said)
|
||||
}
|
||||
if row.bad == "" || row.elsewhere {
|
||||
if len(held) > 0 {
|
||||
t.Errorf("%s: held %v", row.name, held)
|
||||
@@ -111,7 +143,123 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
if _, planned := plans["backup"]; planned {
|
||||
t.Errorf("%s: the held module has a plan", row.name)
|
||||
}
|
||||
// The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole.
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
// A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file.
|
||||
mustHold := row.dataHeld
|
||||
if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
|
||||
// asked, and a manifest once per digest.
|
||||
byKey := map[string]int{}
|
||||
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
|
||||
byKey[state+" watched"]++
|
||||
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
|
||||
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
|
||||
w := fromruntime.Watched{WatchedStates: ws}
|
||||
for range 2 {
|
||||
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
|
||||
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
|
||||
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
|
||||
t.Error("the watched reader does not answer the placement from its copy")
|
||||
}
|
||||
}
|
||||
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
|
||||
byKey[frombus.PlacementRead+" watched"] != 1 {
|
||||
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
|
||||
}
|
||||
fetched := 0
|
||||
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
|
||||
for range 2 {
|
||||
if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
|
||||
t.Fatalf("the kept fetch answered %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
if fetched != 1 {
|
||||
t.Errorf("a templates archive was fetched %d times, want once", fetched)
|
||||
}
|
||||
|
||||
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
|
||||
// and the plans are the same.
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
|
||||
fetched++
|
||||
_, digest, _ := strings.Cut(url, "/blobs/")
|
||||
return b.Archives[digest], nil
|
||||
}), fetched
|
||||
var first map[string]catalogue.Plan
|
||||
for i := range 2 {
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
|
||||
t.Fatalf("composed %v (held %v): %v", plans, held, err)
|
||||
}
|
||||
first = plans
|
||||
}
|
||||
if fetched-archives != 1 {
|
||||
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
|
||||
}
|
||||
}
|
||||
|
||||
// statesOf is the bus b as a module's read states hold it.
|
||||
func statesOf(t *testing.T, b *Bus) frombus.States {
|
||||
t.Helper()
|
||||
s := states{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s[state] == nil {
|
||||
s[state] = map[string][]byte{}
|
||||
}
|
||||
s[state][key] = raw
|
||||
}
|
||||
for node, p := range b.Placement {
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
put(frombus.PlacementRead, "placement."+node, raw, err)
|
||||
}
|
||||
for node, rec := range b.Record {
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
put(frombus.HaveRead, node+".node", raw, err)
|
||||
}
|
||||
for module, m := range b.Module {
|
||||
key, _ := delivery.ModulesKey(module)
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
put(frombus.ModulesRead, key, raw, err)
|
||||
}
|
||||
for digest, man := range b.Manifests {
|
||||
key, _ := delivery.ManifestsKey(digest)
|
||||
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
|
||||
put(frombus.ManifestsRead, key, raw, err)
|
||||
}
|
||||
for at, build := range b.Builds {
|
||||
module, fp, _ := strings.Cut(at, " ")
|
||||
key, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
put(frombus.BuildsRead, key, raw, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// states are read states in memory.
|
||||
type states map[string]map[string][]byte
|
||||
|
||||
func (s states) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := s[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (s states) Keys(state string) ([]string, error) {
|
||||
var keys []string
|
||||
for k := range s[state] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
|
||||
|
||||
@@ -43,13 +43,22 @@ type States interface {
|
||||
type Fetch func(ctx context.Context, url string) ([]byte, error)
|
||||
|
||||
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
|
||||
func StatesReader(s States, fetch Fetch) Reader { return statesReader{s: s, fetch: fetch} }
|
||||
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
|
||||
// takes in only a build it has not seen.
|
||||
func StatesReader(s States, fetch Fetch) Reader {
|
||||
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
|
||||
}
|
||||
|
||||
type statesReader struct {
|
||||
s States
|
||||
fetch Fetch
|
||||
kept *keptTakeIns
|
||||
}
|
||||
|
||||
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
|
||||
|
||||
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
|
||||
|
||||
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
|
||||
keys, err := r.s.Keys(read)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package frombus
|
||||
|
||||
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
|
||||
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
|
||||
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
|
||||
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
|
||||
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
|
||||
type HeldPlans struct {
|
||||
Plans map[string]catalogue.Plan
|
||||
Held map[string]string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Phase is one step of a composition and how long it took.
|
||||
type Phase struct {
|
||||
Name string `json:"phase"`
|
||||
Took time.Duration `json:"took"`
|
||||
}
|
||||
|
||||
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
|
||||
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
|
||||
// took names each phase in order: the read, then each scope's builds and its plans.
|
||||
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
|
||||
start := time.Now()
|
||||
mark := func(name string) {
|
||||
now := time.Now()
|
||||
took = append(took, Phase{Name: name, Took: now.Sub(start)})
|
||||
start = now
|
||||
}
|
||||
b, err := readBase(ctx, r, mesh, s)
|
||||
mark("read")
|
||||
builds := onceBuilds(r)
|
||||
newestScope := s
|
||||
newestScope.KeptAt = nil
|
||||
for _, sc := range []struct {
|
||||
scope Scope
|
||||
into *HeldPlans
|
||||
name string
|
||||
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
|
||||
held := map[string]string{}
|
||||
sc.into.Held = held
|
||||
refuse := func(module string, err error) { held[module] = err.Error() }
|
||||
b.refuseOwn(refuse)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
|
||||
mark("builds at " + sc.name)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
|
||||
mark("plans at " + sc.name)
|
||||
}
|
||||
return at, newest, took
|
||||
}
|
||||
|
||||
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
|
||||
// builds of the same moment.
|
||||
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
type answer struct {
|
||||
b *delivery.BuilderBuild
|
||||
err error
|
||||
}
|
||||
read := map[string]answer{}
|
||||
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key := module + " " + fp
|
||||
if a, ok := read[key]; ok {
|
||||
return a.b, a.err
|
||||
}
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
read[key] = answer{b, err}
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
|
||||
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
|
||||
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
|
||||
type takenInKeeper interface {
|
||||
takenIn(key string) ([]byte, bool)
|
||||
keepTakenIn(key string, raw []byte)
|
||||
}
|
||||
|
||||
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
|
||||
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
|
||||
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
|
||||
raw, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
|
||||
return hex.EncodeToString(sum[:]), true
|
||||
}
|
||||
|
||||
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
|
||||
const takenInAtMost = 512
|
||||
|
||||
// keptTakeIns is a reader's kept take-ins.
|
||||
type keptTakeIns struct {
|
||||
mu sync.Mutex
|
||||
kept map[string][]byte
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
raw, ok := k.kept[key]
|
||||
return raw, ok
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.kept == nil || len(k.kept) >= takenInAtMost {
|
||||
k.kept = map[string][]byte{}
|
||||
}
|
||||
k.kept[key] = raw
|
||||
}
|
||||
|
||||
// PhasesSaid says the phases in one line, each in milliseconds.
|
||||
func PhasesSaid(took []Phase) string {
|
||||
out := ""
|
||||
for i, p := range took {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
@@ -28,9 +29,60 @@ func (States) Get(state, key string) ([]byte, bool, error) {
|
||||
// Keys are a state's keys.
|
||||
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
// Reader is frombus's Reader over this bundle's read states, fetching archives over HTTP.
|
||||
// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that
|
||||
// composes again and again reads through Watched instead (novox/hq ADR 0580).
|
||||
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
|
||||
|
||||
// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
|
||||
// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
|
||||
type Watched struct{ *stdio.WatchedStates }
|
||||
|
||||
// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
|
||||
func (w Watched) Get(state, key string) ([]byte, bool, error) {
|
||||
switch state {
|
||||
case frombus.BuildsRead:
|
||||
return w.Direct(state, key)
|
||||
case frombus.ManifestsRead:
|
||||
return w.Once(state, key)
|
||||
}
|
||||
return w.WatchedStates.Get(state, key)
|
||||
}
|
||||
|
||||
// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition
|
||||
// made again on every change reads it again otherwise.
|
||||
func WatchedReader(w *stdio.WatchedStates) frombus.Reader {
|
||||
return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch))
|
||||
}
|
||||
|
||||
// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again.
|
||||
const archivesAtMost = 64
|
||||
|
||||
// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it
|
||||
// (frombus checks the digest on every read). A failed fetch is not kept.
|
||||
func KeptFetch(fetch frombus.Fetch) frombus.Fetch {
|
||||
var mu sync.Mutex
|
||||
kept := map[string][]byte{}
|
||||
return func(ctx context.Context, url string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
body, ok := kept[url]
|
||||
mu.Unlock()
|
||||
if ok {
|
||||
return body, nil
|
||||
}
|
||||
body, err := fetch(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
if len(kept) >= archivesAtMost {
|
||||
clear(kept)
|
||||
}
|
||||
kept[url] = body
|
||||
mu.Unlock()
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
|
||||
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
@@ -27,6 +27,23 @@ type composer struct {
|
||||
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
|
||||
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
|
||||
unhostable []string
|
||||
|
||||
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
|
||||
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
|
||||
// on the private network, and the rest of the mesh as each node sees it.
|
||||
onNetwork []Node
|
||||
onNetworkSet bool
|
||||
rest map[string]restOf
|
||||
plans map[string]planOf
|
||||
}
|
||||
|
||||
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
|
||||
type restOf struct {
|
||||
world catalogue.World
|
||||
passes Passes
|
||||
left []LeftOut
|
||||
unhostable []string
|
||||
err error
|
||||
}
|
||||
|
||||
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
|
||||
@@ -249,6 +266,14 @@ func (c *composer) whereEveryoneIs() (map[string]string, error) {
|
||||
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
|
||||
// counted, and said.
|
||||
func (c *composer) onTheNetwork() []Node {
|
||||
if !c.onNetworkSet {
|
||||
c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
|
||||
}
|
||||
return slices.Clone(c.onNetwork)
|
||||
}
|
||||
|
||||
// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
|
||||
func (c *composer) onTheNetworkNow() []Node {
|
||||
var out []Node
|
||||
for _, n := range c.in.sorted() {
|
||||
if n.Place.Address == "" {
|
||||
|
||||
@@ -35,6 +35,26 @@ func Render(in Input, node string, mode Mode) (Rendered, error) {
|
||||
return newComposer(in).render(node, mode)
|
||||
}
|
||||
|
||||
// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh
|
||||
// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each
|
||||
// answers as it does alone; the first refusal ends it, in that order, with what follows it empty.
|
||||
func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.planFor(node)
|
||||
if err != nil {
|
||||
return Resolved{}, Rendered{}, nil, err
|
||||
}
|
||||
rendered, err := c.render(node, mode)
|
||||
if err != nil {
|
||||
return resolved, Rendered{}, nil, err
|
||||
}
|
||||
gens, err := c.generators()
|
||||
if err != nil {
|
||||
return resolved, rendered, nil, err
|
||||
}
|
||||
return resolved, rendered, gens, nil
|
||||
}
|
||||
|
||||
func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
resolved, err := c.planFor(nodeName)
|
||||
if err != nil {
|
||||
|
||||
+39
-1
@@ -36,8 +36,46 @@ func Resolve(in Input, node string) (Resolved, error) {
|
||||
// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer.
|
||||
func IsUnresolvable(err error) bool { return unresolvable(err) }
|
||||
|
||||
// planFor is the controller's planFor.
|
||||
// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its
|
||||
// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read.
|
||||
func (c *composer) planFor(nodeName string) (Resolved, error) {
|
||||
p, ok := c.plans[nodeName]
|
||||
if !ok {
|
||||
p.resolved, p.err = c.planForNow(nodeName)
|
||||
if c.plans == nil {
|
||||
c.plans = map[string]planOf{}
|
||||
}
|
||||
c.plans[nodeName] = p
|
||||
}
|
||||
if p.err != nil {
|
||||
return Resolved{}, p.err
|
||||
}
|
||||
return p.resolved.copied(), nil
|
||||
}
|
||||
|
||||
// planOf is planFor's answer for one node, kept.
|
||||
type planOf struct {
|
||||
resolved Resolved
|
||||
err error
|
||||
}
|
||||
|
||||
// copied is r with its own needs, words and settings lists: what a caller may change of it.
|
||||
func (r Resolved) copied() Resolved {
|
||||
out := r
|
||||
out.Plan.Needs = slices.Clone(r.Plan.Needs)
|
||||
out.Foreseen = slices.Clone(r.Foreseen)
|
||||
out.NotGiven = slices.Clone(r.NotGiven)
|
||||
if r.Settings != nil {
|
||||
out.Settings = make(catalogue.SettingsBy, len(r.Settings))
|
||||
for m, layers := range r.Settings {
|
||||
out.Settings[m] = slices.Clone(layers)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planForNow is planFor worked out.
|
||||
func (c *composer) planForNow(nodeName string) (Resolved, error) {
|
||||
resolved, err := c.resolution(nodeName)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
|
||||
@@ -3,6 +3,7 @@ package compose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -59,6 +60,22 @@ func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []Left
|
||||
}
|
||||
|
||||
func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
r, ok := c.rest[exclude]
|
||||
if !ok {
|
||||
r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude)
|
||||
r.unhostable = c.unhostable
|
||||
if c.rest == nil {
|
||||
c.rest = map[string]restOf{}
|
||||
}
|
||||
c.rest[exclude] = r
|
||||
}
|
||||
// Each caller has its own copy of what it may set or add to; the maps and the slices within are only read.
|
||||
c.unhostable = slices.Clone(r.unhostable)
|
||||
return r.world, r.passes, slices.Clone(r.left), r.err
|
||||
}
|
||||
|
||||
// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice.
|
||||
func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
shelf := c.in.Catalogue
|
||||
holdings := append([]catalogue.Held(nil), c.in.Holdings...)
|
||||
nodes := c.in.sorted()
|
||||
|
||||
@@ -91,8 +91,8 @@ func TestToolchainGate(t *testing.T) {
|
||||
gate, set, said := start(t, true)
|
||||
key, value := gateReport(t, "ace", "v26.12.0", old)
|
||||
gate.Toolchain(NodeBuildModule, key, value, false)
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree") {
|
||||
t.Fatalf("before the placement is settled every report counts; got %q", none)
|
||||
if a := delivery.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out
|
||||
t.Fatalf("before the placement is settled every report counts; got %+v", a)
|
||||
}
|
||||
k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter
|
||||
gate.Placement(k, v, false, old)
|
||||
@@ -111,8 +111,8 @@ func TestToolchainGate(t *testing.T) {
|
||||
// Placed on ace with a report written since: it counts, and the builders really disagree.
|
||||
k, v = placing(t, "ace", GoBuildModule, NodeBuildModule)
|
||||
gate.Placement(k, v, false, old.Add(-time.Minute))
|
||||
if _, none := agreedOf(set); !strings.HasPrefix(none, "builders disagree: v26.12.0 recipe f59571cf4be75adf on ace") {
|
||||
t.Fatalf("builders that run different versions still disagree; got %q", none)
|
||||
if a := delivery.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" {
|
||||
t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a)
|
||||
}
|
||||
// Taken off ace again (its key deleted): its report goes again.
|
||||
if !gate.Placement(k, nil, true, placed) {
|
||||
|
||||
@@ -40,6 +40,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
{"a node's health report, a part pointing at the hub", `{"v":1,"node":"anchor","network":{"state":"unhealthy",` +
|
||||
`"since":"2026-10-29T20:00:00Z","parts":[{"part":"tunnel","state":"unhealthy","reason":"the tunnel to the hub has not handshaken for over five minutes",` +
|
||||
`"toward":["hub"],"targets":["novox"],"since":"2026-10-29T20:00:00Z"}]},"at":"2026-10-29T20:00:00Z"}`, &NodeHealth{}},
|
||||
// A node fallen back to its old login (ADR 0583 §6), which the watchman raises as node-account-fallback.
|
||||
{"a node's health report, fallen back to its old login", `{"v":1,"node":"anchor","fallback":{"node":"anchor",` +
|
||||
`"from":"node.anchor--b","why":"the new login's test-dial was refused","at":"2026-11-02T01:00:00Z"},` +
|
||||
`"at":"2026-11-02T01:00:00Z"}`, &NodeHealth{}},
|
||||
{"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(c.raw), c.into); err != nil {
|
||||
@@ -60,6 +64,10 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
if _, err := EncodeNodeHealth(NodeHealth{}); err == nil {
|
||||
t.Error("a report naming no node was encoded")
|
||||
}
|
||||
if _, err := EncodeNodeHealth(NodeHealth{Node: "anchor", Fallback: &LoginFallback{Node: "ace", From: "node.anchor--b",
|
||||
Why: "refused", At: time.Now()}}); err == nil {
|
||||
t.Error("a fall back naming another node was encoded")
|
||||
}
|
||||
// ADR 0571 clause 4: who holds which seat, from placement; a node seat by its node.
|
||||
held := SeatHoldersOf(map[string]Placement{
|
||||
"ace": {Node: "ace", Holds: []Holding{{Claim: "mesh-dns-resolver", Scope: "mesh", Module: "dnsmasq"}, {Claim: "node-files", Scope: "node", Module: "mesh-files"}}},
|
||||
@@ -142,6 +150,40 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) {
|
||||
t.Errorf("%s keyed %q, %v", pull, got, err)
|
||||
}
|
||||
}
|
||||
// ADR 0565 §6: the engine works out its node's key of `packages` from the compositions it composed, the mirror's bytes
|
||||
// for the same resources (mesh-controller internal/inventory/declared_packages_test.go aNodesPackages at 0fbb5ef3):
|
||||
// a module's own composition, the node's own with a folded module's resource named by its id alone, and a file.
|
||||
res := func(s ...string) []json.RawMessage {
|
||||
var out []json.RawMessage
|
||||
for _, r := range s {
|
||||
out = append(out, json.RawMessage(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
comps := []Composed{
|
||||
{Node: "one", Module: "web", Resources: res(`{"id":"pkg-curl","type":"package","package":"curl"}`,
|
||||
`{"id":"pkg-nano","type":"package","package":"nano","absent":true}`, `{"id":"conf","type":"file","path":"/etc/web.conf"}`)},
|
||||
{Node: "one", Resources: res(`{"id":"backup.pkg-curl","type":"package","package":"curl"}`)},
|
||||
}
|
||||
want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` +
|
||||
`{"package":"nano","modules":["web"],"absent":true}]}`
|
||||
got, err := DeclaredPackagesOf("one", comps)
|
||||
if raw, _ := json.Marshal(got); err != nil || string(raw) != want {
|
||||
t.Errorf("the node's packages are %s (%v)", raw, err)
|
||||
}
|
||||
if none, _ := DeclaredPackagesOf("one", nil); string(jsonOf(none)) != `{"node":"one","packages":[]}` || EnginePackagesRead != "mesh-host.packages" {
|
||||
t.Errorf("a node declaring no package is %s", jsonOf(none))
|
||||
}
|
||||
// Their difference with the mirror: a package only one side holds, and one whose modules differ; none when they agree.
|
||||
mirror := DeclaredPackages{Node: "one", Packages: []PackageDeclared{{Package: "curl", Modules: []string{"web"}},
|
||||
{Package: "nano", Modules: []string{"web"}, Absent: true}, {Package: "vim", Modules: []string{"edit"}}}}
|
||||
if d := DiffDeclaredPackages(got, mirror); len(d) != 2 || d[0].Package != "curl" || strings.Join(d[0].Engine, ",") != "backup,web" ||
|
||||
d[1].Package != "vim" || len(d[1].Engine) != 0 || d[1].Mirror[0] != "edit" {
|
||||
t.Errorf("the difference is %+v", d)
|
||||
}
|
||||
if d := DiffDeclaredPackages(got, got); d == nil || len(d) != 0 {
|
||||
t.Errorf("one declaration differs from itself: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOf(v any) []byte {
|
||||
|
||||
@@ -242,9 +242,22 @@ func TestRoundTrip(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeGrantsUserList(raw)
|
||||
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
|
||||
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants || back.LeaseHanded {
|
||||
t.Fatalf("decoded %+v, %v", back, err)
|
||||
}
|
||||
// ADR 0582 clause 3 (issue 774, 774/S3): a key written before the flag existed reads not handed; one written with
|
||||
// it reads handed.
|
||||
if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` +
|
||||
`"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z"}`)); err != nil || old.LeaseHanded {
|
||||
t.Errorf("a key from before the lease write decodes %+v, %v", old, err)
|
||||
}
|
||||
g.LeaseHanded = true
|
||||
if raw, err := EncodeGrantsUserList(g); err != nil || !bytes.Contains(raw, []byte(`"lease-handed":true`)) {
|
||||
t.Errorf("the handed flag is written as %s, %v", raw, err)
|
||||
} else if back, err := DecodeGrantsUserList(raw); err != nil || !back.LeaseHanded {
|
||||
t.Errorf("the handed flag reads back %+v, %v", back, err)
|
||||
}
|
||||
g.LeaseHanded = false
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// **A node's declared packages, worked out by its engine** (novox/hq ADR 0565 §1, §6; issue 1953): the derivation and
|
||||
// the comparison of the node-engine's state `packages` (EnginePackagesState, layout DeclaredPackages), so the engine
|
||||
// that writes the key, the node seat's verb that compares it with the controller's mirror, and any reader work them out
|
||||
// one way. The controller's own copy of the derivation (mesh-controller internal/inventory DeclaredPackagesValue) is
|
||||
// not changed (ADR 0547 §1) and ends with its lease.
|
||||
|
||||
// EnginePackagesRead is the engine's state `packages` as a reader names it in `reads`: the node-engine's manifest is
|
||||
// mesh-host's, so its bucket is mesh-host_packages, read as disk-load reads mesh-host.data (EngineDataRead).
|
||||
const EnginePackagesRead = "mesh-host." + EnginePackagesState
|
||||
|
||||
// DeclaredPackagesOf is a node's key of `packages` from the compositions its engine composed for the node's wants:
|
||||
// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so,
|
||||
// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the
|
||||
// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module
|
||||
// lists none.
|
||||
func DeclaredPackagesOf(node string, compositions []Composed) (DeclaredPackages, error) {
|
||||
by := map[packageKey]map[string]bool{}
|
||||
for _, c := range compositions {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Package string `json:"package"`
|
||||
Absent bool `json:"absent"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return DeclaredPackages{}, fmt.Errorf("delivery: a resource of %s's composition %q is not readable: %w", node, c.Module, err)
|
||||
}
|
||||
if r.Type != "package" || r.Package == "" {
|
||||
continue
|
||||
}
|
||||
k := packageKey{r.Package, r.Absent}
|
||||
if by[k] == nil {
|
||||
by[k] = map[string]bool{}
|
||||
}
|
||||
module := r.Module
|
||||
if module == "" {
|
||||
module = c.Module
|
||||
}
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
if module != "" {
|
||||
by[k][module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
v := DeclaredPackages{Node: node, Packages: []PackageDeclared{}}
|
||||
for _, k := range sortedPackageKeys(by) {
|
||||
modules := make([]string, 0, len(by[k]))
|
||||
for m := range by[k] {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
v.Packages = append(v.Packages, PackageDeclared{Package: k.name, Modules: modules, Absent: k.absent})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// PackageDifference is one package two declarations of a node hold differently: the modules each side declares it
|
||||
// with (present, or absent when Absent), none on a side that does not hold it.
|
||||
type PackageDifference struct {
|
||||
Package string `json:"package"`
|
||||
Absent bool `json:"absent"`
|
||||
Engine []string `json:"engine"`
|
||||
Mirror []string `json:"mirror"`
|
||||
}
|
||||
|
||||
// DiffDeclaredPackages is every package the engine's key and the controller's mirror hold differently for one node, in
|
||||
// the layout's order: what the node seat's verb packages answers until the lease write (ADR 0565 §6). Empty when they
|
||||
// agree.
|
||||
func DiffDeclaredPackages(engine, mirror DeclaredPackages) []PackageDifference {
|
||||
index := func(d DeclaredPackages) map[packageKey][]string {
|
||||
out := map[packageKey][]string{}
|
||||
for _, p := range d.Packages {
|
||||
out[packageKey{p.Package, p.Absent}] = append([]string{}, p.Modules...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
e, m := index(engine), index(mirror)
|
||||
union := map[packageKey]bool{}
|
||||
for k := range e {
|
||||
union[k] = true
|
||||
}
|
||||
for k := range m {
|
||||
union[k] = true
|
||||
}
|
||||
out := []PackageDifference{}
|
||||
for _, k := range sortedPackageKeys(union) {
|
||||
em, inE := e[k]
|
||||
mm, inM := m[k]
|
||||
if inE && inM && slices.Equal(em, mm) {
|
||||
continue
|
||||
}
|
||||
if em == nil {
|
||||
em = []string{}
|
||||
}
|
||||
if mm == nil {
|
||||
mm = []string{}
|
||||
}
|
||||
out = append(out, PackageDifference{Package: k.name, Absent: k.absent, Engine: em, Mirror: mm})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent.
|
||||
type packageKey struct {
|
||||
name string
|
||||
absent bool
|
||||
}
|
||||
|
||||
// sortedPackageKeys is the keys of m by name, absent after present.
|
||||
func sortedPackageKeys[V any](m map[packageKey]V) []packageKey {
|
||||
keys := make([]packageKey, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].name != keys[j].name {
|
||||
return keys[i].name < keys[j].name
|
||||
}
|
||||
return !keys[i].absent && keys[j].absent
|
||||
})
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// **The state of the seat mesh-enrolment** (novox/hq ADR 0587 §2, issue 774): the holder's bucket `enrolments`, holding
|
||||
// no secret, with two kinds of key. `node.<node>`, one per open enrolment, holds an Enrolment; `address.<address>`
|
||||
// reserves one private-network address for one open enrolment and holds the node's name and, from the ask, the ask and the issue's time. Every write is a
|
||||
// compare-and-set at the revision read. The bucket keeps per-key expiry (`key-ttl`): a key at stage live carries the
|
||||
// token's expiry, at asked and redeeming none (§5). It is read by the holder and, as a named reader, by the grants part,
|
||||
// which composes the user `enrol.<node>` from a key at live and the vault's hash (§4). These constants are the seat's
|
||||
// contract: every reader takes the names from here.
|
||||
const (
|
||||
// EnrolmentModule is the module holding the seat mesh-enrolment.
|
||||
EnrolmentModule = catalogue.EnrolmentSeat
|
||||
// EnrolmentsState is its state, EnrolmentsBucket the bucket, EnrolmentsRead the name a reader's manifest declares.
|
||||
EnrolmentsState = "enrolments"
|
||||
EnrolmentsBucket = EnrolmentModule + "_" + EnrolmentsState
|
||||
EnrolmentsRead = EnrolmentModule + "." + EnrolmentsState
|
||||
|
||||
// The stages of an open enrolment (§2, §4, §5).
|
||||
EnrolmentAsked = "asked"
|
||||
EnrolmentLive = "live"
|
||||
EnrolmentRedeeming = "redeeming"
|
||||
|
||||
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (§4).
|
||||
DefaultTokenLife = time.Hour
|
||||
MostTokenLife = 24 * time.Hour
|
||||
|
||||
// EnrolmentUserPrefix is the bus user a joining machine dials as, `enrol.<node>` (§4).
|
||||
EnrolmentUserPrefix = "enrol."
|
||||
|
||||
enrolmentNodePrefix = "node."
|
||||
enrolmentAddressPrefix = "address."
|
||||
)
|
||||
|
||||
// Enrolment is one key `node.<node>` of `enrolments`: the node's name, the private-network address chosen at issue, the
|
||||
// why, the caller as the runtime proved it, the ask's id, the stage, when it was issued and when the token expires, and
|
||||
// from redeem the digest of the public keys presented (KeysDigest). No secret and nothing derived from the token.
|
||||
type Enrolment struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Why string `json:"why"`
|
||||
Caller string `json:"caller"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Stage string `json:"stage"`
|
||||
Issued time.Time `json:"issued"`
|
||||
Expires time.Time `json:"expires"`
|
||||
KeysDigest string `json:"keys-digest,omitempty"`
|
||||
// OverlayKey is the machine's tunnel public key, given at issue (the operator's decision of 2026-11-02 01:05),
|
||||
// base64 of 32 bytes: at every stage, since the node enters placement with it on the operator's yes.
|
||||
OverlayKey string `json:"overlay-key"`
|
||||
}
|
||||
|
||||
// EnrolmentAddress is one key `address.<address>`: the node the address is reserved for and, from when the issue's ask
|
||||
// is raised, that ask's id and the issue's time (ADR 0589 §6), so the reservation names the enrolment it holds the
|
||||
// address for. The key is written when the address is reserved, before the ask: Ask and Issued are then both empty, and
|
||||
// from the ask both set; one without the other is refused.
|
||||
type EnrolmentAddress struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Ask string `json:"ask,omitempty"`
|
||||
Issued time.Time `json:"issued,omitzero"`
|
||||
}
|
||||
|
||||
func (a EnrolmentAddress) check() error {
|
||||
if err := checkName("node", a.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if (strings.TrimSpace(a.Ask) == "") != a.Issued.IsZero() {
|
||||
return fmt.Errorf("delivery: the address reserved for %s names its ask without the issue's time, or the reverse", a.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnrolmentIssuedID is the id of the enrolment-issued event of node's enrolment issued at issued:
|
||||
// `enrolment-issued.<node>.<issue time in Unix nanoseconds>`, what enrolment-expired names (EnrolmentExpiry).
|
||||
func EnrolmentIssuedID(node string, issued time.Time) string {
|
||||
return catalogue.EnrolmentIssuedEvent + "." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// EnrolmentNodeKey is a node's key in `enrolments`.
|
||||
func EnrolmentNodeKey(node string) string { return enrolmentNodePrefix + node }
|
||||
|
||||
// EnrolmentAddressKey is an address's key in `enrolments`: the address as it is, its dots kept (a key's tokens).
|
||||
func EnrolmentAddressKey(address string) string { return enrolmentAddressPrefix + address }
|
||||
|
||||
// EnrolmentKeyNode is the node a key `node.<node>` names, and whether it is one.
|
||||
func EnrolmentKeyNode(key string) (string, bool) {
|
||||
node, ok := strings.CutPrefix(key, enrolmentNodePrefix)
|
||||
return node, ok && checkName("node", node) == nil
|
||||
}
|
||||
|
||||
// EnrolmentKeyAddress is the address a key `address.<address>` names, and whether it is one.
|
||||
func EnrolmentKeyAddress(key string) (string, bool) {
|
||||
address, ok := strings.CutPrefix(key, enrolmentAddressPrefix)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
_, err := netip.ParseAddr(address)
|
||||
return address, err == nil
|
||||
}
|
||||
|
||||
// EnrolmentUser is the bus user a node enrols as.
|
||||
func EnrolmentUser(node string) string { return EnrolmentUserPrefix + node }
|
||||
|
||||
func (e Enrolment) check() error {
|
||||
if err := checkName("node", e.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := netip.ParseAddr(e.Address); err != nil {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names the address %q, which is not an address", e.Node, e.Address)
|
||||
}
|
||||
if strings.TrimSpace(e.Why) == "" || strings.TrimSpace(e.Caller) == "" {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no why or no caller", e.Node)
|
||||
}
|
||||
if k, err := base64.StdEncoding.DecodeString(e.OverlayKey); err != nil || len(k) != 32 {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no tunnel key of 32 bytes in base64", e.Node)
|
||||
}
|
||||
switch e.Stage {
|
||||
case EnrolmentAsked, EnrolmentLive:
|
||||
if e.KeysDigest != "" {
|
||||
return fmt.Errorf("delivery: the enrolment of %s at %s names presented keys, which only a redeem gives", e.Node, e.Stage)
|
||||
}
|
||||
case EnrolmentRedeeming:
|
||||
if err := checkDigest(e.KeysDigest, "the enrolment's keys digest"); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the enrolment of %s is at stage %q, not asked, live or redeeming", e.Node, e.Stage)
|
||||
}
|
||||
if e.Issued.IsZero() || !e.Expires.After(e.Issued) {
|
||||
return fmt.Errorf("delivery: the enrolment of %s names no issue or an expiry not after it", e.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeEnrolment is e as JSON at Version; it refuses what DecodeEnrolment refuses.
|
||||
func EncodeEnrolment(e Enrolment) ([]byte, error) {
|
||||
if err := e.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.V = Version
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// DecodeEnrolment reads a key `node.<node>`; it refuses an unknown version (ErrUnknownVersion), a node that is not a
|
||||
// name, an address that is not one, an unknown stage, presented keys before redeem or none at it, and an expiry not
|
||||
// after the issue.
|
||||
func DecodeEnrolment(data []byte) (Enrolment, error) {
|
||||
var e Enrolment
|
||||
if err := decode(data, &e); err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
if err := e.check(); err != nil {
|
||||
return Enrolment{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// EncodeEnrolmentAddress is a key `address.<address>` reserving it for node: with no ask and a zero issued when it is
|
||||
// reserved, with the issue's ask and time once the ask is raised. It refuses what DecodeEnrolmentAddress refuses.
|
||||
func EncodeEnrolmentAddress(node, ask string, issued time.Time) ([]byte, error) {
|
||||
a := EnrolmentAddress{V: Version, Node: node, Ask: ask, Issued: issued}
|
||||
if err := a.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeEnrolmentAddress reads a key `address.<address>`; it refuses no node, and an ask without the issue's time or
|
||||
// the reverse.
|
||||
func DecodeEnrolmentAddress(data []byte) (EnrolmentAddress, error) {
|
||||
var a EnrolmentAddress
|
||||
if err := decode(data, &a); err != nil {
|
||||
return EnrolmentAddress{}, err
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return EnrolmentAddress{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// TokenLife is the life issue's `expires` names: DefaultTokenLife when empty, refused when it is not a duration, not
|
||||
// positive or longer than MostTokenLife.
|
||||
func TokenLife(expires string) (time.Duration, error) {
|
||||
if strings.TrimSpace(expires) == "" {
|
||||
return DefaultTokenLife, nil
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(expires))
|
||||
if err != nil || d <= 0 || d > MostTokenLife {
|
||||
return 0, fmt.Errorf("delivery: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// EnrolmentExpiry is what enrolment-expired carries (§2): the node, and the enrolment-issued event that carried the
|
||||
// key's content, named by its message id (the bundle's publish answers no stream sequence, and §2 asks only that the
|
||||
// event be named), since the bus deleted the key before the holder heard of it.
|
||||
type EnrolmentExpiry struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
IssuedID string `json:"issued-event"`
|
||||
}
|
||||
|
||||
func (x EnrolmentExpiry) check() error {
|
||||
if err := checkName("node", x.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(x.IssuedID) == "" {
|
||||
return fmt.Errorf("delivery: the expiry of %s's enrolment names no enrolment-issued event", x.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeEnrolmentExpiry is x as JSON at Version; it refuses what DecodeEnrolmentExpiry refuses.
|
||||
func EncodeEnrolmentExpiry(x EnrolmentExpiry) ([]byte, error) {
|
||||
if err := x.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.V = Version
|
||||
return json.Marshal(x)
|
||||
}
|
||||
|
||||
// DecodeEnrolmentExpiry reads enrolment-expired's body; it refuses an unknown version, a node that is not a name and
|
||||
// no issued event named.
|
||||
func DecodeEnrolmentExpiry(data []byte) (EnrolmentExpiry, error) {
|
||||
var x EnrolmentExpiry
|
||||
if err := decode(data, &x); err != nil {
|
||||
return EnrolmentExpiry{}, err
|
||||
}
|
||||
if err := x.check(); err != nil {
|
||||
return EnrolmentExpiry{}, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine,
|
||||
// which live in two repositories.
|
||||
|
||||
// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over
|
||||
// RedeemProof. No token: the bus's login check verified it.
|
||||
type Redeem struct {
|
||||
Node string `json:"node"`
|
||||
PublicKey string `json:"public_key"`
|
||||
OverlayKey string `json:"overlay_key,omitempty"`
|
||||
SealingKey string `json:"sealing_key"`
|
||||
ServingKey string `json:"serving_key,omitempty"`
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
Tunnel json.RawMessage `json:"tunnel,omitempty"`
|
||||
Proof string `json:"proof"`
|
||||
}
|
||||
|
||||
// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it
|
||||
// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes.
|
||||
func RedeemProof(node, public, overlay, sealing, serving string) []byte {
|
||||
return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving)
|
||||
}
|
||||
|
||||
// KeysDigest is the digest of the public keys a redeem presents, `sha256:<hex>`: what the key at redeeming and the
|
||||
// events carry, the one hash an event carries (§1).
|
||||
func KeysDigest(public, overlay, sealing, serving string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00")))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// KeysDigest is r's keys' digest.
|
||||
func (r Redeem) KeysDigest() string {
|
||||
return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey)
|
||||
}
|
||||
|
||||
// ProofBytes is r's proof decoded from base64.
|
||||
func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) }
|
||||
|
||||
// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it
|
||||
// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over
|
||||
// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries.
|
||||
type RedeemAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Account string `json:"account"`
|
||||
Certificate string `json:"certificate"`
|
||||
Signer string `json:"signer"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
}
|
||||
|
||||
// SignedBytes is what the vault signs of a: a without its signature, as JSON.
|
||||
func (a RedeemAnswer) SignedBytes() []byte {
|
||||
a.Signature = ""
|
||||
raw, _ := json.Marshal(a)
|
||||
return raw
|
||||
}
|
||||
|
||||
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
|
||||
const (
|
||||
EnrolmentTokenVerb = "enrolment-token"
|
||||
NodeAccountVerb = "node-account"
|
||||
)
|
||||
|
||||
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
|
||||
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
|
||||
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
|
||||
// nor its hash.
|
||||
type EnrolmentTokenAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Broker string `json:"broker,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Signer string `json:"signer,omitempty"`
|
||||
Sealed string `json:"sealed,omitempty"`
|
||||
Dropped bool `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
|
||||
// presented (base64), never in the clear; Retired says a retire left no account.
|
||||
type NodeAccountAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login,omitempty"`
|
||||
Account string `json:"account,omitempty"`
|
||||
Retired bool `json:"retired,omitempty"`
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
// SourcePrefix2 opens every source fingerprint of the new form.
|
||||
const SourcePrefix2 = "src2:"
|
||||
|
||||
// NoPackageVersion opens the reason a module has no fingerprint while a mesh package it uses has no published version
|
||||
// the indexer holds (novox/hq ADR 0573 §2); the packages follow, by module path.
|
||||
const NoPackageVersion = "no published version of the package "
|
||||
|
||||
// Toolchain2 is what an artifact's toolchain enters the new fingerprint as (novox/hq ADR 0405 clause 7): the
|
||||
// artifact's language, the installed toolchain's version and the build module's recipe version, in place of the
|
||||
// toolchain image's digest. A toolchain update so changes the fingerprints of that language's modules only.
|
||||
@@ -37,15 +41,19 @@ type SourceInputs2 struct {
|
||||
Contexts map[string]string
|
||||
Locks map[string]string
|
||||
Toolchains map[string]Toolchain2
|
||||
Unpinned string
|
||||
// Packages are the version of each mesh package the module uses, by package (novox/hq ADR 0573 §2): a package with
|
||||
// no version gives no fingerprint. None leaves the fingerprint as it was before packages entered it.
|
||||
Packages map[string]string
|
||||
Unpinned string
|
||||
}
|
||||
|
||||
// SourceFingerprint2 is the module's source fingerprint of the new form, `src2:` and the hex sha256 of its inputs'
|
||||
// lines, or none and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is
|
||||
// empty, "no lock" when an artifact resolves packages and has no lock. It is a pure function of its inputs: the order
|
||||
// of maps and a base given twice change nothing. The lines are "module <m>", "tree <t>" and "builder <fp>", then
|
||||
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>" and "toolchain <artifact> <language>
|
||||
// <version> <recipe>", sorted, all joined by a newline.
|
||||
// "base <fp>" (once each), "context <artifact> <tree>", "lock <artifact> <hash>", "toolchain <artifact> <language>
|
||||
// <version> <recipe>" and "package <package> <version>", sorted, all joined by a newline. A module that uses no mesh
|
||||
// package has no package line, so its fingerprint is the one it had before packages entered (novox/hq ADR 0573 §2).
|
||||
func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
if in.Unpinned != "" {
|
||||
return "", in.Unpinned
|
||||
@@ -58,6 +66,16 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
return "", "no lock"
|
||||
}
|
||||
}
|
||||
var unpublished []string
|
||||
for p, v := range in.Packages {
|
||||
if v == "" {
|
||||
unpublished = append(unpublished, p)
|
||||
}
|
||||
}
|
||||
if len(unpublished) > 0 {
|
||||
sort.Strings(unpublished)
|
||||
return "", NoPackageVersion + strings.Join(unpublished, ", ")
|
||||
}
|
||||
lines := []string{"module " + in.Module, "tree " + in.Tree, "builder " + in.Builder}
|
||||
bases := map[string]bool{}
|
||||
for _, b := range in.Bases {
|
||||
@@ -75,6 +93,9 @@ func SourceFingerprint2(in SourceInputs2) (fp, none string) {
|
||||
for a, t := range in.Toolchains {
|
||||
lines = append(lines, "toolchain "+a+" "+t.Language+" "+t.Version+" "+t.Recipe)
|
||||
}
|
||||
for p, v := range in.Packages {
|
||||
lines = append(lines, "package "+p+" "+v)
|
||||
}
|
||||
sort.Strings(lines[3:])
|
||||
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
|
||||
return SourcePrefix2 + hex.EncodeToString(sum[:]), ""
|
||||
|
||||
@@ -62,6 +62,9 @@ func TestFingerprint2Changes(t *testing.T) {
|
||||
"a context": func(in *SourceInputs2) { in.Contexts["mail"] = "t-changed" },
|
||||
"a context added": func(in *SourceInputs2) { in.Contexts["extra"] = "t-root" },
|
||||
"a toolchain added": func(in *SourceInputs2) { in.Toolchains["extra"] = Toolchain2{"go", "1.26.0", "3"} },
|
||||
// Issue 1585 (ADR 0573 §2): a package's new published version, and a package used at all.
|
||||
"a package added": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c1"} },
|
||||
"a package version": func(in *SourceInputs2) { in.Packages = map[string]string{"git.novox.be/novox/mesh-sdk/go": "c2"} },
|
||||
} {
|
||||
in := inputs2()
|
||||
f(&in)
|
||||
@@ -111,6 +114,17 @@ func TestFingerprint2None(t *testing.T) {
|
||||
if fp, none := SourceFingerprint2(in); fp != "" || none != "no tree" {
|
||||
t.Errorf("no tree: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
// Issue 1585: a package with no published version gives none, naming it; an empty set of packages changes nothing.
|
||||
in = inputs2()
|
||||
in.Packages = map[string]string{"git.novox.be/novox/b/go": "", "git.novox.be/novox/a/go": "", "git.novox.be/novox/c/go": "c1"}
|
||||
if fp, none := SourceFingerprint2(in); fp != "" || none != NoPackageVersion+"git.novox.be/novox/a/go, git.novox.be/novox/b/go" {
|
||||
t.Errorf("no package version: fp = %q, none = %q", fp, none)
|
||||
}
|
||||
in = inputs2()
|
||||
in.Packages = map[string]string{}
|
||||
if fp, _ := SourceFingerprint2(in); fp != mustFP2(t, inputs2()) {
|
||||
t.Errorf("no package changes the fingerprint: %s", fp)
|
||||
}
|
||||
in = inputs2()
|
||||
in.Tree, in.Unpinned = "", "a context is a Go program's build source"
|
||||
if _, none := SourceFingerprint2(in); none != in.Unpinned {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -163,4 +164,35 @@ func TestModuleKeepsInputs2(t *testing.T) {
|
||||
if _, err := EncodeModule(m); err == nil {
|
||||
t.Error("Inputs2 without a manifest digest was encoded")
|
||||
}
|
||||
|
||||
// Issue 1585 (ADR 0573 §2): the mesh packages a go.sum names, those of other repositories, and the latest published
|
||||
// version of each: the trunk commit of the deepest module of its repository whose directory holds it.
|
||||
sum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:a=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:b=\n" +
|
||||
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:c=\n" +
|
||||
"github.com/nats-io/nats.go v1.0.0 h1:d=\ngit.novox.be/novox/mesh-host v0.0.0-1 h1:e=\n"
|
||||
pkgs := MeshPackagesOf([]byte(sum))
|
||||
if want := []string{"git.novox.be/novox/mesh-controller/modules/route-proxy/contract", "git.novox.be/novox/mesh-host", "git.novox.be/novox/mesh-sdk/go"}; !reflect.DeepEqual(pkgs, want) {
|
||||
t.Errorf("the packages of a go.sum: %v, want %v", pkgs, want)
|
||||
}
|
||||
if got := OtherPackages(pkgs, "novox/mesh-controller"); len(got) != 2 || got[0] != "git.novox.be/novox/mesh-host" {
|
||||
t.Errorf("the packages of other repositories: %v", got)
|
||||
}
|
||||
held := map[string]Module{
|
||||
"mesh-sdk": {Repository: "novox/mesh-sdk", Path: RootPath, Commit: "c-sdk"},
|
||||
"route-proxy": {Repository: "novox/mesh-controller", Path: "modules/route-proxy", Commit: "c-rp"},
|
||||
"controller": {Repository: "novox/mesh-controller", Path: RootPath, Commit: "c-ctl"},
|
||||
"route-proxy2": {Repository: "novox/mesh-controller", Path: "modules/route", Commit: "c-wrong"},
|
||||
"no-path": {Repository: "novox/mesh-host", Commit: "c-host"},
|
||||
}
|
||||
versions, missing := PackageVersionsOf(pkgs, held)
|
||||
if want := (PackageVersions{"git.novox.be/novox/mesh-sdk/go": "c-sdk", "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": "c-rp"}); !reflect.DeepEqual(versions, want) {
|
||||
t.Errorf("versions %v, want %v", versions, want)
|
||||
}
|
||||
if len(missing) != 1 || missing[0] != "git.novox.be/novox/mesh-host" {
|
||||
t.Errorf("missing %v: a module that names no directory holds no package", missing)
|
||||
}
|
||||
kept.Packages = []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-host"}
|
||||
if fp, none := SourceFingerprint2(kept.WithPackages("m", tc, versions)); fp != "" || !strings.HasPrefix(none, NoPackageVersion) {
|
||||
t.Errorf("a package with no version: %q %q", fp, none)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,17 +356,16 @@ func TestGateSame(t *testing.T) { // 1201/AC6
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateBuckets(t *testing.T) {
|
||||
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
||||
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
||||
// And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's
|
||||
// token test).
|
||||
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 {
|
||||
t.Error("a bucket or history differs from the decided layout")
|
||||
}
|
||||
}
|
||||
|
||||
// today's keys: names of letters, digits, - / and _ key exactly as before the escape.
|
||||
func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2
|
||||
hh, _ := HeadsHash(gateHeads())
|
||||
for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} {
|
||||
if k, err := MergesKey(name); err != nil || k != name {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A node's two logins** (novox/hq ADR 0583, issue 774). A node's bus account rotates between the login `node.<n>` and
|
||||
// `node.<n>--b`, each with the node's permissions and its inbox `_INBOX.node.<n>` unchanged (§1). Which one a node uses
|
||||
// is the vault's state `node-logins`, one key per node (§1, insight 2026-11-01), written by the vault alone: the login
|
||||
// in use, the login rotating if any, and the rotation's stage. Each node-engine reads its own node's key alone; the
|
||||
// grants part reads them all and composes both logins while a node rotates (§2). The key lives while the node does:
|
||||
// its rotating part is cleared at rotation-done or abandon, and the key deleted when the node leaves placement.
|
||||
const (
|
||||
NodeLoginsState = "node-logins"
|
||||
NodeLoginsBucket = VaultModule + "_" + NodeLoginsState
|
||||
NodeLoginsRead = VaultModule + "." + NodeLoginsState
|
||||
|
||||
// The stages of a rotation (§2-§4): begun when the vault made the other login's value and wrote it rotating; ready
|
||||
// when the bus node's have shows a list holding both, said node-account-ready; confirmed when the engine called
|
||||
// node-account-confirm as the new login. Done (rotation-done) is no stage: the rotating part is cleared.
|
||||
RotationBegun = "begun"
|
||||
RotationReady = "ready"
|
||||
RotationConfirmed = "confirmed"
|
||||
|
||||
// The vault's rotation verbs on its seat (§2, §3, §5).
|
||||
RotateNodeVerb = "rotate-node"
|
||||
NodeAccountConfirmVerb = "node-account-confirm"
|
||||
AbandonVerb = "abandon"
|
||||
|
||||
// The vault's events of a rotation, said as its own (§2, §4), each carrying the node's NodeLogin.
|
||||
RotationBegunEvent = "rotation-begun"
|
||||
NodeAccountReadyEvent = "node-account-ready"
|
||||
RotationDoneEvent = "rotation-done"
|
||||
|
||||
// NodeAccountFallbackFinding is what a node-engine says when it fell back to its old login after repeated
|
||||
// authentication failures with the new one, before rotation-done; the watchman raises it (§6).
|
||||
NodeAccountFallbackFinding = "node-account-fallback"
|
||||
|
||||
nodeLoginPrefix = "node."
|
||||
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
|
||||
SecondLoginSuffix = "--b"
|
||||
)
|
||||
|
||||
// RotationEventSubject is where the vault says event of a rotation.
|
||||
func RotationEventSubject(event string) string { return "mesh.mod." + VaultModule + ".event." + event }
|
||||
|
||||
// NodeLogin is one key of `node-logins`, the node's name: the login in use, the login rotating to (empty when none, and
|
||||
// then no stage), the rotation's stage, since when the key says what it says, and the user list's revision that held
|
||||
// both logins, from ready on.
|
||||
type NodeLogin struct {
|
||||
V int `json:"v"`
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Rotating string `json:"rotating,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
ListRevision uint64 `json:"list-revision,omitempty"`
|
||||
// Acts are the hand-act ids that warranted the node's rotations, oldest first (ADR 0583 §2): the vault records each.
|
||||
Acts []string `json:"acts,omitempty"`
|
||||
}
|
||||
|
||||
// FirstLogin is a node's first login, `node.<n>`, the one every node had before rotation; SecondLogin its other.
|
||||
func FirstLogin(node string) string { return nodeLoginPrefix + node }
|
||||
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
|
||||
|
||||
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
|
||||
func OtherLogin(node, login string) string {
|
||||
if login == SecondLogin(node) {
|
||||
return FirstLogin(node)
|
||||
}
|
||||
return SecondLogin(node)
|
||||
}
|
||||
|
||||
// LoginNode is the node a node login names, either of its two, and whether login is one.
|
||||
func LoginNode(login string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
|
||||
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
|
||||
return rest, ok && checkName("node", rest) == nil
|
||||
}
|
||||
|
||||
// Rotates says the node is rotating: a login rotating is named.
|
||||
func (l NodeLogin) Rotates() bool { return l.Rotating != "" }
|
||||
|
||||
func (l NodeLogin) check() error {
|
||||
if err := checkName("node", l.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
|
||||
return fmt.Errorf("delivery: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
|
||||
}
|
||||
for _, act := range l.Acts {
|
||||
if strings.TrimSpace(act) == "" {
|
||||
return fmt.Errorf("delivery: the node %s names an empty hand act among its rotations' warrants", l.Node)
|
||||
}
|
||||
}
|
||||
if !l.Rotates() {
|
||||
if l.Stage != "" || l.ListRevision != 0 {
|
||||
return fmt.Errorf("delivery: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if l.Rotating != OtherLogin(l.Node, l.Login) {
|
||||
return fmt.Errorf("delivery: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
|
||||
}
|
||||
switch l.Stage {
|
||||
case RotationBegun, RotationReady, RotationConfirmed:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
|
||||
func EncodeNodeLogin(l NodeLogin) ([]byte, error) {
|
||||
if err := l.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.V = Version
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion), a login that is not
|
||||
// one of the node's two, a rotation to anything but the other, and a stage without a rotation or an unknown one. An
|
||||
// empty rotating reads as none.
|
||||
func DecodeNodeLogin(data []byte) (NodeLogin, error) {
|
||||
var l NodeLogin
|
||||
if err := decode(data, &l); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
if err := l.check(); err != nil {
|
||||
return NodeLogin{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
// what it says changed. The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the
|
||||
// passing health message: machine-network (Network), network-rewritten (the resolver file's part, Writer and Owner),
|
||||
// network-unreachable (Peers, read across every node's report), machine-units (Units), relogin-needed (Relogin) and
|
||||
// directory-used-as-found (Found). Moved here from mesh-host internal/link (65fbc1e6) unchanged; the engine builds a
|
||||
// report from its own health statement there (NodeHealthOf).
|
||||
// directory-used-as-found (Found); and for node-account-fallback (Fallback, ADR 0583 §6). Moved here from mesh-host
|
||||
// internal/link (65fbc1e6); the engine builds a report from its own health statement there (NodeHealthOf).
|
||||
|
||||
// NodeHealthState and NodeHealthBucket are the node-engine's state health, as a reader names it and as the bus does.
|
||||
const (
|
||||
@@ -40,6 +40,10 @@ type NodeHealth struct {
|
||||
Relogin []NodeAccount `json:"relogin,omitempty"`
|
||||
// Found are the directories used as found.
|
||||
Found []NodeDirectory `json:"found,omitempty"`
|
||||
// Fallback is set when the engine fell back to its node's old login after the new one failed its test-dial or its
|
||||
// confirm (novox/hq ADR 0583 §6): the watchman raises node-account-fallback from it. Nil while the node logs in as
|
||||
// its login in force.
|
||||
Fallback *LoginFallback `json:"fallback,omitempty"`
|
||||
// Root are the engine's verdicts on each account a module declares never to become root without a person (novox/hq
|
||||
// ADR 0266), sorted: the agent account's among them where a module declares it. What the self-check's DA and D-root
|
||||
// judge an agent's confinement by, on the machine's own word.
|
||||
@@ -51,6 +55,15 @@ type NodeHealth struct {
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// LoginFallback is a node's fall back to its old login (ADR 0583 §6): the node, the login it fell back from (the one the
|
||||
// rotation moved to), why, in words with no credential, and when.
|
||||
type LoginFallback struct {
|
||||
Node string `json:"node"`
|
||||
From string `json:"from"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// NetworkHealth is the machine's networking (ADR 0241): the worst of its parts, since when, and each part.
|
||||
type NetworkHealth struct {
|
||||
State string `json:"state"`
|
||||
@@ -150,6 +163,9 @@ func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
|
||||
if h.Node == "" {
|
||||
return nil, errors.New("a node's health report names its node")
|
||||
}
|
||||
if f := h.Fallback; f != nil && (f.Node != h.Node || f.From == "" || f.Why == "" || f.At.IsZero()) {
|
||||
return nil, fmt.Errorf("a node's fall back to its old login names its node %s, the login fallen back from, why and when", h.Node)
|
||||
}
|
||||
return json.Marshal(h)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -13,7 +16,7 @@ import (
|
||||
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
||||
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
||||
|
||||
// Issue 1126 (ADR 0555): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
||||
// neither it nor the placement file names a field like a secret.
|
||||
func TestNodeRecord(t *testing.T) {
|
||||
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
||||
@@ -68,6 +71,143 @@ func TestNodeRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
||||
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
|
||||
// grants part reads them (ADR 0587 §2).
|
||||
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
||||
// The tunnel key given at issue, at every stage.
|
||||
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
for _, e := range []Enrolment{
|
||||
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
|
||||
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
|
||||
} {
|
||||
raw, err := EncodeEnrolment(e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeEnrolment(raw)
|
||||
e.V = Version
|
||||
if err != nil || !reflect.DeepEqual(back, e) {
|
||||
t.Errorf("enrolment round trip: %+v, %v", back, err)
|
||||
}
|
||||
}
|
||||
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
|
||||
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
|
||||
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
|
||||
}
|
||||
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
|
||||
t.Errorf("address at the ask round trip: %+v, %v", back, err)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"no node": `{"v":1}`,
|
||||
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
|
||||
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
|
||||
} {
|
||||
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
|
||||
t.Errorf("an address with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
|
||||
t.Errorf("the issued event's id: %q", id)
|
||||
}
|
||||
for name, e := range map[string]Enrolment{
|
||||
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
|
||||
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
|
||||
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
|
||||
Expires: issued.Add(time.Hour)},
|
||||
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
|
||||
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
|
||||
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
|
||||
Expires: issued.Add(time.Hour)},
|
||||
} {
|
||||
if _, err := EncodeEnrolment(e); err == nil {
|
||||
t.Errorf("enrolment with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
|
||||
t.Errorf("address key: %q %v", key, ok)
|
||||
}
|
||||
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
|
||||
t.Errorf("node key: %q %v", node, ok)
|
||||
}
|
||||
for _, life := range []string{"", "1h", "24h"} {
|
||||
if _, err := TokenLife(life); err != nil {
|
||||
t.Errorf("a life of %q: %v", life, err)
|
||||
}
|
||||
}
|
||||
for _, life := range []string{"25h", "-1h", "a day"} {
|
||||
if _, err := TokenLife(life); err == nil {
|
||||
t.Errorf("a life of %q: taken", life)
|
||||
}
|
||||
}
|
||||
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
|
||||
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
|
||||
for _, l := range []NodeLogin{
|
||||
{Node: "g14", Login: "node.g14", Since: issued},
|
||||
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
|
||||
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
|
||||
} {
|
||||
raw, err := EncodeNodeLogin(l)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeNodeLogin(raw)
|
||||
l.V = Version
|
||||
if err != nil || !reflect.DeepEqual(back, l) {
|
||||
t.Errorf("node login round trip: %+v, %v", back, err)
|
||||
}
|
||||
}
|
||||
// The hand acts that warranted its rotations, as the vault records them.
|
||||
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
|
||||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
|
||||
t.Errorf("a login with its acts: %s, %v", raw, err)
|
||||
}
|
||||
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
|
||||
t.Errorf("an empty rotating: %+v, %v", l, err)
|
||||
}
|
||||
for name, l := range map[string]NodeLogin{
|
||||
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
|
||||
"another node's login": {Node: "g14", Login: "node.ace"},
|
||||
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
|
||||
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
|
||||
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
|
||||
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
|
||||
} {
|
||||
if _, err := EncodeNodeLogin(l); err == nil {
|
||||
t.Errorf("node login with %s: taken", name)
|
||||
}
|
||||
}
|
||||
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
|
||||
t.Errorf("the second login's node: %q %v", node, ok)
|
||||
}
|
||||
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
|
||||
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
|
||||
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
|
||||
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
|
||||
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
|
||||
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
|
||||
t.Errorf("an expiry: %v", err)
|
||||
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
|
||||
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
|
||||
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"no issued event": `{"v":1,"node":"lab"}`,
|
||||
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
|
||||
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
|
||||
} {
|
||||
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
|
||||
t.Errorf("an expiry with %s: taken", name)
|
||||
}
|
||||
}
|
||||
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
||||
walk(reflect.TypeOf(Placement{}), "Placement")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **Every module builds against the latest published version of each mesh package it uses** (novox/hq ADR 0573 §2;
|
||||
// issue 1585). A mesh package is a Go module of a repository of the mesh's forge (ForgeModulePrefix): mesh-sdk's Go
|
||||
// module, and a seat's contract its holder's repository publishes (ADR 0573 §1). Its version is the trunk merge the
|
||||
// indexer holds for the module whose directory holds the package (PackageVersionsOf): a merge that changes it changes
|
||||
// the fingerprint of every module that uses it, and the build writes that version into its own go.mod and go.sum
|
||||
// (StepAsk.Packages). Which packages a module uses is read from its lock file by MeshPackagesOf, the same way by the
|
||||
// forge bridge and by the build modules (buildstep.ModuleTrees), so nobody names a version by hand.
|
||||
|
||||
// ForgeModulePrefix is the module path prefix of the Go modules the mesh's forge serves: a Go module whose path starts
|
||||
// with it is a mesh package, git.novox.be/novox/<repository>[/<directory of the module in it>].
|
||||
const ForgeModulePrefix = "git.novox.be/novox/"
|
||||
|
||||
// MeshPackagesOf are the mesh packages a Go module's go.sum names (the module path of each line that starts with
|
||||
// ForgeModulePrefix), sorted and each once. go.sum names every module of the build's graph, so a package used only
|
||||
// through another is named too. Nil for none. A package of the module's own repository comes with its own tree, and
|
||||
// its reader leaves it out (OtherPackages).
|
||||
func MeshPackagesOf(goSum []byte) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(string(goSum), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 2 || !strings.HasPrefix(f[0], ForgeModulePrefix) {
|
||||
continue
|
||||
}
|
||||
if _, _, ok := PackageRepository(f[0]); !ok {
|
||||
continue
|
||||
}
|
||||
seen[f[0]] = true
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
return nil
|
||||
}
|
||||
return slices.Sorted(maps.Keys(seen))
|
||||
}
|
||||
|
||||
// OtherPackages are the packages not published from repository, the module's own, in their order; nil for none.
|
||||
func OtherPackages(packages []string, repository string) []string {
|
||||
var out []string
|
||||
for _, p := range packages {
|
||||
if repo, _, ok := PackageRepository(p); ok && !SameRepository(repo, repository) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PackageRepository is the repository (<owner>/<name>) a mesh package is published from and the package's directory in
|
||||
// it ("" for its root); false for a path that is not a forge module's.
|
||||
func PackageRepository(pkg string) (repository, dir string, ok bool) {
|
||||
rest, found := strings.CutPrefix(pkg, ForgeModulePrefix)
|
||||
if !found || rest == "" {
|
||||
return "", "", false
|
||||
}
|
||||
name, dir, _ := strings.Cut(rest, "/")
|
||||
if name == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return "novox/" + name, dir, true
|
||||
}
|
||||
|
||||
// PackageVersionsOf is the latest published version of each package from the modules the indexer holds (by key): the
|
||||
// trunk merge commit of the module of the package's repository whose directory holds the package's directory (the
|
||||
// deepest, a module at the repository's root holding every directory). A package no held module holds, or whose
|
||||
// module names no commit or no directory, is in missing, sorted; versions is nil when packages is empty.
|
||||
func PackageVersionsOf(packages []string, modules map[string]Module) (versions PackageVersions, missing []string) {
|
||||
for _, pkg := range packages {
|
||||
repository, dir, ok := PackageRepository(pkg)
|
||||
commit, depth := "", -1
|
||||
if ok {
|
||||
for _, key := range slices.Sorted(maps.Keys(modules)) {
|
||||
m := modules[key]
|
||||
if !SameRepository(m.Repository, repository) || m.Commit == "" {
|
||||
continue
|
||||
}
|
||||
at := strings.Trim(m.Path, "/")
|
||||
switch {
|
||||
case at == "":
|
||||
continue // a value that names no directory says nothing of where the package lies
|
||||
case at == RootPath:
|
||||
at = ""
|
||||
case dir != at && !strings.HasPrefix(dir, at+"/"):
|
||||
continue
|
||||
}
|
||||
if d := len(at); d > depth {
|
||||
commit, depth = m.Commit, d
|
||||
}
|
||||
}
|
||||
}
|
||||
if commit == "" {
|
||||
missing = append(missing, pkg)
|
||||
continue
|
||||
}
|
||||
if versions == nil {
|
||||
versions = PackageVersions{}
|
||||
}
|
||||
versions[pkg] = commit
|
||||
}
|
||||
slices.Sort(missing)
|
||||
return versions, missing
|
||||
}
|
||||
@@ -65,6 +65,13 @@ const (
|
||||
OpDecide = "decide" // set a decided field of the node (NodeDecided by its JSON name), its address among them
|
||||
OpConfig = "config" // set a module's config placement or values on the node
|
||||
OpSeed = "seed" // the node's whole placement: the take-over (adopt, §7), never given by a caller
|
||||
// OpAddNode adds a joining node with its decided fields and the public halves of its keys, on the warrant of the
|
||||
// enrolment that issued its token (novox/hq ADR 0587 §7): mesh-enrolment's alone, the proposal's one change.
|
||||
OpAddNode = "add-node"
|
||||
// OpRemoveNode takes a joining node off placement again when its enrolment ends unconfirmed, revoked or expired
|
||||
// (the operator's decision of 2026-11-02 01:05): mesh-enrolment's alone, on the same warrant, the proposal's one
|
||||
// change, of a node with no module and no holding. The holder deletes its key (RemovedNodes).
|
||||
OpRemoveNode = "remove-node"
|
||||
)
|
||||
|
||||
// The states of an open proposal.
|
||||
@@ -107,31 +114,34 @@ type PlacementProposalChange struct {
|
||||
Values map[string]any `json:"values,omitempty"`
|
||||
// Whole is the node's whole placement (OpSeed).
|
||||
Whole *Placement `json:"whole,omitempty"`
|
||||
// Decided and Keys are a joining node's (OpAddNode): its decided fields, the address the operator approved among
|
||||
// them, and the public halves of its keys: at the operator's yes its tunnel key alone, given at issue (the operator's
|
||||
// decision of 2026-11-02 01:05); the identity keys are bound at redeem, checked against the node account's digest.
|
||||
Decided *NodeDecided `json:"decided,omitempty"`
|
||||
Keys *JoiningKeys `json:"keys,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeLease is why the change cannot be proposed before the lease write, or "" (ADR 0567 §8): until then the holder
|
||||
// keeps the controller's assignments in step through its verbs assign, unassign, pin and unpin, so only those are
|
||||
// proposable — a module placed or taken off with no host port or binding, a pin set or removed, a module's config values
|
||||
// and the seed. A host port, a binding, a holding, a decided field and a config layer's places or accesses wait for the
|
||||
// lease write, which the refusal names.
|
||||
func (c PlacementProposalChange) BeforeLease() string {
|
||||
const lease = "which the controller's verbs cannot take from a caller, so it waits for the lease write (ADR 0567 §8)"
|
||||
switch c.Op {
|
||||
case OpPlace:
|
||||
if len(c.Ports)+len(c.Bindings) > 0 {
|
||||
return fmt.Sprintf("the place of %s on %s names host ports or bindings, %s", c.Module, c.Node, lease)
|
||||
}
|
||||
case OpTakeOff, OpPin, OpUnpin, OpSeed:
|
||||
case OpConfig:
|
||||
// A module's config values are the state's alone (§13); its layers' places and accesses are the controller's
|
||||
// until the lease write.
|
||||
if c.MeshWide != nil || c.Layer != nil {
|
||||
return fmt.Sprintf("the config of %s on %s sets a layer's places or accesses, %s", c.Module, c.Node, lease)
|
||||
}
|
||||
default:
|
||||
return fmt.Sprintf("a change %s of %s is no assignment or pin, %s", c.Op, c.Node, lease)
|
||||
}
|
||||
return ""
|
||||
// JoiningKeys are the public halves of a joining node's keys (ADR 0587 §6, §7): its private network (tunnel) key, given
|
||||
// at issue, and its identity, sealing and serving keys, presented at redeem, as Redeem carries them. An add-node at the
|
||||
// operator's yes carries the tunnel key alone. Public halves alone, never a private key.
|
||||
type JoiningKeys struct {
|
||||
Identity string `json:"identity"`
|
||||
Overlay string `json:"overlay,omitempty"`
|
||||
Sealing string `json:"sealing"`
|
||||
Serving string `json:"serving,omitempty"`
|
||||
}
|
||||
|
||||
// Digest is the keys' digest, as the enrolment's key holds it (KeysDigest).
|
||||
func (k JoiningKeys) Digest() string { return KeysDigest(k.Identity, k.Overlay, k.Sealing, k.Serving) }
|
||||
|
||||
// AddNodeWarrant is the warrant an add-node carries (ADR 0587 §7): the enrolment's node and address as its issue's ask
|
||||
// was bound to them, the ask's id, and the warrant's id as the teller wrote it. mesh-placement checks it names the
|
||||
// change's node and address, and that the caller is mesh-enrolment as the runtime proves it (AddNodeRefusal).
|
||||
type AddNodeWarrant struct {
|
||||
Node string `json:"node"`
|
||||
Address string `json:"address"`
|
||||
Ask string `json:"ask"`
|
||||
Warrant string `json:"warrant"`
|
||||
}
|
||||
|
||||
func (c PlacementProposalChange) check() error {
|
||||
@@ -160,8 +170,28 @@ func (c PlacementProposalChange) check() error {
|
||||
return fmt.Errorf("delivery: the seed of %s names no placement of that node", c.Node)
|
||||
}
|
||||
return c.Whole.check()
|
||||
case OpAddNode:
|
||||
if c.Decided == nil || c.Decided.Address == "" || c.Keys == nil || c.Keys.Overlay == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s names no address or no tunnel key", c.Node)
|
||||
}
|
||||
// The identity keys come at redeem, whole or not at all: an identity with no sealing key, or the reverse.
|
||||
if (c.Keys.Identity == "") != (c.Keys.Sealing == "") || (c.Keys.Identity == "" && c.Keys.Serving != "") {
|
||||
return fmt.Errorf("delivery: the add-node of %s names part of its identity keys; they come whole at redeem", c.Node)
|
||||
}
|
||||
if c.Decided.Hub {
|
||||
return fmt.Errorf("delivery: the add-node of %s makes it the hub, which a joining node never is", c.Node)
|
||||
}
|
||||
if c.Module != "" || len(c.Ports)+len(c.Bindings) > 0 || c.Whole != nil {
|
||||
return fmt.Errorf("delivery: the add-node of %s places a module; a module is placed by a proposal of its own", c.Node)
|
||||
}
|
||||
return nil
|
||||
case OpRemoveNode:
|
||||
if c.Module != "" || c.Decided != nil || c.Keys != nil || c.Whole != nil {
|
||||
return fmt.Errorf("delivery: the remove-node of %s names more than its node", c.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide or config", c.Op)
|
||||
return fmt.Errorf("delivery: %q is no change of placement: place, take-off, pin, unpin, hold, unhold, decide, config, add-node or remove-node", c.Op)
|
||||
}
|
||||
|
||||
// LayChanges is the placements of the nodes the changes name with the changes laid over current, in the order given;
|
||||
@@ -200,6 +230,23 @@ func LayChanges(current map[string]Placement, changes []PlacementProposalChange)
|
||||
switch c.Op {
|
||||
case OpSeed:
|
||||
p = clonePlacement(*c.Whole)
|
||||
case OpAddNode:
|
||||
// A joining node has no key yet (ADR 0587 §7): one that has is no joining node.
|
||||
if _, has := current[c.Node]; has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is placed already, so it does not join", i+1, c.Node))
|
||||
continue
|
||||
}
|
||||
p = Placement{V: Version, Node: c.Node, Decided: *c.Decided, Modules: []PlacedModule{}}
|
||||
case OpRemoveNode:
|
||||
// Only a joining node never confirmed goes this way: one with no module and no holding (a node that runs
|
||||
// anything is taken off module by module, by a proposal the operator approves).
|
||||
cur, has := current[c.Node]
|
||||
if !has {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s is not placed, so there is nothing to remove", i+1, c.Node))
|
||||
} else if len(cur.Modules)+len(cur.Holds) > 0 {
|
||||
findings = append(findings, fmt.Sprintf("change %d: %s runs modules or holds seats, so it is no joining node to remove", i+1, c.Node))
|
||||
}
|
||||
continue
|
||||
case OpPlace:
|
||||
m := PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
|
||||
if at >= 0 {
|
||||
@@ -403,6 +450,9 @@ type PlacementProposal struct {
|
||||
// Again is when `again` asked the holder to judge it now and raise its ask again (§10); the holder does once per
|
||||
// value.
|
||||
Again time.Time `json:"again,omitempty"`
|
||||
// Warrant is the enrolment's warrant an add-node carries (ADR 0587 §7): its Approve is the issue's, so the holder
|
||||
// writes it with no ask of its own. Only an add-node carries one.
|
||||
Warrant *AddNodeWarrant `json:"warrant,omitempty"`
|
||||
// Withdrawn is who withdrew it and why (`withdraw`, §2): the holder then deletes it and its candidates and says
|
||||
// placement-withdrawn, which only the holder's process can say on its stream.
|
||||
Withdrawn string `json:"withdrawn,omitempty"`
|
||||
@@ -444,9 +494,69 @@ func (p PlacementProposal) check() error {
|
||||
if p.Seed && p.ExportDigest == "" {
|
||||
return fmt.Errorf("delivery: the seed %s names no export digest", p.ID)
|
||||
}
|
||||
return p.checkAddNode()
|
||||
}
|
||||
|
||||
// checkAddNode refuses an add-node or a remove-node that is not the proposal's one change, carries no warrant, or whose
|
||||
// warrant names another node (or, for an add-node, another address) than the change; and a warrant on any other
|
||||
// proposal (ADR 0587 §7).
|
||||
func (p PlacementProposal) checkAddNode() error {
|
||||
adds := 0
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpAddNode || c.Op == OpRemoveNode {
|
||||
adds++
|
||||
}
|
||||
}
|
||||
if adds == 0 {
|
||||
if p.Warrant != nil {
|
||||
return fmt.Errorf("delivery: the proposal %s carries an enrolment's warrant and adds no node", p.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if adds != 1 || len(p.Changes) != 1 || p.HandBack || p.Seed {
|
||||
return fmt.Errorf("delivery: the proposal %s adds a node beside other changes; an add-node is a proposal's one change", p.ID)
|
||||
}
|
||||
c, w := p.Changes[0], p.Warrant
|
||||
if w == nil || w.Ask == "" || w.Warrant == "" {
|
||||
return fmt.Errorf("delivery: the add-node of %s carries no warrant of its enrolment", c.Node)
|
||||
}
|
||||
if c.Op == OpRemoveNode {
|
||||
if w.Node != c.Node {
|
||||
return fmt.Errorf("delivery: the remove-node of %s carries the warrant of %s", c.Node, w.Node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if w.Node != c.Node || w.Address != c.Decided.Address {
|
||||
return fmt.Errorf("delivery: the add-node of %s at %s carries the warrant of %s at %s", c.Node, c.Decided.Address,
|
||||
w.Node, w.Address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovedNodes are the nodes whose key a proposal deletes (its remove-node), which LayChanges leaves out of what it lays.
|
||||
func (p PlacementProposal) RemovedNodes() []string {
|
||||
var out []string
|
||||
for _, c := range p.Changes {
|
||||
if c.Op == OpRemoveNode {
|
||||
out = append(out, c.Node)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AddNodeRefusal is why the holder of mesh-placement refuses an add-node or a remove-node from caller, the module the runtime proved
|
||||
// called (ADR 0587 §7), or "": only mesh-enrolment adds a node; what the proposal itself must hold, DecodePlacementProposal
|
||||
// refuses.
|
||||
func AddNodeRefusal(p PlacementProposal, caller string) string {
|
||||
if err := p.checkAddNode(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if p.Warrant != nil && caller != EnrolmentModule {
|
||||
return fmt.Sprintf("an add-node or a remove-node is mesh-enrolment's alone, and %s proposed it", caller)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EncodePlacementProposal is p as JSON at Version; it refuses what DecodePlacementProposal refuses.
|
||||
func EncodePlacementProposal(p PlacementProposal) ([]byte, error) {
|
||||
if err := p.check(); err != nil {
|
||||
|
||||
@@ -651,19 +651,6 @@ func TestPlacement(t *testing.T) {
|
||||
t.Errorf("%s: %v, want a finding naming %q", name, f, c.want)
|
||||
}
|
||||
}
|
||||
for change, waits := range map[*PlacementProposalChange]bool{
|
||||
{Op: OpPlace, Node: "g14", Module: "x"}: false, {Op: OpTakeOff, Node: "g14", Module: "x"}: false,
|
||||
{Op: OpPin, Node: "g14"}: false, {Op: OpUnpin, Node: "g14"}: false, {Op: OpSeed, Node: "g14"}: false,
|
||||
{Op: OpPlace, Node: "g14", Module: "x", Ports: map[string]int{"80": 80}}: true,
|
||||
{Op: OpPlace, Node: "g14", Module: "x", Bindings: map[string]Provider{"p": {}}}: true,
|
||||
{Op: OpHold, Node: "g14"}: true, {Op: OpDecide, Node: "g14"}: true,
|
||||
{Op: OpConfig, Node: "g14", Values: map[string]any{"puid": 1}}: false, {Op: OpConfig, Node: "g14", MeshValues: map[string]any{"a": 1}}: false,
|
||||
{Op: OpConfig, Node: "g14", Layer: map[string]any{"places": 1}}: true, {Op: OpConfig, Node: "g14", MeshWide: map[string]any{"accesses": 1}}: true,
|
||||
} {
|
||||
if got := change.BeforeLease(); (got != "") != waits || (waits && !strings.Contains(got, "lease write")) {
|
||||
t.Errorf("%+v before the lease write: %q", *change, got)
|
||||
}
|
||||
}
|
||||
|
||||
const id = "p-20261028t183000-1a2b3c"
|
||||
if got := NewProposalID(time.Date(2026, 10, 28, 18, 30, 0, 0, time.UTC)); !strings.HasPrefix(got, "p-20261028t183000-") || CheckProposalID(got) != nil {
|
||||
@@ -702,6 +689,92 @@ func TestPlacement(t *testing.T) {
|
||||
if ChangesDigest(p) == ChangesDigest(seed) {
|
||||
t.Error("two proposals' changes digest alike")
|
||||
}
|
||||
// 774/S42/AC1 (ADR 0587 §7): an add-node carries its enrolment's warrant naming its node and address, as its one
|
||||
// change, from mesh-enrolment alone; a warrant naming another node or address is refused.
|
||||
// At the operator's yes it carries the tunnel key alone (the operator, 2026-11-02 01:05).
|
||||
joining := PlacementProposalChange{Op: OpAddNode, Node: "lab", Decided: &NodeDecided{Address: "10.0.0.7"},
|
||||
Keys: &JoiningKeys{Overlay: "wg"}}
|
||||
add := PlacementProposal{ID: id, By: "ace.mesh-enrolment", Why: "lab joins", At: time.Unix(1, 0).UTC(),
|
||||
Changes: []PlacementProposalChange{joining}, Base: map[string]uint64{"lab": 0}, State: ProposalAnswering,
|
||||
Warrant: &AddNodeWarrant{Node: "lab", Address: "10.0.0.7", Ask: "a-1", Warrant: "w-1"}}
|
||||
if raw, err := EncodePlacementProposal(add); err != nil {
|
||||
t.Errorf("an add-node: %v", err)
|
||||
} else if back, err := DecodePlacementProposal(raw); err != nil || back.Warrant == nil || back.Changes[0].Keys.Overlay != "wg" {
|
||||
t.Errorf("an add-node round trip: %+v, %v", back, err)
|
||||
}
|
||||
if AddNodeRefusal(add, EnrolmentModule) != "" || !strings.Contains(AddNodeRefusal(add, AssignerModule), "mesh-enrolment's alone") {
|
||||
t.Errorf("add-node by caller: %q, %q", AddNodeRefusal(add, EnrolmentModule), AddNodeRefusal(add, AssignerModule))
|
||||
}
|
||||
if laid, f := LayChanges(current, add.Changes); len(f) != 0 || laid["lab"].Decided.Address != "10.0.0.7" || len(laid["lab"].Modules) != 0 {
|
||||
t.Errorf("an add-node laid: %+v, %v", laid, f)
|
||||
}
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpAddNode, Node: "g14", Decided: &NodeDecided{Address: "10.0.0.9"},
|
||||
Keys: &JoiningKeys{Overlay: "wg"}}}); len(f) != 1 || !strings.Contains(f[0], "placed already") {
|
||||
t.Errorf("an add-node of a placed node: %v", f)
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"warrant of another node": func(p *PlacementProposal) {
|
||||
p.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||
},
|
||||
"warrant of another address": func(p *PlacementProposal) {
|
||||
p.Warrant = &AddNodeWarrant{Node: "lab", Address: "10.0.0.8", Ask: "a", Warrant: "w"}
|
||||
},
|
||||
"no warrant": func(p *PlacementProposal) { p.Warrant = nil },
|
||||
"no tunnel key": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Keys = &JoiningKeys{Identity: "id", Sealing: "seal"}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"half its identity keys": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Keys = &JoiningKeys{Overlay: "wg", Identity: "id"}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"beside another change": func(p *PlacementProposal) {
|
||||
p.Changes = append(p.Changes, PlacementProposalChange{Op: OpPlace, Node: "lab", Module: "zsh"})
|
||||
},
|
||||
"the hub": func(p *PlacementProposal) {
|
||||
c := joining
|
||||
c.Decided = &NodeDecided{Address: "10.0.0.7", Hub: true}
|
||||
p.Changes = []PlacementProposalChange{c}
|
||||
},
|
||||
"a warrant on a move": func(p *PlacementProposal) {
|
||||
p.Changes, p.Base = []PlacementProposalChange{{Op: OpTakeOff, Node: "g14", Module: "zsh"}}, map[string]uint64{"g14": 4}
|
||||
},
|
||||
} {
|
||||
q := add
|
||||
q.Changes = append([]PlacementProposalChange(nil), add.Changes...)
|
||||
mut(&q)
|
||||
if _, err := EncodePlacementProposal(q); err == nil {
|
||||
t.Errorf("an add-node with %s: taken", name)
|
||||
}
|
||||
}
|
||||
// With its identity keys whole, as bound at redeem, it is taken too.
|
||||
whole := add
|
||||
whole.Changes = []PlacementProposalChange{joining}
|
||||
whole.Changes[0].Keys = &JoiningKeys{Overlay: "wg", Identity: "id", Sealing: "seal"}
|
||||
if _, err := EncodePlacementProposal(whole); err != nil {
|
||||
t.Errorf("an add-node with its identity keys: %v", err)
|
||||
}
|
||||
// An enrolment's end takes its never-confirmed node off again: mesh-enrolment's alone, of a node running nothing.
|
||||
remove := add
|
||||
remove.Changes = []PlacementProposalChange{{Op: OpRemoveNode, Node: "lab"}}
|
||||
if _, err := EncodePlacementProposal(remove); err != nil || AddNodeRefusal(remove, AssignerModule) == "" ||
|
||||
AddNodeRefusal(remove, EnrolmentModule) != "" || !slices.Equal(remove.RemovedNodes(), []string{"lab"}) {
|
||||
t.Errorf("a remove-node: %v, %q", err, AddNodeRefusal(remove, AssignerModule))
|
||||
}
|
||||
joined := map[string]Placement{"lab": {V: Version, Node: "lab", Decided: NodeDecided{Address: "10.0.0.7"}, Modules: []PlacedModule{}}}
|
||||
if laid, f := LayChanges(joined, remove.Changes); len(f) != 0 || len(laid) != 0 {
|
||||
t.Errorf("a joining node removed: %v, %v", laid, f)
|
||||
}
|
||||
if _, f := LayChanges(current, []PlacementProposalChange{{Op: OpRemoveNode, Node: "g14"}}); len(f) != 1 ||
|
||||
!strings.Contains(f[0], "runs modules") {
|
||||
t.Errorf("a running node removed: %v", f)
|
||||
}
|
||||
remove.Warrant = &AddNodeWarrant{Node: "pi", Address: "10.0.0.7", Ask: "a", Warrant: "w"}
|
||||
if _, err := EncodePlacementProposal(remove); err == nil {
|
||||
t.Error("a remove-node on another node's warrant: taken")
|
||||
}
|
||||
for name, mut := range map[string]func(*PlacementProposal){
|
||||
"no id": func(p *PlacementProposal) { p.ID = "" },
|
||||
"no proposer": func(p *PlacementProposal) { p.By = "" },
|
||||
|
||||
@@ -107,6 +107,8 @@ type PreviewModule struct {
|
||||
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the merge; Tree
|
||||
// is then their NamedTree (novox/hq ADR 0499 clause 2). Absent otherwise.
|
||||
Paths map[string]string `json:"paths,omitempty"`
|
||||
// Packages are the mesh packages the module's lock file names at the merge (MeshPackagesOf, novox/hq ADR 0573 §2).
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge
|
||||
|
||||
@@ -48,8 +48,8 @@ const (
|
||||
|
||||
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
||||
KindNodeCertificate = "node-certificate"
|
||||
// KindEnrolment is an enrolment reply, signed under its own context; no caller is named until adoption's record
|
||||
// (ADR 0561 §5).
|
||||
// KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for mesh-enrolment alone (ADR 0587
|
||||
// §3, §6).
|
||||
KindEnrolment = "enrolment"
|
||||
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
|
||||
KindAuthority = "authority"
|
||||
|
||||
+47
-4
@@ -284,6 +284,10 @@ type CandidateModule struct {
|
||||
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the preview
|
||||
// (novox/hq ADR 0499 clause 2), so the builder's fetch request for the candidate names them. Absent otherwise.
|
||||
Paths map[string]string `json:"paths,omitempty"`
|
||||
// Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2, §4): the latest
|
||||
// published version of each, or the head of the candidate's pull request that publishes it, so a group's candidate
|
||||
// builds against the group's own package. Absent for none.
|
||||
Packages PackageVersions `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
func (m CandidateModule) check() error {
|
||||
@@ -296,6 +300,9 @@ func (m CandidateModule) check() error {
|
||||
if err := checkPathObjects(m.Paths); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Packages.Check(); err != nil {
|
||||
return err
|
||||
}
|
||||
return checkNewForm(m.FP2, m.Manifest)
|
||||
}
|
||||
|
||||
@@ -415,18 +422,28 @@ type Module struct {
|
||||
// so the checker tells whether a change touches an artifact's build source without reading a manifest (the lead's
|
||||
// decision L8 of R4e). Absent for an artifact that names none, and in a value written before.
|
||||
ArtifactPaths map[string][]string `json:"paths,omitempty"`
|
||||
// Packages are the versions of the mesh packages FP2 was computed with (novox/hq ADR 0573 §2): the trunk merge the
|
||||
// indexer held for each package of Inputs2.Packages. A builder builds the fingerprint against exactly these
|
||||
// (StepAsk.Packages). Empty when FP2 is empty or the module uses no mesh package.
|
||||
Packages PackageVersions `json:"packages,omitempty"`
|
||||
// Verdict is the indexer's verdict on Manifest, VerdictAccepted or VerdictRefused, and Problems each problem of a
|
||||
// refusal, as the verdicts state holds them (novox/hq ADR 0578 §1): a reader of modules takes it without a read of
|
||||
// that state. Empty in a value written before the indexer judged, which a reader takes as accepted (TakeManifestVerdict).
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
// Warnings are the problems of an accepted manifest a stricter judge outgrew (novox/hq ADR 0579 §1): Verdict is then
|
||||
// empty (accepted), and the watchman raises delivery.ManifestOutgrown until the manifest is mended.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// VerdictOf is the verdict a modules value carries, or nil when it carries none (taken as accepted, TakeManifestVerdict).
|
||||
func (m Module) VerdictOf(module string) *ManifestVerdict {
|
||||
if m.Verdict == "" {
|
||||
if m.Verdict == "" && len(m.Warnings) == 0 {
|
||||
return nil
|
||||
}
|
||||
if m.Verdict == "" {
|
||||
return &ManifestVerdict{Module: module, State: VerdictAccepted, Warnings: m.Warnings}
|
||||
}
|
||||
return &ManifestVerdict{Module: module, State: m.Verdict, Problems: m.Problems}
|
||||
}
|
||||
|
||||
@@ -446,6 +463,10 @@ type KeptInputs2 struct {
|
||||
// computation from kept inputs gives the merge's value (novox/hq ADR 0499 clause 2). Absent for every other module,
|
||||
// whose Tree is its directory's tree, and in a value written before.
|
||||
Paths map[string]string `json:"paths,omitempty"`
|
||||
// Packages are the mesh packages the module's lock file names (MeshPackagesOf, novox/hq ADR 0573 §2): the
|
||||
// fingerprint takes the latest published version of each (Module.Packages). Absent for none, and in a value written
|
||||
// before.
|
||||
Packages []string `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// checkPathObjects refuses a named path that is not clean and relative to the module's directory, or names no object.
|
||||
@@ -482,6 +503,19 @@ func (k KeptInputs2) With(module string, toolchains map[string]Toolchain2) Sourc
|
||||
Locks: k.Locks, Toolchains: toolchains}
|
||||
}
|
||||
|
||||
// WithPackages is With and the version of each package the module uses (Packages), as PackageVersionsOf read them: a
|
||||
// package with no version enters with none, and the fingerprint is then none (SourceFingerprint2).
|
||||
func (k KeptInputs2) WithPackages(module string, toolchains map[string]Toolchain2, versions PackageVersions) SourceInputs2 {
|
||||
in := k.With(module, toolchains)
|
||||
if len(k.Packages) > 0 {
|
||||
in.Packages = map[string]string{}
|
||||
for _, p := range k.Packages {
|
||||
in.Packages[p] = versions[p]
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func (m Module) check() error {
|
||||
switch {
|
||||
case m.FP != "" && m.None != "":
|
||||
@@ -498,6 +532,9 @@ func (m Module) check() error {
|
||||
return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language)
|
||||
}
|
||||
}
|
||||
if err := m.Packages.Check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Inputs2 != nil && m.Manifest == "" {
|
||||
return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest")
|
||||
}
|
||||
@@ -523,6 +560,8 @@ func (m Module) check() error {
|
||||
return fmt.Errorf("delivery: a module carries a verdict and no manifest digest")
|
||||
case (m.Verdict == VerdictRefused) != (len(m.Problems) > 0):
|
||||
return fmt.Errorf("delivery: a module's problems are named only with a refusal, and a refusal names them")
|
||||
case m.Verdict == VerdictRefused && len(m.Warnings) > 0:
|
||||
return fmt.Errorf("delivery: a refused module warns: a refusal names problems")
|
||||
}
|
||||
return checkNewForm(m.FP2, m.Manifest)
|
||||
}
|
||||
@@ -555,9 +594,13 @@ type Forge struct {
|
||||
// id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The
|
||||
// indexer names the paths; a message without them gives such a module none. Older writers send none.
|
||||
Paths map[string]map[string]string `json:"paths,omitempty"`
|
||||
Act string `json:"act,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
Open []OpenPull `json:"open,omitempty"`
|
||||
// Packages are, of each module directory, the mesh packages its lock file names (MeshPackagesOf; novox/hq ADR 0573
|
||||
// §2): the indexer keeps them in the module's inputs and takes each one's latest published version into its
|
||||
// fingerprint. Absent for a directory that uses none, and from an older writer.
|
||||
Packages map[string][]string `json:"packages,omitempty"`
|
||||
Act string `json:"act,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
Open []OpenPull `json:"open,omitempty"`
|
||||
// RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is
|
||||
// the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is
|
||||
// refused, loudly, rather than read with them dropped.
|
||||
|
||||
@@ -253,6 +253,30 @@ type StepAsk struct {
|
||||
Compose *ComposeJob `json:"compose,omitempty"`
|
||||
// Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node.
|
||||
Needs []string `json:"needs,omitempty"`
|
||||
// Packages are the version of each mesh package the build or the check builds against, by its module path (a Go
|
||||
// module's path, git.novox.be/novox/mesh-sdk/go; the shape takes a seat's package too), whatever version the
|
||||
// repository's go.mod names (novox/hq ADR 0573 §2, §4; issue 1585): a Go package's version is a full commit, the
|
||||
// trunk merge the indexer holds as the package's latest published version or, in a delivery group's check, the
|
||||
// head of the group's member that publishes it. The build module writes each into its workspace, checksum-verified,
|
||||
// and names them in its log (PackageVersions). A compose takes none: it builds the mesh-sdk its reader names.
|
||||
Packages PackageVersions `json:"packages,omitempty"`
|
||||
}
|
||||
|
||||
// PackageVersions are the versions of the mesh packages a build or a check builds against, by package (a Go module's
|
||||
// path), each a full commit (novox/hq ADR 0573 §2).
|
||||
type PackageVersions map[string]string
|
||||
|
||||
// Check refuses a package that is no module path or a version that is no full commit, naming it.
|
||||
func (p PackageVersions) Check() error {
|
||||
for pkg, version := range p {
|
||||
if pkg == "" || strings.ContainsAny(pkg, " \t\n@") || strings.HasPrefix(pkg, "/") {
|
||||
return fmt.Errorf("delivery: the package %q is no module path", pkg)
|
||||
}
|
||||
if !askCommitOK.MatchString(version) {
|
||||
return fmt.Errorf("delivery: the package %s names the version %q, which is no full commit", pkg, version)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer
|
||||
@@ -345,6 +369,9 @@ type CheckJob struct {
|
||||
// has to list them. The build step hands them to its test tool as the argument NodeNamesArg, and the build module
|
||||
// gives them to the tests in NodeNamesEnv.
|
||||
NodeNames []string `json:"node-names,omitempty"`
|
||||
// Toolchain is the toolchain the checker agreed for the check's language (novox/hq ADR 0584): a node building with
|
||||
// another declines the ask, so a pass is never recorded as run on a version it did not run on. Nil names none.
|
||||
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
|
||||
}
|
||||
|
||||
// NodeNamesArg is the test tool's argument that carries a check's node names, and NodeNamesEnv the environment
|
||||
@@ -389,6 +416,11 @@ func (a StepAsk) check() error {
|
||||
return fmt.Errorf("delivery: the ask %s is a check of an image, which has no tests", a.ID)
|
||||
case len(a.Needs) > 0 && a.Check == nil:
|
||||
return fmt.Errorf("delivery: the ask %s names needs and is no check, whose tests alone need a node", a.ID)
|
||||
case len(a.Packages) > 0 && a.Compose != nil:
|
||||
return fmt.Errorf("delivery: the ask %s is a compose and names package versions, which a compose does not take", a.ID)
|
||||
}
|
||||
if err := a.Packages.Check(); err != nil {
|
||||
return fmt.Errorf("the ask %s: %w", a.ID, err)
|
||||
}
|
||||
for _, n := range a.Needs {
|
||||
if err := CheckNeed(n); err != nil {
|
||||
|
||||
@@ -17,7 +17,8 @@ func buildAsk() StepAsk {
|
||||
Build: &BuildJob{Module: "mesh-delivery-view", Path: "modules/mesh-delivery-view", FP: "ab12",
|
||||
Fetch: map[string]any{"repository": "novox/mesh-delivery", "commit": askCommit, "dir": "modules/mesh-delivery-view", "paths": []any{"cmd"}},
|
||||
Artifacts: []AskArtifact{{Artifact: "bundle"}, {Artifact: "base", From: "registry/x@sha256:00"}},
|
||||
Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}}}
|
||||
Publish: true, Toolchain: &ToolchainUsed{Version: "go1.26.0", Recipe: "3"}},
|
||||
Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit}}
|
||||
}
|
||||
|
||||
func checkAsk() StepAsk {
|
||||
@@ -25,10 +26,11 @@ func checkAsk() StepAsk {
|
||||
At: time.Date(2026, 10, 23, 13, 0, 0, 0, time.UTC),
|
||||
Check: &CheckJob{Fetch: map[string]any{"repository": "novox/mesh-catalog", "commit": askCommit, "ref": "refs/pull/3/head", "base": askCommit},
|
||||
Levels: []string{LevelUnit, LevelIntegration}, Kept: true, Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Run: "",
|
||||
NodeNames: []string{"anchor", "laptop"}}}
|
||||
NodeNames: []string{"anchor", "laptop"}},
|
||||
Packages: PackageVersions{"git.novox.be/novox/mesh-sdk/go": askCommit, "git.novox.be/novox/mesh-controller/modules/route-proxy/contract": askCommit}}
|
||||
}
|
||||
|
||||
// 1887/A1a: an ask encodes and decodes with every field.
|
||||
// 1887/A1a: an ask encodes and decodes with every field; 1585/S4: the package versions too.
|
||||
func TestAskRoundTrips(t *testing.T) {
|
||||
for _, a := range []StepAsk{buildAsk(), checkAsk()} {
|
||||
raw, err := EncodeStepAsk(a)
|
||||
@@ -58,6 +60,13 @@ func TestAskRefusesUnknownKindAndLanguage(t *testing.T) {
|
||||
"commit": func(a *StepAsk) { a.Build.Fetch = map[string]any{"repository": "novox/x", "commit": "main"} },
|
||||
"path": func(a *StepAsk) { a.Build.Path = "" },
|
||||
"needs": func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, // issue 890: a build needs no node
|
||||
// Issue 1585: a package's version is a full commit, of a module path.
|
||||
"version": func(a *StepAsk) { a.Packages["git.novox.be/novox/mesh-sdk/go"] = "v0.1.7" },
|
||||
"package": func(a *StepAsk) { a.Packages["git.novox.be/novox/x y"] = askCommit },
|
||||
"compose": func(a *StepAsk) {
|
||||
a.Kind, a.Build = AskCheck, nil
|
||||
a.Compose = &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit}, Dir: "go", Package: "x", Request: json.RawMessage(`{}`)}
|
||||
},
|
||||
}
|
||||
for name, change := range cases {
|
||||
a := buildAsk()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the bus is, what certificate
|
||||
// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the
|
||||
// machine's tunnel key, the machine's first tunnel (ADR 0169).
|
||||
//
|
||||
// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the
|
||||
// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the
|
||||
// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue`
|
||||
// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of
|
||||
// ADR 0587 §3, 2026-11-02).
|
||||
type Token struct {
|
||||
Version int `json:"v"`
|
||||
|
||||
// Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before
|
||||
// the mesh can say anything.
|
||||
Node string `json:"node,omitempty"`
|
||||
|
||||
Broker string `json:"broker,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Signer []byte `json:"signer,omitempty"`
|
||||
Secret string `json:"secret"`
|
||||
|
||||
// Adopted says this node joins adopted (novox/hq ADR 0100); absent for a converged node.
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
|
||||
// Tunnel is this machine's first tunnel, when the token was issued for the key it made (novox/hq ADR 0169): its own
|
||||
// address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet.
|
||||
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// TokenTunnel is the joining machine's side of its first tunnel, in the field names the controller writes.
|
||||
type TokenTunnel struct {
|
||||
Key string `json:"key"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
HubKey string `json:"hub_key"`
|
||||
HubEndpoint string `json:"hub_endpoint"`
|
||||
}
|
||||
|
||||
// ParseToken reads a token a person pasted. Every refusal says *this is not a token* rather than *this is the wrong
|
||||
// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token
|
||||
// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects
|
||||
// to whatever answers, without the signing key it cannot tell a declaration from a forgery.
|
||||
func ParseToken(encoded string) (Token, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||
if err != nil {
|
||||
return Token{}, fmt.Errorf("this is not a token: %w", err)
|
||||
}
|
||||
var t Token
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
return Token{}, fmt.Errorf("this is not a token: %w", err)
|
||||
}
|
||||
if t.Version != 1 {
|
||||
return Token{}, fmt.Errorf(
|
||||
"this token says it is version %d, and this host understands version 1", t.Version)
|
||||
}
|
||||
if missing := t.Missing(); len(missing) > 0 {
|
||||
return Token{}, fmt.Errorf(
|
||||
"this token is missing %s, so it cannot be used to join anything",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Missing names every part of t a joining machine needs and t lacks.
|
||||
func (t Token) Missing() []string {
|
||||
var missing []string
|
||||
if strings.TrimSpace(t.Broker) == "" {
|
||||
missing = append(missing, "the broker's address")
|
||||
}
|
||||
if strings.TrimSpace(t.Fingerprint) == "" {
|
||||
missing = append(missing, "the broker certificate's fingerprint")
|
||||
}
|
||||
if len(t.Signer) != ed25519.PublicKeySize {
|
||||
missing = append(missing, "the vault's signing key")
|
||||
}
|
||||
if strings.TrimSpace(t.Secret) == "" {
|
||||
missing = append(missing, "the one-time secret")
|
||||
}
|
||||
if tt := t.Tunnel; tt != nil {
|
||||
for _, part := range []struct{ value, says string }{
|
||||
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
|
||||
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
|
||||
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
|
||||
} {
|
||||
if strings.TrimSpace(part.value) == "" {
|
||||
missing = append(missing, part.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the
|
||||
// controller's `token issue` writes it. Refused when a part is missing.
|
||||
func (t Token) Encode() (string, error) {
|
||||
t.Version = 1
|
||||
if missing := t.Missing(); len(missing) > 0 {
|
||||
return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", "))
|
||||
}
|
||||
raw, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after.
|
||||
func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) }
|
||||
@@ -0,0 +1,53 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9,
|
||||
// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519
|
||||
// key of the seed 00 01 .. 1f. Copied, never written by hand.
|
||||
const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19"
|
||||
|
||||
// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it
|
||||
// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens.
|
||||
func TestATokenTheControllerMadeReads(t *testing.T) {
|
||||
signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8")
|
||||
want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222",
|
||||
Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true,
|
||||
Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24",
|
||||
HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}}
|
||||
got, err := ParseToken(" " + controllerToken + "\n")
|
||||
if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 {
|
||||
t.Fatalf("parsed %+v, %v", got, err)
|
||||
}
|
||||
if again, err := got.Encode(); err != nil || again != controllerToken {
|
||||
t.Errorf("encoded again: %q, %v", again, err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
cut func(*Token)
|
||||
expect string
|
||||
}{
|
||||
{func(t *Token) { t.Broker = "" }, "broker's address"},
|
||||
{func(t *Token) { t.Fingerprint = "" }, "fingerprint"},
|
||||
{func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"},
|
||||
{func(t *Token) { t.Secret = "" }, "one-time secret"},
|
||||
{func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"},
|
||||
} {
|
||||
cut := want
|
||||
cut.Tunnel = &TokenTunnel{}
|
||||
*cut.Tunnel = *want.Tunnel
|
||||
c.cut(&cut)
|
||||
if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) {
|
||||
t.Errorf("a token without %s: %v", c.expect, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} {
|
||||
if _, err := ParseToken(bad); err == nil {
|
||||
t.Errorf("%q parsed as a token", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-10
@@ -99,22 +99,47 @@ type NodeToolchain struct {
|
||||
Toolchain
|
||||
}
|
||||
|
||||
// AgreedToolchain is the one Toolchain2 every node's entry of a language agrees on, or none and why: "toolchain not
|
||||
// yet reported" with no entries, and "builders disagree" naming each differing version and recipe with the nodes
|
||||
// reporting it when two differ (novox/hq ADR 0405 clause 2). The answer does not depend on the entries' order. The
|
||||
// entries must all be of one language.
|
||||
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
|
||||
// SittingOut is a node whose toolchain of a language is not the agreed one: it builds nothing of that language until
|
||||
// it reports the agreed version (novox/hq ADR 0584 clause 2).
|
||||
type SittingOut struct {
|
||||
Node string
|
||||
Version string
|
||||
Recipe string
|
||||
}
|
||||
|
||||
// Agreement is what the nodes' entries of one language come to (novox/hq ADR 0584). Toolchain is the agreed one, set
|
||||
// when Wait is empty. Wait is why nothing is agreed: "toolchain not yet reported" with no entries, or "builders
|
||||
// disagree" naming each version and recipe with the nodes reporting it, when no version has a strict majority.
|
||||
// SittingOut lists, sorted by node, the nodes that report another version than the agreed one.
|
||||
type Agreement struct {
|
||||
Toolchain Toolchain2
|
||||
SittingOut []SittingOut
|
||||
Wait string
|
||||
}
|
||||
|
||||
// ToolchainAgreement is the agreed toolchain of a language: the version and recipe a strict majority of the
|
||||
// reporting nodes report (novox/hq ADR 0584 clause 1). With no strict majority (two against two) or no entry it
|
||||
// gives no toolchain and says why in Wait. The answer does not depend on the entries' order. The entries must all be
|
||||
// of one language.
|
||||
func ToolchainAgreement(entries []NodeToolchain) Agreement {
|
||||
if len(entries) == 0 {
|
||||
return Toolchain2{}, ToolchainNoReport
|
||||
return Agreement{Wait: ToolchainNoReport}
|
||||
}
|
||||
groups := map[Toolchain2][]string{}
|
||||
for _, e := range entries {
|
||||
k := Toolchain2{Language: e.Language, Version: e.Version, Recipe: e.Recipe}
|
||||
groups[k] = append(groups[k], e.Node)
|
||||
}
|
||||
if len(groups) == 1 {
|
||||
for k := range groups {
|
||||
return k, ""
|
||||
for k, nodes := range groups {
|
||||
if 2*len(nodes) > len(entries) {
|
||||
a := Agreement{Toolchain: k}
|
||||
for _, e := range entries {
|
||||
if e.Version != k.Version || e.Recipe != k.Recipe {
|
||||
a.SittingOut = append(a.SittingOut, SittingOut{Node: e.Node, Version: e.Version, Recipe: e.Recipe})
|
||||
}
|
||||
}
|
||||
sort.Slice(a.SittingOut, func(i, j int) bool { return a.SittingOut[i].Node < a.SittingOut[j].Node })
|
||||
return a
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, len(groups))
|
||||
@@ -123,5 +148,12 @@ func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
|
||||
parts = append(parts, fmt.Sprintf("%s recipe %s on %s", k.Version, k.Recipe, strings.Join(nodes, ", ")))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return Toolchain2{}, "builders disagree: " + strings.Join(parts, "; ")
|
||||
return Agreement{Wait: "builders disagree: " + strings.Join(parts, "; ")}
|
||||
}
|
||||
|
||||
// AgreedToolchain is ToolchainAgreement's toolchain, or none and why (its Wait): the version a strict majority of the
|
||||
// nodes report (novox/hq ADR 0584, which changes ADR 0405 clause 2 and ADR 0477 clause 2).
|
||||
func AgreedToolchain(entries []NodeToolchain) (tc Toolchain2, none string) {
|
||||
a := ToolchainAgreement(entries)
|
||||
return a.Toolchain, a.Wait
|
||||
}
|
||||
|
||||
@@ -55,36 +55,50 @@ func TestToolchainKeyAndValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1443/AC2
|
||||
func TestAgreedToolchain(t *testing.T) {
|
||||
tc, none := AgreedToolchain([]NodeToolchain{tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.1", "3")})
|
||||
if tc != (Toolchain2{Language: "go", Version: "1.26.1", Recipe: "3"}) || none != "" {
|
||||
t.Errorf("%+v %q", tc, none)
|
||||
}
|
||||
}
|
||||
|
||||
// 1443/AC3
|
||||
func TestAgreedToolchainNoReport(t *testing.T) {
|
||||
tc, none := AgreedToolchain(nil)
|
||||
if tc != (Toolchain2{}) || none != "toolchain not yet reported" {
|
||||
t.Errorf("%+v %q", tc, none)
|
||||
}
|
||||
}
|
||||
|
||||
// 1443/AC4
|
||||
func TestAgreedToolchainDisagree(t *testing.T) {
|
||||
a, b, c := tcEntry("ace", "go", "1.26.1", "3"), tcEntry("novox", "go", "1.26.2", "3"), tcEntry("g14", "go", "1.26.1", "4")
|
||||
_, first := AgreedToolchain([]NodeToolchain{a, b, c})
|
||||
_, second := AgreedToolchain([]NodeToolchain{c, b, a})
|
||||
if first == "" || first != second {
|
||||
t.Errorf("order changed the answer: %q vs %q", first, second)
|
||||
}
|
||||
for _, want := range []string{"builders disagree", "1.26.1", "1.26.2", "ace", "novox", "g14"} {
|
||||
if !strings.Contains(first, want) {
|
||||
t.Errorf("%q does not name %q", first, want)
|
||||
// 1443/AC2-4, ADR 0584: the agreed toolchain is the strict majority's; a minority node sits out; a tie waits.
|
||||
func TestToolchainAgreement(t *testing.T) {
|
||||
e := tcEntry
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
in []NodeToolchain
|
||||
version string
|
||||
out []SittingOut
|
||||
wait []string // "" wait means agreed
|
||||
}{
|
||||
{"all agree", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
|
||||
{"three against one", []NodeToolchain{e("ace", "typescript", "26.12.1", "3"), e("g14", "typescript", "26.13.0", "3"), e("novox", "typescript", "26.13.0", "3"), e("shanks", "typescript", "26.13.0", "3")},
|
||||
"26.13.0", []SittingOut{{"ace", "26.12.1", "3"}}, nil},
|
||||
{"two against two", []NodeToolchain{e("ace", "go", "1.26.1", "3"), e("novox", "go", "1.26.2", "3"), e("g14", "go", "1.26.1", "3"), e("shanks", "go", "1.26.2", "3")},
|
||||
"", nil, []string{"builders disagree", "1.26.1 recipe 3 on ace, g14", "1.26.2 recipe 3 on novox, shanks"}},
|
||||
{"one node alone", []NodeToolchain{e("ace", "go", "1.26.1", "3")}, "1.26.1", nil, nil},
|
||||
{"no report", nil, "", nil, []string{ToolchainNoReport}},
|
||||
} {
|
||||
a := ToolchainAgreement(c.in)
|
||||
rev := make([]NodeToolchain, len(c.in))
|
||||
for i, x := range c.in {
|
||||
rev[len(c.in)-1-i] = x
|
||||
}
|
||||
if b := ToolchainAgreement(rev); b.Wait != a.Wait || b.Toolchain != a.Toolchain {
|
||||
t.Errorf("%s: order changed the answer: %+v vs %+v", c.name, a, b)
|
||||
}
|
||||
if a.Toolchain.Version != c.version || len(a.SittingOut) != len(c.out) {
|
||||
t.Errorf("%s: %+v", c.name, a)
|
||||
}
|
||||
for i := range c.out {
|
||||
if a.SittingOut[i] != c.out[i] {
|
||||
t.Errorf("%s: sitting out %+v, want %+v", c.name, a.SittingOut, c.out)
|
||||
}
|
||||
}
|
||||
if (len(c.wait) == 0) != (a.Wait == "") {
|
||||
t.Errorf("%s: wait %q", c.name, a.Wait)
|
||||
}
|
||||
for _, w := range c.wait {
|
||||
if !strings.Contains(a.Wait, w) {
|
||||
t.Errorf("%s: %q does not name %q", c.name, a.Wait, w)
|
||||
}
|
||||
}
|
||||
if tc, none := AgreedToolchain(c.in); tc != a.Toolchain || none != a.Wait {
|
||||
t.Errorf("%s: AgreedToolchain %+v %q", c.name, tc, none)
|
||||
}
|
||||
}
|
||||
if tc, _ := AgreedToolchain([]NodeToolchain{a, b}); tc != (Toolchain2{}) {
|
||||
t.Errorf("a disagreement gave %+v", tc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ type GrantsUserList struct {
|
||||
// LeftOut are the users the list leaves out for want of a hash in the vault.
|
||||
LeftOut []string `json:"left_out"`
|
||||
At time.Time `json:"at"`
|
||||
// LeaseHanded says the controller's lease is handed to the grants part (novox/hq ADR 0582 clause 3, ADR 0571 clause
|
||||
// 8): from then on the bus node follows this list alone and never falls back to the controller's. A key written
|
||||
// before it reads false.
|
||||
LeaseHanded bool `json:"lease-handed,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow.
|
||||
|
||||
+17
-13
@@ -3,6 +3,7 @@ package delivery
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -26,15 +27,23 @@ const (
|
||||
// ManifestRefusedEvent is the indexer's event saying a manifest was refused (ManifestRefused), so the watchman
|
||||
// raises it as a condition beside the indexer's finding.
|
||||
ManifestRefusedEvent = "manifest-refused"
|
||||
|
||||
// ManifestOutgrown is the finding of a manifest the indexer accepted before and a stricter judge finds problems in
|
||||
// (novox/hq ADR 0579 §1): it stays accepted, its problems are the verdict's Warnings, and the watchman raises it as a
|
||||
// warning until the manifest is mended.
|
||||
ManifestOutgrown = "manifest-outgrown"
|
||||
)
|
||||
|
||||
// ManifestVerdict is the value of a verdicts key: the module whose manifest it is, accepted or refused, every problem the judge
|
||||
// found (none when accepted), the judge that made it (the indexer's build fingerprint) and when.
|
||||
type ManifestVerdict struct {
|
||||
V int `json:"v"`
|
||||
Module string `json:"module"`
|
||||
State string `json:"state"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
V int `json:"v"`
|
||||
Module string `json:"module"`
|
||||
State string `json:"state"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
// Warnings are the problems a stricter judge found in a manifest it accepted before, digest unchanged (novox/hq ADR
|
||||
// 0579 §1): the verdict stays accepted.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Judge string `json:"judge"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
@@ -45,15 +54,7 @@ func (v ManifestVerdict) Refused() bool { return v.State == VerdictRefused }
|
||||
// Same is whether v and o say the same of a manifest: the module, the state and the problems, whoever judged and when.
|
||||
// The indexer writes a verdict only when it is not the same as the one it holds.
|
||||
func (v ManifestVerdict) Same(o ManifestVerdict) bool {
|
||||
if v.Module != o.Module || v.State != o.State || len(v.Problems) != len(o.Problems) {
|
||||
return false
|
||||
}
|
||||
for i := range v.Problems {
|
||||
if v.Problems[i] != o.Problems[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return v.Module == o.Module && v.State == o.State && slices.Equal(v.Problems, o.Problems) && slices.Equal(v.Warnings, o.Warnings)
|
||||
}
|
||||
|
||||
func (v ManifestVerdict) check() error {
|
||||
@@ -69,6 +70,9 @@ func (v ManifestVerdict) check() error {
|
||||
if len(v.Problems) == 0 {
|
||||
return fmt.Errorf("delivery: the verdict on %s refuses its manifest and names no problem", v.Module)
|
||||
}
|
||||
if len(v.Warnings) > 0 {
|
||||
return fmt.Errorf("delivery: the verdict on %s refuses its manifest and warns: a refusal names problems", v.Module)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery: the verdict on %s is %q, neither %s nor %s", v.Module, v.State, VerdictAccepted, VerdictRefused)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) {
|
||||
"another state": {Module: "m", State: "maybe", Judge: "j"},
|
||||
"refused, no problem": {Module: "m", State: VerdictRefused, Judge: "j"},
|
||||
"accepted, a problem": {Module: "m", State: VerdictAccepted, Problems: []string{"x"}, Judge: "j"},
|
||||
"refused, a warning": {Module: "m", State: VerdictRefused, Problems: []string{"x"}, Warnings: []string{"y"}, Judge: "j"},
|
||||
} {
|
||||
if _, err := EncodeManifestVerdict(bad); err == nil {
|
||||
t.Errorf("%s: encoded", name)
|
||||
@@ -62,4 +63,13 @@ func TestAManifestVerdictIsTheContractReadersTake(t *testing.T) {
|
||||
if _, err := EncodeModule(m); err == nil {
|
||||
t.Fatal("a module refused naming no problem was encoded")
|
||||
}
|
||||
// ADR 0579 §1: an accepted manifest a stricter judge outgrew carries its warnings and is taken as accepted.
|
||||
outgrown := ManifestVerdict{Module: "x", State: VerdictAccepted, Warnings: []string{"w"}, Judge: "j"}
|
||||
if _, err := EncodeManifestVerdict(outgrown); err != nil || outgrown.Same(ManifestVerdict{Module: "x", State: VerdictAccepted}) {
|
||||
t.Fatalf("an outgrown verdict: %v", err)
|
||||
}
|
||||
m.Verdict, m.Warnings = "", []string{"w"}
|
||||
if r, _ := TakeManifestVerdict(m.VerdictOf("x")); r || len(m.VerdictOf("x").Warnings) != 1 {
|
||||
t.Fatalf("an outgrown module: %+v", m.VerdictOf("x"))
|
||||
}
|
||||
}
|
||||
|
||||
+11
-28
@@ -1,8 +1,7 @@
|
||||
// Package handacts is the hand-act log's contract (novox/hq ADR 0565 clauses 10 and 11, issue 1953): the layout of an act,
|
||||
// moved unchanged from the controller's (mesh-controller internal/link HandAct) so one reader reads both logs alike; the
|
||||
// names of the seat mesh-hand-acts and of what its holder keeps; and the recorder every module records through, which
|
||||
// asks the seat and, while no holder answers, the controller's verb until the controller hands its lease away, saying
|
||||
// which log took the act.
|
||||
// asks the seat alone: the controller's hand-act and warranted are gone (issue 1953 L5b) and the bus refuses them.
|
||||
package handacts
|
||||
|
||||
import (
|
||||
@@ -95,13 +94,13 @@ type Taken struct {
|
||||
Answer json.RawMessage `json:"answer,omitempty"`
|
||||
}
|
||||
|
||||
// Recorder records acts for a module (ADR 0565 clause 11): through the seat mesh-hand-acts, and while the seat has no
|
||||
// holder that answers, through the controller's verb until the controller hands its lease away (the release's order
|
||||
// gives no seat's holder before its callers; 00-plan.md, "Missing" 1).
|
||||
// Recorder records acts for a module (ADR 0565 clause 11) through the seat mesh-hand-acts alone. An act the seat does
|
||||
// not take is an error that says nothing was recorded, never a quiet success.
|
||||
type Recorder struct {
|
||||
// Ask calls a seat verb by its address (`seat:<seat>.<verb>`): stdio.Ask through the runtime.
|
||||
Ask func(key string, body any) (json.RawMessage, error)
|
||||
// LeaseAway says the controller's lease is handed away: then nothing falls back to it. Nil is never handed away.
|
||||
// LeaseAway is no longer read: nothing falls back to the controller. Kept so a caller that sets it builds; it goes
|
||||
// once no module sets it.
|
||||
LeaseAway func() bool
|
||||
}
|
||||
|
||||
@@ -121,38 +120,22 @@ func (r Recorder) Record(q Request) (Taken, error) {
|
||||
body[k] = v
|
||||
}
|
||||
}
|
||||
controller := map[string]any{"what": q.What, "why": q.Why, "cause": q.Cause}
|
||||
if q.Condition != "" {
|
||||
controller["condition"] = q.Condition
|
||||
}
|
||||
if q.Cause == "" {
|
||||
controller["cause"] = q.Verb
|
||||
}
|
||||
return r.either("hand-act", body, "hand-act", controller)
|
||||
return r.record("hand-act", body)
|
||||
}
|
||||
|
||||
// RecordWarrant records what the asking module did on the operator's warrant for its ask.
|
||||
func (r Recorder) RecordWarrant(asker, ask string) (Taken, error) {
|
||||
body := map[string]any{"asker": asker, "ask": ask}
|
||||
return r.either("warranted", body, "warranted", body)
|
||||
return r.record("warranted", body)
|
||||
}
|
||||
|
||||
func (r Recorder) either(verb string, body any, controllerVerb string, controllerBody any) (Taken, error) {
|
||||
func (r Recorder) record(verb string, body any) (Taken, error) {
|
||||
answer, err := r.Ask("seat:"+Seat+"."+verb, body)
|
||||
if err == nil {
|
||||
return Taken{Log: LogSeat, Answer: answer}, nil
|
||||
}
|
||||
if !NoResponder(err) {
|
||||
return Taken{}, fmt.Errorf("%s.%s refused: %w", Seat, verb, err)
|
||||
if NoResponder(err) {
|
||||
return Taken{}, fmt.Errorf("%s has no holder that answers: nothing was recorded (%w)", Seat, err)
|
||||
}
|
||||
if r.LeaseAway != nil && r.LeaseAway() {
|
||||
return Taken{}, fmt.Errorf("%s has no holder that answers, and the controller handed its lease away: nothing "+
|
||||
"was recorded (%w)", Seat, err)
|
||||
}
|
||||
answer, cerr := r.Ask("seat:mesh-controller."+controllerVerb, controllerBody)
|
||||
if cerr != nil {
|
||||
return Taken{}, fmt.Errorf("%s has no holder that answers (%v), and the controller's %s refused too: %w", Seat, err,
|
||||
controllerVerb, cerr)
|
||||
}
|
||||
return Taken{Log: LogController, Answer: answer}, nil
|
||||
return Taken{}, fmt.Errorf("%s.%s refused, nothing was recorded: %w", Seat, verb, err)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4): an act reads the controller's entries unchanged, and a recorder
|
||||
// records through the seat, through the controller's verb only while the seat has no holder and the lease is the
|
||||
// controller's, saying which log took it, and refuses to record nowhere.
|
||||
// ADR 0565 clauses 10 and 11 (issue 1953 L5-S2, S4, L5b): an act reads the controller's entries unchanged, and a
|
||||
// recorder records through the seat alone, the controller's verbs being gone, and says loudly when nothing was recorded.
|
||||
func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
|
||||
// The shape of mesh-controller internal/link HandAct, as its bucket keeps an entry (handacts.go at 0fbb5ef3).
|
||||
raw := `{"id":"act-1","at":"2026-10-28T09:00:00Z","by":"person.jochen","verb":"hand-act record","why":"a stuck consumer",` +
|
||||
@@ -39,14 +38,13 @@ func TestAnActIsRecordedInTheLogThatAnswers(t *testing.T) {
|
||||
warrants bool
|
||||
}{
|
||||
{name: "the seat answers", log: LogSeat, asked: "seat:mesh-hand-acts.hand-act"},
|
||||
{name: "no holder yet", seat: noResponders, log: LogController, asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
|
||||
{name: "no holder, the lease handed away", seat: noResponders, away: true, refused: "handed its lease away",
|
||||
{name: "no holder: nothing recorded, the controller never asked", seat: noResponders, refused: "nothing was recorded",
|
||||
asked: "seat:mesh-hand-acts.hand-act"},
|
||||
{name: "the seat refuses", seat: errors.New("why is required"), refused: "refused", asked: "seat:mesh-hand-acts.hand-act"},
|
||||
{name: "neither answers", seat: noResponders, ctrl: noResponders, refused: "refused too",
|
||||
asked: "seat:mesh-hand-acts.hand-act,seat:mesh-controller.hand-act"},
|
||||
{name: "a warrant without a holder", seat: noResponders, warrants: true, log: LogController,
|
||||
asked: "seat:mesh-hand-acts.warranted,seat:mesh-controller.warranted"},
|
||||
{name: "the seat refuses", seat: errors.New("why is required"), refused: "refused, nothing was recorded",
|
||||
asked: "seat:mesh-hand-acts.hand-act"},
|
||||
{name: "a warrant without a holder", seat: noResponders, warrants: true, refused: "nothing was recorded",
|
||||
asked: "seat:mesh-hand-acts.warranted"},
|
||||
{name: "a warrant recorded", warrants: true, log: LogSeat, asked: "seat:mesh-hand-acts.warranted"},
|
||||
} {
|
||||
var asked []string
|
||||
r := Recorder{LeaseAway: func() bool { return c.away }, Ask: func(key string, body any) (json.RawMessage, error) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// The same scan as mesh-catalog's checks/sdkjudge, run by mesh-sdk's own check (novox/hq ADR 0578 §4: the change that
|
||||
// adds a manifest judge to mesh-sdk is refused), so a change to mesh-sdk alone is refused too. Copied from mesh-catalog
|
||||
// feat/judge-once checks/sdkjudge/main.go; the two lists of composition's refusals are kept the same.
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// composition is every function or table the check lets stay in mesh-sdk, as "path-from-go/:name", and why. It names no
|
||||
// manifest judgement: each is the composer's refusal of composition itself (ADR 0578 clause 5).
|
||||
var composition = map[string]string{
|
||||
"catalogue/consumers.go:consumersPlaceholderProblems": "composition: rendering a consumer's template",
|
||||
"catalogue/collections.go:collectedPlaceholderProblems": "composition: placeholders a contribution renders",
|
||||
"catalogue/environment_into.go:placeholderProblems": "composition: placeholders a contribution renders",
|
||||
"catalogue/seat_contributions.go:seatPlaceholderProblems": "composition: placeholders a contribution renders",
|
||||
"catalogue/collections.go:itemTemplateProblem": "composition: filling an item's template",
|
||||
"catalogue/setting_takes.go:takenSettingsProblems": "composition: whether stored settings still compose",
|
||||
"catalogue/template.go:HeldTemplateProblem": "composition: the take-in refusal of a resolved build",
|
||||
}
|
||||
|
||||
// judgeProblems is what mesh-sdk's Go tree under root (its go/ directory) holds of a manifest judge, one line each, but for
|
||||
// what composition lets stay (the map the caller gives; the test of the real tree gives the package's), and the
|
||||
// entries of composition it no longer holds.
|
||||
func judgeProblems(root string, composition map[string]string) ([]string, error) {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if n := d.Name(); n == "testdata" || n == "vendor" || strings.HasPrefix(n, ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", rel, err)
|
||||
}
|
||||
inCatalogue := filepath.Base(filepath.Dir(path)) == "catalogue"
|
||||
for _, d := range file.Decls {
|
||||
switch d := d.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if why := judgeFunc(d); why != "" {
|
||||
out = append(out, note(composition, seen, rel, d.Name.Name, why))
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
if !inCatalogue || d.Tok != token.VAR {
|
||||
continue
|
||||
}
|
||||
for _, spec := range d.Specs {
|
||||
v, _ := spec.(*ast.ValueSpec)
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
for i, name := range v.Names {
|
||||
if i < len(v.Values) && allowName.MatchString(name.Name) && isTable(v.Values[i]) {
|
||||
out = append(out, note(composition, seen, rel, name.Name,
|
||||
"a table of what a manifest may declare (an allow-list)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var kept []string
|
||||
for _, line := range out {
|
||||
if line != "" {
|
||||
kept = append(kept, line)
|
||||
}
|
||||
}
|
||||
for key, why := range composition {
|
||||
if !seen[key] {
|
||||
kept = append(kept, fmt.Sprintf("composition lists %s (%s), which mesh-sdk no longer holds: take it off the list", key, why))
|
||||
}
|
||||
}
|
||||
sort.Strings(kept)
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// note is the line refusing name in rel for why, or "" when composition lets it stay.
|
||||
func note(composition map[string]string, seen map[string]bool, rel, name, why string) string {
|
||||
key := rel + ":" + name
|
||||
if _, ok := composition[key]; ok {
|
||||
seen[key] = true
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s: %s is %s; mesh-sdk carries the contract only, the judge is the indexer's and the checker's (ADR 0578 §4)", rel, name, why)
|
||||
}
|
||||
|
||||
// allowName is what names a table of what is allowed.
|
||||
var allowName = regexp.MustCompile(`(?i)allow|permit|removable|reserved|forbid|denied`)
|
||||
|
||||
// isTable says whether an expression is a map or a list literal of more than one entry.
|
||||
func isTable(e ast.Expr) bool {
|
||||
c, ok := e.(*ast.CompositeLit)
|
||||
if !ok || len(c.Elts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch c.Type.(type) {
|
||||
case *ast.MapType, *ast.ArrayType:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// judgeFunc says why a function is a manifest judge, or "".
|
||||
func judgeFunc(f *ast.FuncDecl) string {
|
||||
name := f.Name.Name
|
||||
if judgeEntry.MatchString(name) {
|
||||
return "a function that judges a manifest"
|
||||
}
|
||||
if !strings.HasSuffix(name, "Problems") && !strings.HasSuffix(name, "Problem") {
|
||||
return ""
|
||||
}
|
||||
if takes(f.Recv) || takes(f.Type.Params) {
|
||||
return "a function that gathers a manifest's problems"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// judgeEntry is the names of the judge's entry points: the strict parsers and the validators of a manifest or a shelf.
|
||||
var judgeEntry = regexp.MustCompile(`^(ParseManifest(With|ForInputs)?|Validate(Manifest|Shelf)\w*)$`)
|
||||
|
||||
// takes says whether a list of parameters names a Manifest or a Shelf anywhere in a type.
|
||||
func takes(fields *ast.FieldList) bool {
|
||||
if fields == nil {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
for _, f := range fields.List {
|
||||
ast.Inspect(f.Type, func(n ast.Node) bool {
|
||||
if id, ok := n.(*ast.Ident); ok && (id.Name == "Manifest" || id.Name == "Shelf") {
|
||||
found = true
|
||||
}
|
||||
return !found
|
||||
})
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// mesh-sdk holds no manifest judge: no function gathering a manifest's problems, no judge entry point and no allow-list
|
||||
// table in the catalogue, but for composition's own refusals, each listed with why (ADR 0578 §4, §5).
|
||||
func TestMeshSDKHoldsNoManifestJudge(t *testing.T) {
|
||||
root, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
problems, err := judgeProblems(root, composition)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range problems {
|
||||
t.Error(p)
|
||||
}
|
||||
}
|
||||
+7
-15
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
@@ -17,10 +16,10 @@ import (
|
||||
// not a membership is handed to onBad (nil to ignore it) and skipped.
|
||||
//
|
||||
// It reads the membership from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7),
|
||||
// watched so the value there now comes first and each change after it; and it hears the subject the grants part
|
||||
// publishes on until the lease write. The assignments stream is no longer read: the bus module's tools delete it
|
||||
// at the lease write (research 072 row 33). A state not there yet, or not readable, is told to onBad and the
|
||||
// subject is followed alone. The returned error is a refusal to follow at all.
|
||||
// watched so the value there now comes first and each change after it. Nothing else: the subject the grants part
|
||||
// published on is no longer heard (ADR 0571 clause 10, issue 774), since it stops publishing at the lease write, and
|
||||
// the assignments stream is no longer read (research 072 row 33). A state not there or not readable is a refusal to
|
||||
// follow, returned: there is nothing else to follow.
|
||||
func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(error)) error {
|
||||
if c.account.Node == "" || c.account.Module == "" {
|
||||
return fmt.Errorf("the account names no node or module, so it has no membership")
|
||||
@@ -38,27 +37,20 @@ func (c *Conn) FollowMembership(apply func(membership.Membership), onBad func(er
|
||||
}
|
||||
apply(issued)
|
||||
}
|
||||
subject := membership.Subject(c.account.Node, c.account.Module)
|
||||
if _, err := c.nc.Subscribe(subject, func(m *nats.Msg) { take(m.Data) }); err != nil {
|
||||
return fmt.Errorf("cannot follow %s: %w", subject, err)
|
||||
}
|
||||
key := membership.GrantsKey(c.account.Node, c.account.Module)
|
||||
js, err := jetstream.New(c.nc)
|
||||
if err != nil {
|
||||
bad(fmt.Errorf("cannot read %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
|
||||
return nil
|
||||
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
kv, err := js.KeyValue(ctx, membership.GrantsBucket)
|
||||
if err != nil {
|
||||
bad(fmt.Errorf("cannot read %s of %s yet (%v); following %s alone", key, membership.GrantsBucket, err, subject))
|
||||
return nil
|
||||
return fmt.Errorf("cannot read %s of %s: %w", key, membership.GrantsBucket, err)
|
||||
}
|
||||
w, err := kv.Watch(context.Background(), key)
|
||||
if err != nil {
|
||||
bad(fmt.Errorf("cannot watch %s of %s (%v); following %s alone", key, membership.GrantsBucket, err, subject))
|
||||
return nil
|
||||
return fmt.Errorf("cannot watch %s of %s: %w", key, membership.GrantsBucket, err)
|
||||
}
|
||||
go func() {
|
||||
for e := range w.Updates() {
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
)
|
||||
|
||||
// A process connected pinned to a test bus hears the membership published for its node and module, and
|
||||
// ignores what is not one (novox/hq issue 1633, AC2).
|
||||
// A process connected pinned to a test bus follows the membership the grants part writes for its node and module in
|
||||
// its state memberships, and reports what is not one (novox/hq issue 1633, AC2); it no longer hears mesh.assignment,
|
||||
// where the grants part stops publishing at the lease write (ADR 0571 clause 10; issue 774, 774/S5/AC1). Without the
|
||||
// state it refuses to follow, naming it.
|
||||
func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
|
||||
url, fingerprint := selfSignedServer(t)
|
||||
account := mailSenderAccount(t, url, fingerprint)
|
||||
@@ -20,26 +26,52 @@ func TestFollowMembershipHearsWhatIsIssuedToTheAccount(t *testing.T) {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
got := make(chan membership.Membership, 1)
|
||||
got := make(chan membership.Membership, 4)
|
||||
bad := make(chan error, 4)
|
||||
if err := conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e }); err != nil {
|
||||
follow := func() error {
|
||||
return conn.FollowMembership(func(m membership.Membership) { got <- m }, func(e error) { bad <- e })
|
||||
}
|
||||
if err := follow(); err == nil || !strings.Contains(err.Error(), membership.GrantsBucket) {
|
||||
t.Fatalf("followed with no state memberships: %v", err)
|
||||
}
|
||||
js, err := jetstream.New(conn.nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte("not json")); err != nil {
|
||||
ctx := context.Background()
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: membership.GrantsBucket})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.1"]}`)); err != nil {
|
||||
key := membership.GrantsKey("novox", "mesh-mail")
|
||||
if _, err := kv.Put(ctx, key, []byte("not json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := follow(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Published on the subject, as the grants part did until the write: not heard.
|
||||
if err := conn.nc.Publish(membership.Subject("novox", "mesh-mail"), []byte(`{"node":"novox","module":"mesh-mail","mesh":["10.0.0.9"]}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, key, membership.EncodeGrantsMembership(membership.Membership{Node: "novox", Module: "mesh-mail",
|
||||
Mesh: []string{"10.0.0.1"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case m := <-got:
|
||||
if m.Module != "mesh-mail" || len(m.Mesh) != 1 {
|
||||
if m.Module != "mesh-mail" || len(m.Mesh) != 1 || m.Mesh[0] != "10.0.0.1" {
|
||||
t.Fatalf("membership: %+v", m)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no membership heard")
|
||||
t.Fatal("no membership followed")
|
||||
}
|
||||
select {
|
||||
case m := <-got:
|
||||
t.Errorf("a second membership was applied, from the subject: %+v", m)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if len(bad) == 0 {
|
||||
t.Error("the body that is not a membership was not reported")
|
||||
t.Error("the value that is not a membership was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,4 +230,77 @@ func TestABundleKeepsAndWatchesStateThroughTheRuntime(t *testing.T) {
|
||||
t.Fatalf("the get answered %v", r)
|
||||
}
|
||||
_ = inW.Close()
|
||||
|
||||
// ADR 0580 clause 1: the watched copy answers keys and values after the watch's current values, a later put or delete
|
||||
// changes its answer, a follower's then sees the copy that took its change, each state is watched once, and a
|
||||
// value kept once is read by key once.
|
||||
handles, watches, gets := map[string]func(StateChange) error{}, map[string]int{}, 0
|
||||
w := WatchedStatesOver(func(state string, handle func(StateChange) error) error {
|
||||
watches[state]++
|
||||
handles[state] = handle
|
||||
return handle(StateChange{State: state, Key: "a", Op: OpPut, Value: []byte("1"), Current: true})
|
||||
}, func(state, key string) ([]byte, bool, error) { gets++; return []byte(key), key != "missing", nil })
|
||||
var thenSaw []string
|
||||
w.Follow("placement", func(c StateChange) error {
|
||||
v, found, _ := w.Get("placement", c.Key)
|
||||
thenSaw = append(thenSaw, fmt.Sprintf("%s %s %s %v", c.Op, c.Key, v, found))
|
||||
return nil
|
||||
})
|
||||
if keys, err := w.Keys("placement"); err != nil || fmt.Sprint(keys) != "[a]" {
|
||||
t.Fatalf("the copy's keys are %v (%v)", keys, err)
|
||||
}
|
||||
_ = handles["placement"](StateChange{Key: "b", Op: OpPut, Value: []byte("2")})
|
||||
_ = handles["placement"](StateChange{Key: "a", Op: OpDelete})
|
||||
vals, _ := w.Values("placement")
|
||||
if v, found, _ := w.Get("placement", "a"); found || string(vals["b"]) != "2" || len(vals) != 1 || v != nil {
|
||||
t.Fatalf("after a put and a delete the copy is %v", vals)
|
||||
}
|
||||
if fmt.Sprint(thenSaw) != "[put a 1 true put b 2 true delete a false]" {
|
||||
t.Fatalf("then saw %v", thenSaw)
|
||||
}
|
||||
_, _ = w.Keys("placement")
|
||||
if watches["placement"] != 1 {
|
||||
t.Fatalf("the state was watched %d times", watches["placement"])
|
||||
}
|
||||
// A read that starts a state's watch before its follower joins loses that follower no change: the follower is run over
|
||||
// the copy's current values when it joins, then hears every later change (review of ADR 0580 §2).
|
||||
if _, _, err := w.Get("haves", "n1.a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = handles["haves"](StateChange{Key: "n1.b", Op: OpPut, Value: []byte("2")}) // in the gap: no follower yet
|
||||
var joined []string
|
||||
w.Follow("haves", func(c StateChange) error { joined = append(joined, c.Key); return nil })
|
||||
_ = handles["haves"](StateChange{Key: "n2.c", Op: OpPut, Value: []byte("3")})
|
||||
if fmt.Sprint(joined) != "[a n1.b n2.c]" || watches["haves"] != 1 {
|
||||
t.Fatalf("a follower joining a watched state heard %v (watched %d times)", joined, watches["haves"])
|
||||
}
|
||||
for range 2 {
|
||||
if v, found, _ := w.Once("manifests", "d1"); !found || string(v) != "d1" {
|
||||
t.Fatalf("once read %q %v", v, found)
|
||||
}
|
||||
_, _, _ = w.Once("manifests", "missing")
|
||||
}
|
||||
if gets != 3 {
|
||||
t.Fatalf("a kept value was read by key %d times, want once, and the missing one twice", gets)
|
||||
}
|
||||
// Issue 1954: a state the process alone writes reads its own put and delete back from the copy at once, and the
|
||||
// watch's changes of that key up to the write are not taken over it; a later change is.
|
||||
_ = handles["placement"](StateChange{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 5})
|
||||
if err := w.Wrote("placement", "own", []byte("mine"), 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = w.Wrote("placement", "gone", []byte("x"), 8)
|
||||
_ = w.Removed("placement", "gone")
|
||||
for _, c := range []StateChange{{Key: "own", Op: OpPut, Value: []byte("old"), Revision: 6},
|
||||
{Key: "own", Op: OpPut, Value: []byte("mine"), Revision: 7}, {Key: "gone", Op: OpPut, Value: []byte("x"), Revision: 8}} {
|
||||
_ = handles["placement"](c)
|
||||
}
|
||||
own, _, _ := w.Get("placement", "own")
|
||||
if _, found, _ := w.Get("placement", "gone"); found || string(own) != "mine" {
|
||||
t.Fatalf("after its own writes and the watch's older changes the copy holds own=%s, gone found %v", own, found)
|
||||
}
|
||||
_ = handles["placement"](StateChange{Key: "gone", Op: OpPut, Value: []byte("again"), Revision: 9})
|
||||
if v, _, _ := w.Get("placement", "gone"); string(v) != "again" {
|
||||
t.Fatalf("a change after its own delete is not taken: %s", v)
|
||||
}
|
||||
}
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WatchedStates is a copy of the states a module reads, each filled by its own watch and kept by its changes (novox/hq
|
||||
// ADR 0580 clause 1): a read of a whole state is one watch's current values, never a get per key, which took a
|
||||
// composition past 30 seconds and a compare past its call bound. A state is watched on its first read, or on Follow,
|
||||
// and the watch hands every current value over before the read is answered, so the copy is complete from the first
|
||||
// answer. A key whose value never changes once written (a manifest, by its digest) is read once with Once and kept.
|
||||
// A state helper, no rule: what a module reads is its own.
|
||||
type WatchedStates struct {
|
||||
watch func(state string, handle func(StateChange) error) error
|
||||
get func(state, key string) ([]byte, bool, error)
|
||||
|
||||
mu sync.Mutex
|
||||
states map[string]*watchedState
|
||||
// once holds each value read with Once, by state and key, for the process's life: a few kilobytes per manifest
|
||||
// digest. Past onceAtMost keys it is emptied, which costs only a read again.
|
||||
once map[string][]byte
|
||||
}
|
||||
|
||||
// onceAtMost bounds the values Once keeps.
|
||||
const onceAtMost = 4096
|
||||
|
||||
type watchedState struct {
|
||||
ready chan struct{} // closed once the watch's current values are all in the copy, or it was refused
|
||||
err error
|
||||
|
||||
mu sync.Mutex
|
||||
values map[string][]byte
|
||||
// revs is, by key, the revision its value in the copy is at, and latest the highest revision the copy has seen: a
|
||||
// change the watch hands at a revision not newer than its key's is one the copy already took (Wrote, Removed).
|
||||
revs map[string]uint64
|
||||
latest uint64
|
||||
then []func(StateChange) error
|
||||
started bool // the current values are in, so a change runs then at once
|
||||
pending []StateChange // the current values, run through then once all are in
|
||||
thenMu sync.Mutex // then runs one change at a time
|
||||
}
|
||||
|
||||
// NewWatchedStates is the copy over this bundle's runtime: each state by KeptState.Watch, Once by KeptState.Get.
|
||||
func NewWatchedStates() *WatchedStates {
|
||||
return WatchedStatesOver(
|
||||
func(state string, handle func(StateChange) error) error { return State(state).Watch("", handle) },
|
||||
func(state, key string) ([]byte, bool, error) {
|
||||
e, err := State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// WatchedStatesOver is the copy over a watch and a get of one's own (a process's bus, a test's): watch hands handle
|
||||
// every current value and returns once they are handled, then hands every change; get reads one key.
|
||||
func WatchedStatesOver(watch func(state string, handle func(StateChange) error) error,
|
||||
get func(state, key string) ([]byte, bool, error)) *WatchedStates {
|
||||
return &WatchedStates{watch: watch, get: get, states: map[string]*watchedState{}, once: map[string][]byte{}}
|
||||
}
|
||||
|
||||
// watched is the state's copy, its watch started now when it has none, the caller waiting until its current values
|
||||
// are in. then, when given, runs after the copy took each change: the current values once they are all in (a then
|
||||
// that reads the copy reads it whole), each later change as it comes. No lock is held while the watch starts, so a
|
||||
// then may read any state of the copy.
|
||||
func (w *WatchedStates) watched(state string, then func(StateChange) error) (*watchedState, error) {
|
||||
w.mu.Lock()
|
||||
s, ok := w.states[state]
|
||||
if ok {
|
||||
w.mu.Unlock()
|
||||
<-s.ready
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if then != nil {
|
||||
s.join(state, then)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
s = &watchedState{ready: make(chan struct{}), values: map[string][]byte{}, revs: map[string]uint64{}}
|
||||
if then != nil {
|
||||
s.then = []func(StateChange) error{then}
|
||||
}
|
||||
w.states[state] = s
|
||||
w.mu.Unlock()
|
||||
err := w.watch(state, func(c StateChange) error {
|
||||
s.mu.Lock()
|
||||
switch {
|
||||
case c.Revision != 0 && c.Revision <= s.revs[c.Key]:
|
||||
// Already in the copy, or older than what is: this process wrote the key since (Wrote, Removed).
|
||||
case c.Op == OpPut:
|
||||
s.values[c.Key] = append([]byte(nil), c.Value...)
|
||||
s.revs[c.Key] = c.Revision
|
||||
default:
|
||||
delete(s.values, c.Key)
|
||||
s.revs[c.Key] = c.Revision
|
||||
}
|
||||
s.latest = max(s.latest, c.Revision)
|
||||
if !s.started {
|
||||
s.pending = append(s.pending, c)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return s.run(state, c)
|
||||
})
|
||||
if err != nil {
|
||||
s.err = fmt.Errorf("the state %s is not watched: %w", state, err)
|
||||
w.mu.Lock()
|
||||
delete(w.states, state) // asked again on the next read
|
||||
w.mu.Unlock()
|
||||
close(s.ready)
|
||||
return nil, s.err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.started = true
|
||||
pending := s.pending
|
||||
s.pending = nil
|
||||
s.mu.Unlock()
|
||||
close(s.ready)
|
||||
for _, c := range pending {
|
||||
if err := s.run(state, c); err != nil {
|
||||
sayf("%v", err)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// join adds then to a state already watched (a read started its watch first) and runs it over the copy's current values
|
||||
// before any later change, so no change that landed before it joined is lost to it (review of ADR 0580 §2: a read
|
||||
// beat the follower to the watch at start-up). Under thenMu, so a later change waits for these; a change the copy took
|
||||
// just before the snapshot may also be handed again, which a then that wakes work takes as a second wake.
|
||||
func (s *watchedState) join(state string, then func(StateChange) error) {
|
||||
s.thenMu.Lock()
|
||||
defer s.thenMu.Unlock()
|
||||
s.mu.Lock()
|
||||
s.then = append(s.then, then)
|
||||
current := make([]StateChange, 0, len(s.values))
|
||||
for k, v := range s.values {
|
||||
current = append(current, StateChange{State: state, Key: k, Op: OpPut, Value: v, Current: true})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
sort.Slice(current, func(i, j int) bool { return current[i].Key < current[j].Key })
|
||||
for _, c := range current {
|
||||
if err := then(c); err != nil {
|
||||
sayf("%s %s: %v", state, c.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run hands one change, already in the copy, to every then.
|
||||
func (s *watchedState) run(state string, c StateChange) error {
|
||||
s.thenMu.Lock()
|
||||
defer s.thenMu.Unlock()
|
||||
s.mu.Lock()
|
||||
then := append([]func(StateChange) error(nil), s.then...)
|
||||
s.mu.Unlock()
|
||||
var errs []error
|
||||
for _, f := range then {
|
||||
if err := f(c); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s %s: %v", state, c.Key, errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Follow watches the state into the copy and runs then on each change after the copy took it, so a reader then wakes
|
||||
// never reads a copy older than its change; it asks again until the watch is taken (2 s, doubled to a minute), as
|
||||
// KeptState.Follow does. On a state already watched then hears the changes from now on.
|
||||
func (w *WatchedStates) Follow(state string, then func(StateChange) error) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
if _, err := w.watched(state, then); err == nil {
|
||||
return
|
||||
} else {
|
||||
sayf("%v; asking again in %s", err, wait)
|
||||
}
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// Get is a key's value in the copy, false when it has none.
|
||||
func (w *WatchedStates) Get(state, key string) ([]byte, bool, error) {
|
||||
s, err := w.watched(state, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.values[key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
// Keys are the state's keys in the copy, sorted.
|
||||
func (w *WatchedStates) Keys(state string) ([]string, error) {
|
||||
s, err := w.watched(state, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
keys := make([]string, 0, len(s.values))
|
||||
for k := range s.values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// Values is every key's value in the copy.
|
||||
func (w *WatchedStates) Values(state string) (map[string][]byte, error) {
|
||||
s, err := w.watched(state, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make(map[string][]byte, len(s.values))
|
||||
for k, v := range s.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Once is a key whose value never changes once written, read by key the first time it is found and kept after: a
|
||||
// manifest by its digest. A key not found is not kept, so it is read again on the next ask.
|
||||
func (w *WatchedStates) Once(state, key string) ([]byte, bool, error) {
|
||||
w.mu.Lock()
|
||||
v, ok := w.once[state+" "+key]
|
||||
w.mu.Unlock()
|
||||
if ok {
|
||||
return v, true, nil
|
||||
}
|
||||
v, found, err := w.get(state, key)
|
||||
if err != nil || !found {
|
||||
return nil, false, err
|
||||
}
|
||||
w.mu.Lock()
|
||||
if len(w.once) >= onceAtMost {
|
||||
clear(w.once)
|
||||
}
|
||||
w.once[state+" "+key] = v
|
||||
w.mu.Unlock()
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
// Wrote takes a put this process made into the copy at once, at the revision the bus answered (0 when not known): a
|
||||
// state the process alone writes (a holder's own) is read back from the copy right after its own write, before the
|
||||
// watch hands the change back, and the watch's older changes of the key are not taken over it (issue 1954). The state
|
||||
// is watched first when it is not yet.
|
||||
func (w *WatchedStates) Wrote(state, key string, value []byte, revision uint64) error {
|
||||
return w.took(state, key, value, revision, true)
|
||||
}
|
||||
|
||||
// Removed takes a delete this process made into the copy at once, as Wrote takes a put.
|
||||
func (w *WatchedStates) Removed(state, key string) error { return w.took(state, key, nil, 0, false) }
|
||||
|
||||
func (w *WatchedStates) took(state, key string, value []byte, revision uint64, put bool) error {
|
||||
s, err := w.watched(state, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch {
|
||||
case put && revision != 0:
|
||||
if revision <= s.revs[key] {
|
||||
return nil // the watch handed a newer change of the key already
|
||||
}
|
||||
s.values[key] = append([]byte(nil), value...)
|
||||
s.revs[key] = revision
|
||||
s.latest = max(s.latest, revision)
|
||||
default:
|
||||
// A delete's revision is not answered, nor a put's at times: it is newer than every revision the copy has seen,
|
||||
// so the watch's changes of the key up to that one are not taken over it.
|
||||
if put {
|
||||
s.values[key] = append([]byte(nil), value...)
|
||||
} else {
|
||||
delete(s.values, key)
|
||||
}
|
||||
s.revs[key] = s.latest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Direct is a key read by key now, not from the copy: a value that changes and is read one at a time (a build).
|
||||
func (w *WatchedStates) Direct(state, key string) ([]byte, bool, error) { return w.get(state, key) }
|
||||
Reference in New Issue
Block a user