Merge remote-tracking branch 'origin/main' into adr-0297-module-logs
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

This commit is contained in:
jochen
2026-10-12 02:29:00 +02:00
75 changed files with 5286 additions and 165 deletions
+53
View File
@@ -0,0 +1,53 @@
package broker
import "testing"
// The controller asks a tool that answers a software-issued credential in its own name, and nothing else under
// `call` (novox/hq ADR 0305 §2, issue 526): the grant is composed from the records, so the installer's first user
// list, a controller with no records, carries none.
func TestTheControllerAsksAnIssuedCredentialsToolInItsOwnNameAlone(t *testing.T) {
r := Records{Nodes: []string{"n", "ace"}, Assigned: map[string][]Declared{
"n": {{Module: "x", Issued: []string{"t"}}},
"ace": {{Module: "plex", Issued: []string{"plex_issued_credential"}}, {Module: "kometa"}},
}}
users, err := Users(r)
if err != nil {
t.Fatal(err)
}
controller := users[0]
if controller.Kind != KindController {
t.Fatalf("the first user is %s", controller.Username())
}
perms, err := PermissionsFor(controller)
if err != nil {
t.Fatal(err)
}
for _, s := range []string{
"mesh.mod.x.call.t.n.controller",
"mesh.mod.plex.call.plex_issued_credential.ace.controller",
} {
if !MayPublish(perms, s) {
t.Errorf("the controller may not publish %s", s)
}
}
for _, s := range []string{
"mesh.mod.x.call.t.n.ace.node-tools",
"mesh.mod.x.call.t.n." + CallerToken("ace.node-tools"),
"mesh.mod.plex.call.plex_issued_credential.ace." + CallerToken("ace.node-tools"),
"mesh.mod.plex.call.plex_status.ace.controller",
"mesh.mod.kometa.call.t.ace.controller",
"mesh.seat.mesh-controller.call.status.controller",
} {
if MayPublish(perms, s) {
t.Errorf("the controller may publish %s", s)
}
}
bare, err := PermissionsFor(Principal{Kind: KindController})
if err != nil {
t.Fatal(err)
}
if MayPublish(bare, "mesh.mod.x.call.t.n.controller") {
t.Fatal("a controller composed with no records is granted a caller-named call")
}
}
+18
View File
@@ -123,6 +123,13 @@ type Principal struct {
// permission and nothing beside it.
Invokes []string
// AsksIssued are the tools the controller asks in its own name for a credential a provider's software
// issued, each `<module>.<tool>` (novox/hq ADR 0305): on the subject that names its caller, so the tool
// knows the controller asks. Composed from the records, so the installer's first user list — a controller
// with no records — carries none, and moves with issue 464 like the rest of the controller's caller-named
// grants.
AsksIssued []string
// State is the local names of the state this principal's module keeps, and Reads the state of
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
// instances and read by whoever declares it.
@@ -900,6 +907,17 @@ func PermissionsFor(p Principal) (Permissions, error) {
// and moves with issue 464.
if p.Kind != KindController {
pub, sub = callerNamed(p.Username(), pub, sub)
} else {
// **But the tools that answer a software-issued credential** (novox/hq ADR 0305 §2): those answer the
// controller alone, and know it only from the subject's last token, so it asks them in its own name —
// those tools, on any machine, and nothing else under `call`.
for _, key := range p.AsksIssued {
module, tool, ok := strings.Cut(key, ".")
if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") {
continue
}
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
}
}
sort.Strings(pub)
+23 -1
View File
@@ -29,6 +29,9 @@ type Declared struct {
Holds []Seat
// Uses are the seats this module sends to.
Uses []Seat
// Issued are the tools that answer a credential its software issued, one per offer that says so (novox/hq
// ADR 0305): the controller is granted to ask each in its own name, and no other tool so.
Issued []string
// Watches are the seats whose events it consumes.
Watches []Seat
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
@@ -84,7 +87,7 @@ type Records struct {
// is a mesh that cannot be told anything, and there is no state of the records in which that is
// correct.
func Users(r Records) ([]Principal, error) {
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r)}}
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r)}}
for _, node := range sortedCopy(r.Nodes) {
witness := false
@@ -206,6 +209,25 @@ const controllerModule = "mesh-controller"
// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller
// asks the operator through it like any other user, and is granted what its declaration names for a caller.
// None while nothing holds it.
// issuedOf is every tool of a module assigned anywhere that answers a credential its software issued, as
// `<module>.<tool>` (novox/hq ADR 0305): what the controller asks in its own name, and nothing beside.
func issuedOf(r Records) []string {
seen := map[string]bool{}
var out []string
for _, node := range sortedCopy(r.Nodes) {
for _, d := range r.Assigned[node] {
for _, t := range d.Issued {
if key := d.Module + "." + t; !seen[key] {
seen[key] = true
out = append(out, key)
}
}
}
}
sort.Strings(out)
return out
}
func asksSeatOf(r Records) []Seat {
for _, node := range sortedCopy(r.Nodes) {
for _, d := range r.Assigned[node] {
@@ -79,23 +79,38 @@ func TestAHolderMeetsItsOwnContribution(t *testing.T) {
}
}
func TestTwoModulesDeclaringOnePackageAreRefusedBeforeAnythingIsRecorded(t *testing.T) {
func TestTwoModulesMayDeclareOnePackage(t *testing.T) {
pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}),
res("package", "pacman-contrib"))
bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib"))
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar, "other": other}
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar}
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"}); err != nil {
t.Fatalf("a second declaration of a package was refused (novox/hq ADR 0303): %v", err)
}
}
err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"})
if err == nil || !strings.Contains(err.Error(), "pacman-contrib") || !strings.Contains(err.Error(), "bar") {
t.Fatalf("the second owner of a package was not refused by name: %v", err)
func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) {
gone := res("package", "ufw")
gone["absent"] = true
filter := withResources(mod("nftables", nil, nil, nil), gone)
wants := withResources(mod("legacy", nil, nil, nil), res("package", "ufw"))
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
catalogue := map[string]Manifest{"nftables": filter, "legacy": wants, "other": other}
err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"legacy"})
if err == nil || !strings.Contains(err.Error(), "ufw") || !strings.Contains(err.Error(), "nftables") ||
!strings.Contains(err.Error(), "legacy") || !strings.Contains(err.Error(), "ADR 0303") {
t.Fatalf("a package declared present and absent was not refused naming both: %v", err)
}
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"other"}); err != nil {
t.Errorf("a module declaring nothing shared is refused: %v", err)
if strings.Contains(err.Error(), "one owner per node") {
t.Errorf("the refusal gives the one-owner rule, which no longer holds for packages: %v", err)
}
// A collision already on the node is status's, not a reason to refuse an unrelated assignment.
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman", "bar"}, []string{"other"}); err != nil {
t.Errorf("an unrelated assignment is refused for a collision already there: %v", err)
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"other"}); err != nil {
t.Errorf("a module declaring nothing contradictory is refused: %v", err)
}
// A contradiction already on the node is status's, not a reason to refuse an unrelated assignment.
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables", "legacy"}, []string{"other"}); err != nil {
t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err)
}
}
+22 -2
View File
@@ -342,18 +342,38 @@ func (e *NotMadeError) Error() string {
// compose. Empty when every module composes. The same judgement SetSettings makes before storing.
func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string {
out := map[string]string{}
for module, why := range r.LeftOutBecause(settings, adopted) {
out[module] = why.Error()
}
return out
}
// LeftOutBecause is LeftOut with each reason as the error it was, so a reader can tell a setting nobody
// gave (an *UnsetSettingError) from any other cause and say it as the operator's to give (novox/hq issue
// 380). An UnreadManifestError for a stored manifest this controller cannot read whole.
func (r Resolution) LeftOutBecause(settings SettingsBy, adopted bool) map[string]error {
out := map[string]error{}
for _, m := range r.Modules {
if why := UnknownFieldReason(m); why != "" {
out[m.Module] = why
out[m.Module] = &UnreadManifestError{Module: m.Module, said: why}
continue
}
if err := JudgeSettings(m, settings[m.Module], adopted); err != nil {
out[m.Module] = err.Error()
out[m.Module] = err
}
}
return out
}
// UnreadManifestError is a module left out because its stored manifest has a key this controller does not know
// (novox/hq ADR 0262): said once for the whole mesh, by the catalogue's own condition, not per machine.
type UnreadManifestError struct {
Module string
said string
}
func (e *UnreadManifestError) Error() string { return e.said }
// Compose is Declaration with the owner of every resource said.
func (r Resolution) Compose(with Rendering) (Composed, error) {
owner := map[string]string{}
+1 -1
View File
@@ -21,7 +21,7 @@ func deliveryVerbs() []Verb {
// Delivery time against the delivery budgets (novox/hq ADR 0282): what probe D16 reads, optional until its
// holder serves it.
{Name: "times", Description: "Delivery time: from a merge to every machine of its walk running the build. " +
"Each class's delivery budget (a leaf module five minutes, a core module ten), the last days' median and " +
"Each class's delivery budget (a leaf module three minutes, a core module ten), the last days' median and " +
"worst, how many within and over, the newest delivery of each class, every delivery over its budget with its " +
"longest phase, and the delivered ones whose time is not known. Given a delivery's id, its time phase by phase.",
Input: schema(map[string]string{"days": "how many days back (default 7)", "id": "one delivery's id: its phases"}, nil),
+71
View File
@@ -0,0 +1,71 @@
package catalogue
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"slices"
"strconv"
"testing"
"github.com/novox/mesh-controller/internal/beside"
)
// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue
// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone
// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists
// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor
// passes what it refuses by path alone.
func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) {
file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go")
parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
if err != nil {
t.Fatalf("the node-engine's guard does not parse: %v", err)
}
lists := map[string][]string{}
consts := map[string]string{}
ast.Inspect(parsed, func(n ast.Node) bool {
spec, ok := n.(*ast.ValueSpec)
if !ok {
return true
}
for i, name := range spec.Names {
if i >= len(spec.Values) {
continue
}
switch v := spec.Values[i].(type) {
case *ast.CompositeLit:
for _, elt := range v.Elts {
if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING {
s, _ := strconv.Unquote(lit.Value)
lists[name.Name] = append(lists[name.Name], s)
}
}
case *ast.BasicLit:
if v.Kind == token.STRING {
consts[name.Name], _ = strconv.Unquote(v.Value)
}
}
}
return true
})
for name, ours := range map[string][]string{
"protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees,
} {
theirs := lists[name]
if len(theirs) == 0 {
t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name)
continue
}
a, b := slices.Clone(ours), slices.Clone(theirs)
slices.Sort(a)
slices.Sort(b)
if !slices.Equal(a, b) {
t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b)
}
}
if consts["engineModule"] != engineModule {
t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule)
}
}
@@ -0,0 +1,42 @@
package catalogue
import (
"strings"
"testing"
)
// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer
// names the tool that answers it and no own secret, and every other shape is refused at parse.
func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"media","version":"1",
"provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}],
"serves":{"media-api":{"port":32400}}}`))
if err != nil {
t.Fatal(err)
}
if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" {
t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued)
}
if _, shared := m.SharedCredentialOf("media-api"); shared {
t.Fatal("an issued credential was read as an own secret shared")
}
if _, issued := m.IssuedCredentialOf("other"); issued {
t.Fatal("a provision the module does not offer reads as issued")
}
for want, raw := range map[string]string{
"the one issuer an offer may name": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
"names no tool that answers it": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
"the one issuer an offer may name ": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
} {
want = strings.TrimSpace(want)
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("expected a refusal saying %q, got %v", want, err)
}
}
}
+41 -3
View File
@@ -268,9 +268,29 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool {
// MachineReach is whether a provision is usable only on its provider's own machine.
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
// OfferCredential names which of the provider's own secrets a provision's consumers receive — or, with
// IssuedBy, that the provider's software issues the provision's one credential itself (novox/hq ADR 0305,
// issue 526): a media server's sign-in token from its vendor, which the mesh can neither make nor rotate.
// Tool is then the provider's own tool that answers that value, sealed to a key made for the controller's
// one call, and to the controller alone.
type OfferCredential struct {
Own string `json:"own"`
Own string `json:"own,omitempty"`
IssuedBy string `json:"issued-by,omitempty"`
Tool string `json:"tool,omitempty"`
}
// IssuedBySoftware says a provision's credential is issued by its provider's software (novox/hq ADR 0305).
const IssuedBySoftware = "software"
// IssuedCredentialOf is the tool through which this module answers the credential its software issued for a
// provision, and whether its offer says the software issues it (novox/hq ADR 0305).
func (m Manifest) IssuedCredentialOf(provision string) (string, bool) {
for _, o := range m.Provides {
if o.Name == provision && o.Credential != nil && o.Credential.IssuedBy == IssuedBySoftware && o.Credential.Tool != "" {
return o.Credential.Tool, true
}
}
return "", false
}
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
@@ -1555,7 +1575,24 @@ func ParseManifest(raw []byte) (Manifest, error) {
"makes is %d", m.Module, p, id.Max, shortest))
}
}
if offer.Credential != nil {
if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") {
// The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own
// secret, which would be a second copy for the mesh to keep in step with the software's.
switch {
case c.IssuedBy != IssuedBySoftware:
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential issued by %q; the one issuer an offer may name is %q (ADR 0305)",
m.Module, p, c.IssuedBy, IssuedBySoftware))
case c.Own != "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential its software issues and names its own secret %q too; the "+
"software's value is the one, so the offer names no own secret (ADR 0305)", m.Module, p, c.Own))
case strings.TrimSpace(c.Tool) == "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential its software issues and names no tool that answers it: "+
"\"tool\": \"<its tool>\" (ADR 0305)", m.Module, p))
}
} else if offer.Credential != nil {
own, declared := m.OwnSecrets[offer.Credential.Own]
switch {
case offer.Credential.Own == "":
@@ -2145,6 +2182,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, m.undeclaredMounts()...)
problems = append(problems, m.unknownDirRefs()...)
problems = append(problems, m.unknownAccessRefs()...)
problems = append(problems, m.resolvedPathProblems()...)
problems = append(problems, m.jailProblems()...)
// What a module adds to the account's environment and to the login shell, and the holder's
// placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses
+99
View File
@@ -92,6 +92,105 @@ func systemPath(path string) string {
return ""
}
// Where no directory or file a module's definition resolves to may be (novox/hq issue 496).
//
// mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to <root>/docker: with the
// default root, /var/lib/docker, every container's filesystem. systemPath judged only the `places` and `accesses`
// settings, so the default layout and a definition's own paths reached the merge gate unjudged; it composed every
// machine and passed, and only the node-engine refused the directory, at apply, failing the walk.
//
// **Judged where a definition is judged, never where a machine is composed.** resolvedPathProblems runs in
// ParseManifest, which every route a definition takes into the mesh passes: `module check`, registration of a
// build (the builder's and the registry verbs'), and the merge gate's reading of the changed repository. A
// refusal there stops one definition before it reaches any machine. Composition reads registered manifests
// without ParseManifest, and judges nothing of this: refusing there would fail the whole machine's declaration and
// freeze every module on it for one module's path, where the node-engine fails only that resource.
//
// **The node-engine's rules, no more** (mesh-host's internal/apply/placement_guard.go, with files judged as
// directories are after novox/hq issue 495, rule 6). A path is refused when it is one of protectedRoots or holds
// one, when it is at or below a tree in forbiddenBelow, or at or below one of engineTrees and its module is not
// the node-engine's. What the engine judges with what only the machine knows stays the engine's: where the
// runtimes really keep their data, links, the accounts' homes, a directory's owner below /etc. The lists are the
// engine's own words, and a test (engine_guard_test.go) holds them equal to mesh-host's beside this repository.
// systemPath stays the stricter rule for a setting: a setting is an operator's word about one machine, and the
// trees it lists (/etc, /usr, /run, the mesh's own) are where the mesh's own modules write by design.
var (
protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64",
"/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin",
"/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin",
"/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp",
"/var/spool"}
forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker",
"/var/lib/containers", "/var/lib/containerd"}
engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
)
// engineModule is the node-engine's own module, the one that places in engineTrees.
const engineModule = "mesh-host"
// enginePath says why the node-engine refuses a directory or file at path for module, or "".
func enginePath(path, module string) string {
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return ""
}
atOrBelow := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") }
for _, root := range protectedRoots {
if path == root || path == "/" || strings.HasPrefix(root, path+"/") {
return root + " is one of the machine's own directories, and owning it is owning everything in it"
}
}
for _, tree := range forbiddenBelow {
if atOrBelow(tree) {
return "nothing is placed in " + tree
}
}
if module != engineModule {
for _, tree := range engineTrees {
if atOrBelow(tree) {
return tree + " is the node-engine's own, placed in by its own module alone"
}
}
}
return ""
}
// resolvedPathProblems is every directory and file of the definition whose path, resolved as a node with the
// default root resolves it, the node-engine would refuse (novox/hq issue 496). A path still holding a placeholder
// only a machine fills (a setting, an access the definition gives no default) is the engine's to judge.
func (m Manifest) resolvedPathProblems() []string {
dirs := dirsFor(m, Rendering{})
accesses := map[string]string{}
for _, a := range m.Accesses {
if a.ID != "" && a.Path != "" {
accesses[a.ID] = a.Path
}
}
var problems []string
for _, r := range m.Resources {
kind := fmt.Sprint(r["type"])
if kind != "directory" && kind != "file" {
continue
}
id := fmt.Sprint(r["id"])
path, _ := r["path"].(string)
if kind == "directory" && path == "" {
path = dirs[id]
}
path, _ = dirFill(path, dirs, m.Module)
path, _ = accessFill(path, accesses, m.Module)
if path == "" || strings.Contains(path, "${") {
continue
}
if why := enginePath(path, m.Module); why != "" {
problems = append(problems, fmt.Sprintf("%s's %s %q resolves to %s, which the node-engine refuses: %s. "+
"A module's directories and files are judged when its definition is, so the merge gate refuses it "+
"before any machine does (novox/hq issue 496)", m.Module, kind, id, filepath.Clean(path), why))
}
}
return problems
}
// accessRef is how a module names one of its accesses: ${access:<id>}.
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
+33 -1
View File
@@ -960,6 +960,7 @@ func checkResources(modules []Manifest) []string {
var problems []string
owner := map[string]string{}
ownedPath := map[string]string{} // path → owning module, for the access check below
packagePresent, packageAbsent := map[string][]string{}, map[string][]string{}
for _, m := range modules {
// Compared placed, not as written (novox/hq ADR 0112): ${dir:state} is the same six
@@ -992,7 +993,22 @@ func checkResources(modules []Manifest) []string {
}
continue
}
for _, field := range []string{"path", "unit", "name", "package"} {
if fmt.Sprint(r["type"]) == "package" {
// **A package may be declared by several modules** (novox/hq ADR 0303): declaring it says
// it is needed, and the host installs a missing one, never removes one a declaration
// stopped naming, so a second declaration changes nothing on the machine. What two
// modules can contradict is presence: one declaring it present and another absent
// (ADR 0180) would have the host install and remove it in turn, so that pair is refused.
if name, _ := r["package"].(string); name != "" {
if r["absent"] == true {
packageAbsent[name] = append(packageAbsent[name], m.Module)
} else {
packagePresent[name] = append(packagePresent[name], m.Module)
}
}
continue
}
for _, field := range []string{"path", "unit", "name"} {
value, ok := r[field].(string)
if !ok || value == "" {
if field == "path" && fmt.Sprint(r["type"]) == "directory" {
@@ -1038,6 +1054,12 @@ func checkResources(modules []Manifest) []string {
ownedPath[fact.Path] = m.Module
}
}
for _, name := range packageNames(packageAbsent) {
if wanted := packagePresent[name]; len(wanted) > 0 {
problems = append(problems, fmt.Sprintf("%s declares the package %q absent and %s declare it present (novox/hq ADR 0303)",
strings.Join(packageAbsent[name], ", "), name, strings.Join(wanted, ", ")))
}
}
// An accessed path is the operator's, so no module may declare it as one of its own
// (novox/hq ADR 0051). Refused here rather than silently tolerated: an owner would create and
@@ -1308,3 +1330,13 @@ func answeredElsewhere(want string, node Node, world World, brokered, keeps map[
holder, held := HolderAmong(want, world.Offered[want], world.Held)
return held && holder.Node != node.Name
}
// packageNames are the packages a map names, in order.
func packageNames(m map[string][]string) []string {
names := make([]string, 0, len(m))
for n := range m {
names = append(names, n)
}
sort.Strings(names)
return names
}
+127
View File
@@ -0,0 +1,127 @@
package catalogue
// A definition's directories and files are judged at their resolved paths when the definition is (novox/hq issue
// 496). mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to <root>/docker —
// /var/lib/docker, every container's filesystem. The merge gate composed every machine and passed it; only the
// node-engine refused it, at apply, and failed the walk. ParseManifest is what `module check`, registration and the
// merge gate's reading of a changed repository all run, so a refusal here is a refusal at each of them.
import (
"strings"
"testing"
)
func TestADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) {
// The #205 shape, as it was merged.
_, err := ParseManifest([]byte(`{"module": "docker", "version": "1",
"resources": [{"id": "state", "type": "directory", "place": "."}]}`))
if err == nil {
t.Fatal("a directory resolving to /var/lib/docker was accepted; the node-engine refuses it at apply")
}
for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} {
if !strings.Contains(err.Error(), said) {
t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err)
}
}
}
func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) {
// The fix #205 needed: the mesh's own directory for the module, <root>/mesh/docker.
m, err := ParseManifest([]byte(`{"module": "docker", "version": "1", "resources": [
{"id": "state", "type": "directory", "place": "mesh"},
{"id": "marker", "type": "file", "path": "${dir:state}/applied", "content": "x"}]}`))
if err != nil {
t.Fatalf("place %q is in the mesh's tree, where the mesh writes for every module: %v", "mesh", err)
}
if got := dirsFor(m, Rendering{}); got["state"] != "/var/lib/mesh/docker" {
t.Fatalf("got %v", got)
}
}
// The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files.
func TestADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) {
refused := map[string]string{
"a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [
{"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`,
"a file in containerd's data": `{"module": "images", "version": "1", "resources": [
{"id": "f", "type": "file", "path": "/var/lib/containerd/x", "content": "x"}]}`,
"a file beneath a placed directory that climbs out of it": `{"module": "docker", "version": "1", "resources": [
{"id": "state", "type": "directory", "place": "mesh"},
{"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}]}`,
"a file in /boot": `{"module": "grub", "version": "1", "resources": [
{"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}]}`,
"a directory that is the mesh's whole tree": `{"module": "mesh", "version": "1", "resources": [
{"id": "all", "type": "directory", "place": "."}]}`,
"a directory that holds /etc": `{"module": "x", "version": "1", "resources": [
{"id": "d", "type": "directory", "path": "/"}]}`,
"a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1",
"resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`,
}
for name, raw := range refused {
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "issue 496") {
t.Errorf("%s: accepted, or refused for another reason: %v", name, err)
}
}
// What the engine applies, the mesh's own modules' paths among them (read from every machine's live plan):
// the machine's configuration, run directories, programs below /usr/local, the node-engine's own trees by its
// own module, an account's keys, a module's own root, and — not on the engine's lists, so not refused here —
// below /lib and at /storage, /data, /services and /var/lock.
accepted := map[string]string{
"a unit in /etc": `{"module": "power", "version": "1", "resources": [
{"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"},
{"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}]}`,
"a run directory": `{"module": "fail2ban", "version": "1", "resources": [
{"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`,
"a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [
{"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}]}`,
"the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version": "1", "resources": [
{"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"},
{"id": "state", "type": "directory", "path": "/var/lib/mesh-host"}]}`,
"an account's .ssh": `{"module": "ssh-client", "version": "1", "resources": [
{"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"},
{"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}]}`,
"a module's own root": `{"module": "mailu", "version": "1", "resources": [
{"id": "state", "type": "directory", "place": "."}]}`,
"a file below /lib": `{"module": "udev", "version": "1", "resources": [
{"id": "rule", "type": "file", "path": "/lib/udev/rules.d/99-x.rules", "content": "x"}]}`,
"directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version": "1", "resources": [
{"id": "a", "type": "directory", "path": "/storage"}, {"id": "b", "type": "directory", "path": "/data"},
{"id": "c", "type": "directory", "path": "/services"}, {"id": "d", "type": "directory", "path": "/var/lock"}]}`,
}
for name, raw := range accepted {
if _, err := ParseManifest([]byte(raw)); err != nil {
t.Errorf("%s: refused: %v", name, err)
}
}
}
func TestAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) {
// A file's path may name an access; the access's default path is the definition's, so it is judged with it.
_, err := ParseManifest([]byte(`{"module": "backup", "version": "1",
"accesses": [{"id": "images", "path": "/var/lib/docker/volumes"}],
"resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`))
if err == nil || !strings.Contains(err.Error(), "/var/lib/docker/volumes/marker") ||
!strings.Contains(err.Error(), "issue 496") {
t.Fatalf("a file reaching Docker's data through an access's default path was accepted: %v", err)
}
// An access the definition gives no path is placed by a setting, which Places and AccessPlaces judge, and on
// the machine by the node-engine: nothing here to resolve it against, so nothing is refused for it.
if _, err := ParseManifest([]byte(`{"module": "backup", "version": "1",
"accesses": [{"id": "images"}],
"resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)); err != nil {
t.Fatalf("an access placed only by a setting cannot be judged at the definition: %v", err)
}
}
func TestComposingAMachineIsNeverStoppedByOneModulesPath(t *testing.T) {
// A module registered from outside the catalogue never passes the gate. Refusing its path while a machine's
// declaration is composed would fail the whole declaration and freeze every module on that machine; the
// node-engine fails only the one resource. So composition leaves it to the engine.
m := Manifest{Module: "docker", Version: "1", Resources: []map[string]any{
{"id": "state", "type": "directory", "place": "."},
}}
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{m}}).Declaration(Rendering{}); err != nil {
t.Fatalf("the machine's declaration failed for one module's path: %v", err)
}
}
+4
View File
@@ -349,6 +349,10 @@ func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, addi
if before[p] {
continue // on the node already; not this assignment's doing
}
if strings.Contains(p, "ADR 0303") {
problems = append(problems, p) // a package declared present and absent says its own rule
continue
}
problems = append(problems, p+" (novox/hq ADR 0210: one owner per node; the other module "+
"depends on the owner's seat instead)")
}
+15 -4
View File
@@ -38,6 +38,17 @@ func settingsUsed(content string) []string {
return keys
}
// UnsetSettingError is a module whose definition says ${setting:<key>} where nothing sets that key: typed, so
// that whoever reads why a module was left out of a machine can tell a setting nobody gave — the operator's
// to give, named with the command that gives it — from any other reason (novox/hq issue 380). Its words are
// the refusal's, unchanged.
type UnsetSettingError struct {
Module, Setting string
said string
}
func (e *UnsetSettingError) Error() string { return e.said }
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
//
// The last layer setting a key wins, which is the node's over the mesh's over the module's own
@@ -58,12 +69,12 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
for _, key := range settingsUsed(content) {
value, set := settingValue(layers, key)
if !set {
return fmt.Errorf(
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+
"preference has a default in the definition (ADR 0262): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))
module, key, key, module, key, orNoSettings(layers))}
}
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
}
@@ -114,11 +125,11 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err
for _, key := range settingsUsed(unit) {
value, set := settingValue(layers, key)
if !set {
return fmt.Errorf(
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
"%s has a service whose unit says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))
module, key, key, module, key, orNoSettings(layers))}
}
v := plainly(value)
if !unitPart.MatchString(v) {
+17 -2
View File
@@ -160,8 +160,14 @@ var ControllerVerbs = []Verb{
Input: schema(map[string]string{"plan": "the walk's id", "why": "what lets it go: its turn, a person's release"},
[]string{"plan"})},
{Name: "delivery-stop", Description: "End a walk on its delivery's word (novox/hq ADR 0239): failed, said as stopped " +
"by whom and why; what it asked still builds and registers, nothing further is asked or sent.",
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery"},
"by whom and why; what it asked still builds and registers, nothing further is asked or sent. Given merges to " +
"leave out, every other merge the walk answered is walked again — in the open batch of its kind, or a new one — " +
"without them, without each later merge of their repository, which contains one, and without each merge moving " +
"a module one moves, each left out too and named (novox/hq ADR 0299); a batch not yet cut drops the merges. The " +
"answer names the batch, the merges walked again, the merges left out, the modules the stopped walk sent that " +
"stay on their machines, and the merges elsewhere that will still deliver a stopped change.",
Input: schema(map[string]string{"plan": "the walk's id", "why": "why", "by": "who stopped the delivery",
"without": "merges to leave out, <repository>@<commit>, comma-separated"},
[]string{"plan", "why"})},
{Name: "delivery-walks", Description: "The walks the controller keeps (novox/hq ADR 0239): every open one and the " +
"last ended ones, each whole — its tiers, each module's state, first machines and first-node gate with its readings, and its " +
@@ -243,6 +249,15 @@ var ControllerVerbs = []Verb{
"secret": "the own secret's name in the module's definition",
"at": "the machine the operator sits at, where the prompt opens",
}, []string{"node", "module", "secret", "at"})},
{Name: "issued", Description: "Take the credential a provider's software issued for a provision — a media " +
"server's sign-in token — and keep it as that provision's one credential for every consumer (novox/hq ADR " +
"0305): the controller asks the provider's own tool on its machine, in the controller's own name, for the " +
"value sealed to a key made for this call alone, seals it to every holder and forgets it, and sends their " +
"machines when it changed. Carries no value and answers none; a value every holder already holds is not " +
"sealed again, so asking again restarts nothing. Refused for a provision no offer says its software issues.",
Input: schema(map[string]string{
"provision": "the provision whose provider's software issues its credential, e.g. plex-api",
}, []string{"provision"})},
{Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " +
"that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " +
"naming the module, the secret and who asked; the operator types the value there, never on a channel " +
+15
View File
@@ -3,6 +3,7 @@ package inventory
import (
"context"
"fmt"
"slices"
"strings"
"github.com/novox/mesh-controller/internal/broker"
@@ -172,6 +173,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
Checks: catalogue.HealthChecks(m),
// And whether it runs as an account of its own (novox/hq ADR 0259 §8).
RunsAs: m.RunsAs,
// And the tools that answer a credential its software issued, which the controller asks in its own
// name (novox/hq ADR 0305).
Issued: issuedTools(m),
}
// Whether it can be given an account at all: delivered as its own secret named broker, so one
// that declares none has nowhere to read it (novox/hq issue 195).
@@ -199,6 +203,17 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
return d
}
// issuedTools are the tools through which a module answers the credentials its software issues (novox/hq ADR 0305).
func issuedTools(m catalogue.Manifest) []string {
var out []string
for _, o := range m.Provides {
if tool, issued := m.IssuedCredentialOf(o.Name); issued && !slices.Contains(out, tool) {
out = append(out, tool)
}
}
return out
}
// bucketsOf is the state a module keeps, as the bus holds it.
func bucketsOf(m catalogue.Manifest) []broker.Bucket {
var out []broker.Bucket
+155
View File
@@ -0,0 +1,155 @@
package inventory
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/secrets"
)
// A credential a provider's software issued, kept as the provision's one credential (novox/hq ADR 0305, issue
// 526).
//
// **The software made it, so the mesh carries it.** A media server's sign-in token comes from its vendor; the mesh
// can neither make nor rotate it. The controller fetches it from the provider's own tool, sealed to a key made for
// that one call, and hands the value here, which seals it to every consumer that holds the provision from that
// provider — in the pair credential's shape, so a consumer's definition does not know where it came from (ADR 0158
// §5) — and to the operator, and forgets it.
//
// **Asked often, written seldom.** The provider asks hourly and on every start. The mesh keeps no plaintext, so it
// cannot compare values; it stamps every row with the value's fingerprint instead, and a value whose stamp every
// row already carries is not sealed again — sealing anew would change every consumer's declaration and restart
// each of them for nothing.
// OriginIssued is a pair credential the provider's software issued and the provider gave (novox/hq ADR 0305).
const OriginIssued = "issued"
// IssuedStamp is the generation a value issued by a provider's software is kept under: a fingerprint of the value,
// so the same value fetched again is known as the same without being kept.
func IssuedStamp(value string) string {
sum := sha256.Sum256([]byte(value))
return "issued:sha256:" + hex.EncodeToString(sum[:8])
}
// IssuedKept is what keeping an issued value did: whether it was sealed anew, the pairs that hold it, and the
// machines a send must reach — the provider's and every holder's — when it was.
type IssuedKept struct {
Changed bool
// Holders are every pair that holds the provision from the provider; Sealed those sealed anew by this act.
Holders []Holder
Sealed []Holder
Machines []string
// Unsealable are the holders whose machine has no sealing key: left as they are, said by name.
Unsealable []Holder
}
// KeepIssuedSecret seals a value a provider's software issued to every pair of provision from provider that does
// not hold it yet, under one stamp; a pair that does is left as it is, and when every pair does nothing is written
// (novox/hq ADR 0305 §4, §5).
func (i *Inventory) KeepIssuedSecret(ctx context.Context, provision, provider, value string) (IssuedKept, error) {
if strings.TrimSpace(value) == "" {
return IssuedKept{}, fmt.Errorf("there is nothing to seal")
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return IssuedKept{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return IssuedKept{}, err
}
if providerKey == "" {
return IssuedKept{}, fmt.Errorf("%s has no sealing key, so %s cannot be sealed to it", provider, provision)
}
stamp := IssuedStamp(value)
type row struct {
holder Holder
consumerID any
generation, heldC, heldP string
consumerKey string
}
rows, err := i.store.Pool().Query(ctx,
`select s.consumer, c.name, s.consumer_module, s.local, coalesce(s.generation, ''),
coalesce(s.consumer_key, ''), coalesce(s.provider_key, ''), coalesce(c.sealing_key, '')
from secret s join node c on c.id = s.consumer
where s.provider = $1 and s.name = $2
order by c.name, s.consumer_module, s.local`, providerNode.ID, provision)
if err != nil {
return IssuedKept{}, err
}
var held []row
for rows.Next() {
var r row
if err := rows.Scan(&r.consumerID, &r.holder.Consumer, &r.holder.ConsumerModule, &r.holder.Local,
&r.generation, &r.heldC, &r.heldP, &r.consumerKey); err != nil {
rows.Close()
return IssuedKept{}, err
}
r.holder.Provision, r.holder.Provider = provision, provider
held = append(held, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return IssuedKept{}, err
}
var kept IssuedKept
var stale []row
for _, r := range held {
if r.consumerKey == "" {
kept.Unsealable = append(kept.Unsealable, r.holder)
continue
}
kept.Holders = append(kept.Holders, r.holder)
// Only a pair that does not hold this value, sealed to the keys its ends have now, is sealed anew: a
// holder that already does is left byte for byte as it is, so it is not restarted for another's sake.
if r.generation != stamp || r.heldC != r.consumerKey || r.heldP != providerKey {
stale = append(stale, r)
}
}
if len(stale) == 0 {
return kept, nil
}
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
if err != nil {
return IssuedKept{}, err
}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return IssuedKept{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
machines := map[string]bool{provider: true}
for _, r := range stale {
sealed, err := secrets.Accept(value, r.consumerKey, providerKey)
if err != nil {
return IssuedKept{}, err
}
if _, err := tx.Exec(ctx,
`update secret set for_consumer = $6, for_provider = $7, consumer_key = $8, provider_key = $9,
operator_sealed = $10, operator_key = $11, origin = $12, generation = $13, created_at = now()
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
provision, r.consumerID, r.holder.ConsumerModule, providerNode.ID, r.holder.Local,
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
forOperator, operatorKey, OriginIssued, stamp); err != nil {
return IssuedKept{}, err
}
machines[r.holder.Consumer] = true
kept.Sealed = append(kept.Sealed, r.holder)
}
if err := tx.Commit(ctx); err != nil {
return IssuedKept{}, err
}
kept.Changed = true
for m := range machines {
kept.Machines = append(kept.Machines, m)
}
sort.Strings(kept.Machines)
return kept, nil
}
+178
View File
@@ -0,0 +1,178 @@
package inventory
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A value a provider's software issued is sealed to every pair that holds the provision from that provider, under
// one stamp of the value, and the same value given again writes nothing and sends nobody (novox/hq ADR 0305 §4,
// §5, issue 526).
func TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
consumerKey, consumerOpens := aSealingKey(t)
cn, _ := inv.NodeByName(ctx, "consumer")
if err := inv.RecordSealingKey(ctx, cn.ID, consumerKey); err != nil {
t.Fatal(err)
}
for _, m := range []string{"kometa", "tautulli"} {
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"plex-api"},
Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil {
t.Fatal(err)
}
// What the mesh made before the provider gave its value: a random value per pair (issue 164).
if _, err := inv.SecretFor(ctx, "plex-api", "consumer", m, "provider", ""); err != nil {
t.Fatal(err)
}
}
kept, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token")
if err != nil {
t.Fatal(err)
}
if !kept.Changed || len(kept.Holders) != 2 {
t.Fatalf("the value was not sealed to both holders: %+v", kept)
}
if len(kept.Machines) != 2 || kept.Machines[0] != "consumer" || kept.Machines[1] != "provider" {
t.Fatalf("the machines to send are the provider's and every holder's: %v", kept.Machines)
}
for _, m := range []string{"kometa", "tautulli"} {
got, err := inv.SecretFor(ctx, "plex-api", "consumer", m, "provider", "")
if err != nil {
t.Fatal(err)
}
if got.Origin != OriginIssued {
t.Fatalf("%s's pair says it came from %q, not the provider's software", m, got.Origin)
}
opened, ok := consumerOpens(got.ForConsumer)
if !ok || string(opened) != "the-servers-own-token" {
t.Fatalf("%s's copy is not the provider's value", m)
}
var generation string
pn, _ := inv.NodeByName(ctx, "provider")
if err := inv.store.Pool().QueryRow(ctx, `select generation from secret where name = 'plex-api' and consumer = $1
and consumer_module = $2 and provider = $3`, cn.ID, m, pn.ID).Scan(&generation); err != nil {
t.Fatal(err)
}
if generation != IssuedStamp("the-servers-own-token") {
t.Fatalf("%s's pair is not stamped with the value's fingerprint: %q", m, generation)
}
}
before, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "")
// A consumer that binds later gets a pair of its own, and no holder's value is remade for it: the offer names
// no own secret, so ADR 0158 §6's remaking on a bind does not reach it (ADR 0305 §7).
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "overseerr", Version: "1", Requires: []string{"plex-api"},
Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil {
t.Fatal(err)
}
late, err := inv.SecretFor(ctx, "plex-api", "consumer", "overseerr", "provider", "")
if err != nil {
t.Fatal(err)
}
if late.Origin == OriginIssued {
t.Fatal("a consumer that bound after the ask holds the issued value without an ask")
}
if held, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); held.ForConsumer != before.ForConsumer {
t.Fatal("a later bind remade a holder's issued value")
}
// The next ask seals the provider's value to the late consumer alone; the holders that had it are not touched.
again, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token")
if err != nil {
t.Fatal(err)
}
if !again.Changed || len(again.Sealed) != 1 || again.Sealed[0].ConsumerModule != "overseerr" {
t.Fatalf("the late consumer was not sealed to, or not alone: %+v", again)
}
if held, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); held.ForConsumer != before.ForConsumer {
t.Fatal("sealing the late consumer changed what a holder already held")
}
if got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "overseerr", "provider", ""); got.Origin != OriginIssued {
t.Fatal("the late consumer does not hold the issued value after the ask")
}
// And once every holder holds it, the same value writes nothing and sends nobody.
still, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token")
if err != nil {
t.Fatal(err)
}
if still.Changed || len(still.Machines) != 0 {
t.Fatalf("the same value was sealed again, which restarts every consumer for nothing: %+v", still)
}
after, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "")
if after.ForConsumer != before.ForConsumer {
t.Fatal("the same value given again changed what the consumer holds")
}
changed, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "a-token-plex-changed")
if err != nil {
t.Fatal(err)
}
if !changed.Changed {
t.Fatal("a changed value was not sealed")
}
got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "tautulli", "provider", "")
if opened, ok := consumerOpens(got.ForConsumer); !ok || string(opened) != "a-token-plex-changed" {
t.Fatal("the changed value did not reach the holder")
}
if _, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", " "); err == nil {
t.Fatal("an empty value was kept")
}
}
// A consumer whose sealing key changed holds a value the mesh made in place of the issued one, and the next ask
// seals the issued value to it again; so does a pair a person accepted over it (novox/hq ADR 0305 §5, the review
// of mesh-controller #239).
func TestAnIssuedValueIsSealedAgainAfterAKeyChangeOrAnAcceptance(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "kometa", Version: "1", Requires: []string{"plex-api"},
Secrets: map[string]string{"plex-api": "/run/plex-api.secret"}}, Source{}); err != nil {
t.Fatal(err)
}
if _, err := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", ""); err != nil {
t.Fatal(err)
}
if kept, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token"); err != nil || !kept.Changed {
t.Fatalf("the first ask did not seal: %+v %v", kept, err)
}
// The consumer rejoins with a new key: the mesh remakes the pair, a value the software refuses.
newKey, opens := aSealingKey(t)
cn, _ := inv.NodeByName(ctx, "consumer")
if err := inv.RecordSealingKey(ctx, cn.ID, newKey); err != nil {
t.Fatal(err)
}
remade, err := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "")
if err != nil {
t.Fatal(err)
}
if remade.Origin != OriginMade {
t.Fatalf("a value the mesh remade says it came from %q", remade.Origin)
}
again, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token")
if err != nil {
t.Fatal(err)
}
if !again.Changed || len(again.Sealed) != 1 {
t.Fatalf("the issued value was not sealed again after the key changed: %+v", again)
}
got, _ := inv.SecretFor(ctx, "plex-api", "consumer", "kometa", "provider", "")
if opened, ok := opens(got.ForConsumer); !ok || string(opened) != "the-servers-own-token" {
t.Fatal("the consumer does not hold the issued value under its new key")
}
// A person accepts another value over it: the next ask seals the software's again.
if err := inv.AcceptSecretForPair(ctx, "plex-api", "consumer", "kometa", "provider", "", "a-typed-copy"); err != nil {
t.Fatal(err)
}
after, err := inv.KeepIssuedSecret(ctx, "plex-api", "provider", "the-servers-own-token")
if err != nil {
t.Fatal(err)
}
if !after.Changed {
t.Fatal("an accepted value over the issued one was read as still holding it")
}
}
+142 -48
View File
@@ -111,6 +111,12 @@ type PlanBatch struct {
// a merge never shares a batch with one that waits for mesh-delivery's (ADR 0276, decided during the
// build, 2026-10-10).
Own bool `json:"own,omitempty"`
// Closed says the window was closed by a stop that walks a stopped walk's merges again in this batch: they
// were heard before, and an older commit never waits behind a newer one (novox/hq ADR 0299). Withheld are
// the modules a stopped merge moves: built at the branch, which holds that merge, they would deliver it, so
// the batch's walk does not build them.
Closed bool `json:"closed,omitempty"`
Withheld []string `json:"withheld,omitempty"`
}
// OwnPath says the record is a batch of merges on the controller's own path.
@@ -187,6 +193,17 @@ type PlanDelivery struct {
// TakenOverBy names the walk a walk folded before it started was taken over by: its merges are that
// walk's now.
TakenOverBy string `json:"taken_over_by,omitempty"`
// WalkedAgainBy names, on a walk stopped without some of its merges, the batch that walks its other merges
// again, and LeftOut the merges it left out (novox/hq issue 459, ADR 0299): a delivery of a merge left out
// ends stopped with the walk, one of a merge walked again follows that batch's walk.
WalkedAgainBy string `json:"walked_again_by,omitempty"`
LeftOut []PlanLeftOut `json:"left_out,omitempty"`
// Kept are the modules the stopped walk already sent and nothing walks again: the machines named keep running
// the stopped walk's build (novox/hq ADR 0299).
Kept []PlanKept `json:"kept,omitempty"`
// Withheld are, on a walk cut from a batch a stop walked merges again in, the modules it did not build: a
// stopped merge moves them, and built only as dependents at the branch they would deliver it (ADR 0299).
Withheld []string `json:"withheld,omitempty"`
// Batch is the window of a batch; nil once it is cut into a walk.
Batch *PlanBatch `json:"batch,omitempty"`
// Alone says the walk walks one merge on its own commit, after a failed walk carried it in a later one:
@@ -194,6 +211,26 @@ type PlanDelivery struct {
Alone bool `json:"alone,omitempty"`
}
// PlanLeftOut is a merge a stop left out of the batch that walks a stopped walk's merges again (novox/hq ADR
// 0299): a merge stopped, or a later merge of its repository's branch, which contains the stopped one and so
// cannot be walked without delivering it.
type PlanLeftOut struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
// Contains is the stopped merge a later merge contains, or whose modules it shares; empty for a merge
// stopped itself.
Contains string `json:"contains,omitempty"`
// Shares are the modules it moves that a stopped merge moves too: built at the branch, which holds the stopped
// merge, they would deliver it.
Shares []string `json:"shares,omitempty"`
}
// PlanKept is a module a stopped walk sent and nothing walks again, and the machines it reached.
type PlanKept struct {
Module string `json:"module"`
Machines []string `json:"machines"`
}
// Waiting is whether the walk waits for its delivery's word.
func (p Plan) Waiting() bool {
return p.Delivery != nil && p.Delivery.Awaits != "" && p.Delivery.Go == nil
@@ -265,7 +302,10 @@ type PlanGate struct {
Sent map[string]SentDeclaration `json:"sent,omitempty"`
// Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that
// does not resets them.
Passes int `json:"passes,omitempty"`
Passes int `json:"passes,omitempty"`
// Wants is how many passes the send needs: a core module's three, a leaf module's one (novox/hq ADR 0282
// decision 4 as ADR 0304 decision 3 changed it). Zero in a gate judged before it was said: three.
Wants int `json:"wants,omitempty"`
LastPass *time.Time `json:"last_pass,omitempty"`
// Last is what the newest judging found wanting, while it still may pass.
Last string `json:"last,omitempty"`
@@ -305,6 +345,18 @@ type PlanGate struct {
// Readings are the judging's readings with their times (novox/hq ADR 0282 decision 6): every reading that
// counted a pass, and the first that did not after one that did. At most maxReadings, the newest kept.
Readings []GateReading `json:"readings,omitempty"`
// Grants is the grants step taken before this gate's send (novox/hq issue 490): the bus's user list sent
// alone to the machine holding the bus, every build there kept, so what the send newly grants is on the
// bus before the gate judges it. Nil when the list did not change, or the gate's machine holds the bus.
Grants *GrantsStep `json:"grants,omitempty"`
}
// GrantsStep is the bus's user list sent to the machine holding the bus ahead of a gated send (novox/hq
// issue 490): to which machine, when, and, when it could not be sent, why — the send went on without it.
type GrantsStep struct {
Node string `json:"node"`
At *time.Time `json:"at,omitempty"`
Failed string `json:"failed,omitempty"`
}
// GateReading is one reading of a first-node gate.
@@ -385,39 +437,28 @@ func (p Plan) Open() bool { return p.State == PlanBuilding || p.State == PlanRol
// with ErrPlanMoved otherwise; and only by a process that may act (ActsUnder), whose epoch it records.
// On success p's revision and epoch are the ones written, so the caller may save it again.
func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
return i.SavePlansMoving(ctx, []*Plan{p}, nil)
}
// MergeMove is a merge given to the plan that answers it now (novox/hq ADR 0276).
type MergeMove struct {
Repository, Commit, Plan string
Alone bool
}
// SavePlansMoving writes plans, as SavePlan does each, and gives merges to the plans that answer them, all in one
// transaction: every write and move, or none (novox/hq ADR 0299 decision 7 — a stop that walks merges again never
// leaves a merge answered by two plans, nor a walk stopped whose merges were not moved). A plan written by
// somebody else since it was read refuses the whole act with ErrPlanMoved. Each plan is said (PlanSaved) once
// the transaction is committed, in order.
func (i *Inventory) SavePlansMoving(ctx context.Context, plans []*Plan, moves []MergeMove) error {
epoch, err := i.actingEpoch(ctx)
if err != nil {
return fmt.Errorf("the plan for %s %s is not written: %w", p.Repository, p.Commit, err)
}
tiers, err := json.Marshal(p.Tiers)
if err != nil {
if len(plans) > 0 {
return fmt.Errorf("the plan for %s %s is not written: %w", plans[0].Repository, plans[0].Commit, err)
}
return err
}
modules, err := json.Marshal(p.Modules)
if err != nil {
return err
}
var release, delivery, commits, times []byte
if p.Times != nil {
if times, err = json.Marshal(p.Times); err != nil {
return err
}
}
if len(p.Commits) > 0 {
if commits, err = json.Marshal(p.Commits); err != nil {
return err
}
}
if p.Release != nil {
if release, err = json.Marshal(p.Release); err != nil {
return err
}
}
if p.Delivery != nil {
if delivery, err = json.Marshal(p.Delivery); err != nil {
return err
}
}
// **And how long the tier it left took** (novox/hq to-be 45 Phase 0): measured here, where the
// plan moves, in the same transaction as the move, so no save can move a tier unmeasured or
// measure one twice.
@@ -426,9 +467,77 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
type written struct {
revision int64
entered time.Time
}
done := make([]written, len(plans))
for n, p := range plans {
revision, entered, err := writePlan(ctx, tx, p, epoch)
if err != nil {
return err
}
done[n] = written{revision, entered}
}
for _, m := range moves {
if _, err := tx.Exec(ctx,
`update batched_merge set plan_id = nullif($3, ''), alone = $4 where repository = lower($1) and commit_hash = $2`,
m.Repository, m.Commit, m.Plan, m.Alone); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return err
}
for n, p := range plans {
p.Revision, p.TierEntered = done[n].revision, done[n].entered
if epoch != nil {
p.Epoch = uint64(*epoch)
} else {
p.Epoch = 0
}
if PlanSaved != nil {
PlanSaved(*p)
}
}
return nil
}
// writePlan writes one plan within a transaction, by compare-and-set on its revision: its new revision, and when
// it entered its tier.
func writePlan(ctx context.Context, tx pgx.Tx, p *Plan, epoch *int64) (int64, time.Time, error) {
tiers, err := json.Marshal(p.Tiers)
if err != nil {
return 0, time.Time{}, err
}
modules, err := json.Marshal(p.Modules)
if err != nil {
return 0, time.Time{}, err
}
var release, delivery, commits, times []byte
if p.Times != nil {
if times, err = json.Marshal(p.Times); err != nil {
return 0, time.Time{}, err
}
}
if len(p.Commits) > 0 {
if commits, err = json.Marshal(p.Commits); err != nil {
return 0, time.Time{}, err
}
}
if p.Release != nil {
if release, err = json.Marshal(p.Release); err != nil {
return 0, time.Time{}, err
}
}
if p.Delivery != nil {
if delivery, err = json.Marshal(p.Delivery); err != nil {
return 0, time.Time{}, err
}
}
entered, err := planTierLeft(ctx, tx, *p, time.Now())
if err != nil {
return err
return 0, time.Time{}, err
}
var revision int64
err = tx.QueryRow(ctx,
@@ -449,24 +558,9 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
// The row is there and at another revision — moved since this was read, or there already
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
// had revisions is at zero, and its first save here is from a read at zero.)
return fmt.Errorf("the plan for %s %s (%s) is not written: %w", p.Repository, short(p.Commit), p.ID, ErrPlanMoved)
return 0, time.Time{}, fmt.Errorf("the plan for %s %s (%s) is not written: %w", p.Repository, short(p.Commit), p.ID, ErrPlanMoved)
}
if err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
p.Revision, p.TierEntered = revision, entered
if epoch != nil {
p.Epoch = uint64(*epoch)
} else {
p.Epoch = 0
}
if PlanSaved != nil {
PlanSaved(*p)
}
return nil
return revision, entered, err
}
// short is a commit as a person reads it.
+6 -3
View File
@@ -58,7 +58,9 @@ const (
// **Remade when either end's sealing key changes.** A node that rejoined generated a new key and
// can no longer open what was sealed to the old one, so keeping the blob would deliver something
// unreadable for ever. The new secret reaches both ends in the same push, which is the only
// moment they can be changed together.
// moment they can be changed together. A remade value is the mesh's, whatever the row held: its
// origin says made and it carries no stamp, so a value a provider's software issued is sealed to it
// again at the provider's next ask (novox/hq ADR 0305 §5) rather than read as still held.
func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModule, provider, local string) (
Secret, error) {
consumerKey, err := i.SealingKeyOf(ctx, consumer)
@@ -121,7 +123,8 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
created_at = now(),
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key,
origin = 'made', generation = null`,
name, consumerNode.ID, consumerModule, providerNode.ID,
made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey, local)
if err != nil {
@@ -202,7 +205,7 @@ func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, con
on conflict (name, local, consumer, consumer_module, provider) do update set
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
created_at = now(), origin = excluded.origin,
created_at = now(), origin = excluded.origin, generation = null,
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
name, consumerNode.ID, consumerModule, providerNode.ID,
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
+62 -9
View File
@@ -100,6 +100,23 @@ type Send struct {
CounterRaisedTo int64
// Recorded is false for a refusal of a sequence the mesh has no send for: its sender is unknown.
Recorded bool
// Answer is what the machine last reported of this send; filled only where a last send is read back
// (LastSend, LastSends), the zero answer elsewhere.
Answer SendAnswer
}
// SendAnswer is what a machine reported of one send, read from its last report when that report is about the
// send's declaration (novox/hq issue 485): whether it applied it, failed part of it or refused it, and why.
// The zero answer is a machine that has not reported on this send — yet, or since a later report replaced it.
type SendAnswer struct {
// Outcome is applied, failed or refused, as the machine reported it; empty when it has not reported on it.
Outcome string
// Refused is the machine's own words when it refused the declaration whole.
Refused string
// Failed is how many of its resources failed, when some did.
Failed int
// At is when the machine reported it; nil when it has not.
At *time.Time
}
// unknownSender is the sender of a refused sequence the mesh has no record of sending.
@@ -209,20 +226,56 @@ func scanSends(rows pgx.Rows) ([]Send, error) {
return out, rows.Err()
}
// LastSend is the last declaration a machine was sent, by its name; false when none was recorded. A refusal
// of a sequence the mesh has no send for is not a send, and is not it.
// LastSend is the last declaration a machine was sent, by its name, with what the machine answered of it; false
// when none was recorded. A refusal of a sequence the mesh has no send for is not a send, and is not it.
func (i *Inventory) LastSend(ctx context.Context, name string) (Send, bool, error) {
rows, err := i.store.Pool().Query(ctx, `select `+sendColumns+`
from declaration_send s join node n on n.id = s.node
where n.name = $1 and s.recorded order by s.id desc limit 1`, name)
sends, err := i.lastSends(ctx, `n.name = $1`, name)
if err != nil {
return Send{}, false, err
}
sends, err := scanSends(rows)
if err != nil || len(sends) == 0 {
return Send{}, false, err
s, found := sends[name]
return s, found, nil
}
// LastSends is every machine's last send, by machine name, each with what the machine answered of it: what
// `status` says per machine (novox/hq issue 485). A machine never sent anything has no entry.
func (i *Inventory) LastSends(ctx context.Context) (map[string]Send, error) {
return i.lastSends(ctx, `true`)
}
// lastSends reads the newest recorded send of each machine matching where, beside the machine's last report
// when that report is about the send (novox/hq issue 485). It names the send's declaration (its digest), and:
// - from an ordered node-engine, one that reports the epoch and sequence it acted on, those are the send's
// own. A periodic report about an identical earlier send names the same digest and may arrive after a later
// send the machine never received; only the sequence tells them apart. It also holds for a fast machine
// whose report lands before the send's row is stamped.
// - from an engine that reports no order, the report came after the send — both times the store's own — so
// a machine sent again a declaration it once applied does not read as having applied the new send.
func (i *Inventory) lastSends(ctx context.Context, where string, args ...any) (map[string]Send, error) {
rows, err := i.store.Pool().Query(ctx, `select distinct on (n.name) `+sendColumns+`,
coalesce(r.outcome, ''), coalesce(r.refused, ''), case when jsonb_typeof(r.failed) = 'array' then jsonb_array_length(r.failed) else 0 end, r.at
from declaration_send s join node n on n.id = s.node
left join node_report r on r.node = s.node and r.declared <> '' and r.declared = s.digest
and case when r.reported_sequence is not null
then r.reported_sequence = s.sequence and coalesce(r.reported_epoch, 0) = coalesce(s.epoch, 0)
else r.at >= s.sent_at end
where s.recorded and `+where+`
order by n.name, s.id desc`, args...)
if err != nil {
return nil, err
}
return sends[0], true, nil
defer rows.Close()
out := map[string]Send{}
for rows.Next() {
var s Send
if err := rows.Scan(&s.Node, &s.NodeName, &s.Sequence, &s.Epoch, &s.Sender, &s.Generation, &s.Digest,
&s.Modules, &s.SentAt, &s.RefusedAt, &s.RefusedApplied, &s.CounterRaisedTo, &s.Recorded,
&s.Answer.Outcome, &s.Answer.Refused, &s.Answer.Failed, &s.Answer.At); err != nil {
return nil, err
}
out[s.NodeName] = s
}
return out, rows.Err()
}
// RefusedSend keeps a machine's refusal of the send of a sequence for the generation it came from, and
+141
View File
@@ -205,3 +205,144 @@ func TestWhetherAMachineReadsAGenerationIsItsLatestWord(t *testing.T) {
}
}
}
// **The last send says what the machine answered of it** (novox/hq issue 485): applied, refused with its
// words, or not answered yet — read from the machine's report only when that report is about the send's
// declaration and came after it, so a report about an earlier send is never this one's answer. And every
// machine's last send is read at once, for status, by name; a machine never sent anything has none.
func TestTheLastSendSaysWhatTheMachineAnswered(t *testing.T) {
inv, node := aNodeWithModules(t)
ctx := t.Context()
record, err := inv.NodeByName(ctx, node)
if err != nil {
t.Fatal(err)
}
if _, err := inv.AddNode(ctx, "laptop"); err != nil {
t.Fatal(err)
}
send := func(sequence int64, digest string) {
t.Helper()
if err := inv.RecordSentWith(ctx, record.ID, digest, nil, 57, 40, Send{Sequence: sequence, Epoch: 57,
Sender: "the controller's daemon (pid 7 on anchor)", Generation: 40, Digest: digest,
Modules: []string{"docker", "sudo"}}); err != nil {
t.Fatal(err)
}
}
report := func(d Doing) {
t.Helper()
if _, err := inv.RecordDoing(ctx, record.ID, d); err != nil {
t.Fatal(err)
}
}
answer := func() SendAnswer {
t.Helper()
last, found, err := inv.LastSend(ctx, node)
if err != nil || !found {
t.Fatalf("the last send is not read back: %v, %v", found, err)
}
return last.Answer
}
send(11, "d11")
if a := answer(); a.Outcome != "" || a.At != nil {
t.Fatalf("a send not reported on reads as answered: %+v", a)
}
report(Doing{Outcome: OutcomeApplied, Declared: "d11", Applied: 3})
if a := answer(); a.Outcome != OutcomeApplied || a.At == nil {
t.Fatalf("a send the machine reported applying reads %+v", a)
}
send(12, "d12")
if a := answer(); a.Outcome != "" {
t.Fatalf("the report about the send before is taken for this one's answer: %+v", a)
}
report(Doing{Outcome: OutcomeRefused, Declared: "d12", Refused: "unknown field \"generation\"\nand more"})
if a := answer(); a.Outcome != OutcomeRefused || a.Refused != "unknown field \"generation\"\nand more" {
t.Fatalf("a send the machine refused reads %+v", a)
}
// Sent again a declaration it reported applying before the send: not answered until it reports again.
report(Doing{Outcome: OutcomeApplied, Declared: "d12", Applied: 3})
send(13, "d12")
if a := answer(); a.Outcome != "" {
t.Fatalf("a report from before the send is taken for its answer: %+v", a)
}
all, err := inv.LastSends(ctx)
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || all[node].Sequence != 13 || all[node].Epoch != 57 || all[node].Generation != 40 ||
!slices.Equal(all[node].Modules, []string{"docker", "sudo"}) {
t.Fatalf("every machine's last send reads %+v", all)
}
if _, found := all["laptop"]; found {
t.Error("a machine never sent anything has a last send")
}
}
// **A report about another declaration, or another send of the same one, is not the last send's answer**
// (novox/hq issue 485, review of mesh-controller #234). A report naming an earlier send's digest that arrives
// after a later send is not the later send's answer. And from an ordered node-engine, a periodic report about
// an identical earlier send — the same digest, arriving after the later send — is not its answer either: only
// the report's epoch and sequence say which send it is about.
func TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend(t *testing.T) {
inv, node := aNodeWithModules(t)
ctx := t.Context()
record, err := inv.NodeByName(ctx, node)
if err != nil {
t.Fatal(err)
}
send := func(sequence int64, digest string) {
t.Helper()
if err := inv.RecordSentWith(ctx, record.ID, digest, nil, 57, 40, Send{Sequence: sequence, Epoch: 57,
Sender: "the controller's daemon (pid 7 on anchor)", Generation: 40, Digest: digest}); err != nil {
t.Fatal(err)
}
}
ordered := func(epoch, sequence int64, digest string) {
t.Helper()
if _, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: digest, Applied: 1},
ReportOrder{Epoch: epoch, Sequence: sequence}, func(ReportOrder) bool { return false }); err != nil {
t.Fatal(err)
}
}
answer := func() string {
t.Helper()
last, found, err := inv.LastSend(ctx, node)
if err != nil || !found {
t.Fatalf("the last send is not read back: %v, %v", found, err)
}
return last.Answer.Outcome
}
// An engine that reports no order: the digest decides.
send(11, "d11")
send(12, "d12")
if _, err := inv.RecordDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: "d11"}); err != nil {
t.Fatal(err)
}
if a := answer(); a != "" {
t.Fatalf("a report about d11, after d12 was sent, reads as d12's answer: %q", a)
}
// An ordered engine: the epoch and sequence decide, whatever the digest and the time.
send(20, "d20")
ordered(57, 20, "d20")
if a := answer(); a != OutcomeApplied {
t.Fatalf("an ordered report about sequence 20 is not its answer: %q", a)
}
send(21, "d20")
ordered(57, 20, "d20")
if a := answer(); a != "" {
t.Fatalf("a periodic report about sequence 20, after 21 was sent with the same digest, reads as 21's answer: %q", a)
}
ordered(56, 21, "d20")
if a := answer(); a != "" {
t.Fatalf("a report about sequence 21 of another epoch reads as this send's answer: %q", a)
}
ordered(57, 21, "d20")
if a := answer(); a != OutcomeApplied {
t.Fatalf("an ordered report about sequence 21 is not its answer: %q", a)
}
}
+17 -8
View File
@@ -123,15 +123,24 @@ func TestNatsABuildIsTakenAndItsOutcomeReachesEverybody(t *testing.T) {
t.Fatalf("line %d came back as %s (%v)", want, msg.Data, err)
}
}
// And it is in the stream for a reader who comes later.
info, err := js.Context().StreamInfo(broker.EventsStream, &nats.StreamInfoRequest{SubjectsFilter: BuildLog("b-1")})
if err != nil {
t.Fatal(err)
// And it is in the stream for a reader who comes later. **Waited for, with a bound, rather
// than read once** (novox/hq issue 507): a line is said with a plain publish, so the server
// hands it to a live subscriber and stores it in the stream independently, and under load the
// subscriber above had both lines while the stream still held one, or none.
var held map[string]uint64
for until := time.Now().Add(5 * time.Second); ; {
info, err := js.Context().StreamInfo(broker.EventsStream, &nats.StreamInfoRequest{SubjectsFilter: BuildLog("b-1")})
if err != nil {
t.Fatal(err)
}
held = info.State.Subjects
if held[BuildLog("b-1")] >= 2 || time.Now().After(until) {
break
}
time.Sleep(10 * time.Millisecond)
}
if info.State.Subjects[BuildLog("b-1")] != 2 {
t.Fatalf("the stream holds %v under the build's subject, want 2", info.State.Subjects)
}
if err == nil {
if held[BuildLog("b-1")] != 2 {
t.Fatalf("the stream holds %v under the build's subject, want 2", held)
}
if result.ID != "b-1" || result.Commit != "abc1234" {
t.Fatalf("the asker got %+v", result)
+19 -1
View File
@@ -173,6 +173,25 @@ func ModuleToolOn(module, tool, node string) string { return ToolSubject(module,
// AskModuleToolOn asks one machine's instance of a module one of its tools and reads its answer.
func AskModuleToolOn(ctx context.Context, conn *nats.Conn, module, tool, node string, args any,
timeout time.Duration) (Answer, error) {
return askModuleToolAt(ctx, conn, ModuleToolOn(module, tool, node), module, tool, node, args, timeout)
}
// AskModuleToolOnAs is AskModuleToolOn in the caller's own name: on the subject that names its caller (novox/hq
// issue 365), which the bus lets only that user publish, so the tool knows who asks from the subject alone. The
// one way a tool can answer the controller and nobody else (novox/hq ADR 0305 §2).
func AskModuleToolOnAs(ctx context.Context, conn *nats.Conn, caller, module, tool, node string, args any,
timeout time.Duration) (Answer, error) {
subject := broker.CalledSubject(ModuleToolOn(module, tool, node), caller)
if subject == "" {
return Answer{}, fmt.Errorf("%q cannot ask %s.%s on %s in its own name: it is not a bus user's name", caller,
module, tool, node)
}
return askModuleToolAt(ctx, conn, subject, module, tool, node, args, timeout)
}
// askModuleToolAt asks one machine's instance of a module one of its tools on subject and reads its answer.
func askModuleToolAt(ctx context.Context, conn *nats.Conn, subject, module, tool, node string, args any,
timeout time.Duration) (Answer, error) {
body, err := json.Marshal(args)
if err != nil {
@@ -180,7 +199,6 @@ func AskModuleToolOn(ctx context.Context, conn *nats.Conn, module, tool, node st
}
asking, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
subject := ModuleToolOn(module, tool, node)
refused, stop := refusalsOf(conn, subject)
defer stop()
type replied struct {