Write each module's have and the node's own facts beside the report, grouped per module and keyed by the module the controller names (issue 1132)

The node-engine reads a resource's module and a declaration's builds, says it does in every report (reads_modules), groups the apply's outcome per module with every failing resource named, and writes have.<node>.<module> and have.<node>.node to the bucket only when a value differs, never ahead of or in place of the report (novox/hq ADR 0429 section 4).
This commit is contained in:
2026-10-18 19:10:05 +02:00
parent 6469e82384
commit 0b391f49a2
19 changed files with 1193 additions and 45 deletions
+126 -16
View File
@@ -103,6 +103,8 @@ type Resource interface {
// and not a hash of the content: it is what lets the store say *this is the same resource
// I applied last time*, which is what makes removal possible at all.
Identity() string
// DeclaredModule is the module the controller says the resource belongs to, or "" (issue 1132).
DeclaredModule() string
// Kind is the resource's type, for the store and for reporting.
Kind() Type
// Target is what the resource acts on, for a person reading a report.
@@ -115,12 +117,30 @@ type Resource interface {
// strict decoder would otherwise refuse. `Kind()` returns the constant and is what anything
// else should read.
// DeclaredModule is on every kind: the module the controller says the resource belongs to, or "" when it does not say
// (an older controller). Read through Declaration.ModuleOfID, which falls back to the guess.
func (d *Directory) DeclaredModule() string { return d.Module }
func (f *File) DeclaredModule() string { return f.Module }
func (u *User) DeclaredModule() string { return u.Module }
func (n *Network) DeclaredModule() string { return n.Module }
func (a *Access) DeclaredModule() string { return a.Module }
func (a *Archive) DeclaredModule() string { return a.Module }
func (d *Process) DeclaredModule() string { return d.Module }
func (s *Service) DeclaredModule() string { return s.Module }
func (o *Opening) DeclaredModule() string { return o.Module }
func (p *Package) DeclaredModule() string { return p.Module }
func (c *Container) DeclaredModule() string { return c.Module }
func (a *Action) DeclaredModule() string { return a.Module }
// Directory is a directory that should exist, with a mode.
type Directory struct {
ID string `json:"id"`
Type Type `json:"type"`
Path string `json:"path"`
Mode string `json:"mode,omitempty"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Path string `json:"path"`
Mode string `json:"mode,omitempty"`
// Owner is the user this belongs to, by name. Absent means root, which is what everything
// managed was until users existed.
Owner string `json:"owner,omitempty"`
@@ -201,8 +221,11 @@ func (d *Directory) validate(where string, _ bool) []string {
// File is a file with literal content. The host renders nothing.
type File struct {
ID string `json:"id"`
Type Type `json:"type"`
ID string `json:"id"`
Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Path string `json:"path"`
Content string `json:"content"`
Mode string `json:"mode,omitempty"`
@@ -520,7 +543,10 @@ func (f *File) checkOwnership(where string) []string {
type User struct {
ID string `json:"id"`
Type Type `json:"type"`
Name string `json:"name"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Name string `json:"name"`
// Shell this user logs in with. Absent means the host asserts nothing and leaves whatever is
// there — the same rule Service.Boot follows, for the same reason: a field that always
@@ -571,7 +597,10 @@ const RootNever = "never"
type Network struct {
ID string `json:"id"`
Type Type `json:"type"`
Name string `json:"name"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Name string `json:"name"`
}
func (n *Network) Identity() string { return n.ID }
@@ -616,7 +645,10 @@ const (
type Access struct {
ID string `json:"id"`
Type Type `json:"type"`
Path string `json:"path"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Path string `json:"path"`
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
Mode string `json:"mode,omitempty"`
@@ -743,6 +775,9 @@ func (u *User) validate(where string, _ bool) []string {
type Archive struct {
ID string `json:"id"`
Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
// Source is where to fetch it from.
Source string `json:"source"`
// Digest is sha256 of the archive, as "sha256:<hex>".
@@ -796,6 +831,9 @@ func (a *Archive) validate(where string, _ bool) []string {
type Process struct {
ID string `json:"id"`
Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
// Name is what the unit is called, and what an operator will see in the process table.
Name string `json:"name"`
// Source is where to fetch the bundle from, and Digest is what it must hash to. The same
@@ -985,7 +1023,10 @@ func (d *Process) validate(where string, _ bool) []string {
type Service struct {
ID string `json:"id"`
Type Type `json:"type"`
Unit string `json:"unit"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Unit string `json:"unit"`
// State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the
// machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules
// declare the machine's own network manager this way: the mesh writes into its configuration
@@ -1157,8 +1198,11 @@ func BlockMarkers(id string) (begin, end string) {
// firewall sees a published port after the runtime has translated it, so a forwarded opening names
// the container's own port in To as well as the machine's in Port.
type Opening struct {
ID string `json:"id"`
Type Type `json:"type"`
ID string `json:"id"`
Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Port int `json:"port"`
Protocol string `json:"protocol"`
From string `json:"from"`
@@ -1223,8 +1267,11 @@ func (o *Opening) validate(where string, _ bool) []string {
// Present is the whole of what it asserts, never a version: version is the package manager's
// business and the mesh does not hold a second opinion about it.
type Package struct {
ID string `json:"id"`
Type Type `json:"type"`
ID string `json:"id"`
Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Package string `json:"package"`
// Absent declares that the package is NOT installed (novox/hq ADR 0180): the host removes it
// when it is, and leaves a machine that never had it alone. For the one case a module replaces
@@ -1249,7 +1296,10 @@ func (p *Package) validate(where string, _ bool) []string {
type Container struct {
ID string `json:"id"`
Type Type `json:"type"`
Name string `json:"name"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Name string `json:"name"`
// Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not.
Image string `json:"image"`
Env map[string]string `json:"env,omitempty"`
@@ -1555,8 +1605,11 @@ func (c *Container) validate(where string, _ bool) []string {
// Action runs something the bundle declared, and the host never learns what it means.
type Action struct {
ID string `json:"id"`
Type Type `json:"type"`
ID string `json:"id"`
Type Type `json:"type"`
// Module is the module this resource belongs to, as the controller says it (novox/hq ADR 0429 §4, issue 1132). Absent
// is an older controller, and the module is then guessed from the id (ModuleGuess).
Module string `json:"module,omitempty"`
Command []string `json:"command"`
// Verify is not optional and is not a courtesy. It is the read-back AND the idempotency
// check: the host does not know what a database is, so "is it already there" is a question
@@ -1727,6 +1780,58 @@ type Declaration struct {
// the only account whose write of a filled file the node-engine's local write socket accepts. Absent is a
// controller that names none, and every such write is refused.
Provisioner string
// Builds is the build id of every module this declaration names, by module (novox/hq ADR 0429 §4, issue 1132).
Builds map[string]string
// declared is what each resource says of its module, by id; nil on a declaration built by hand.
declared map[string]string
}
// ModuleGuess is the module a resource id names when the declaration does not say: everything before its last dot.
//
// The mesh composes a module's resource ids as `<module>.<its own id>`, and **a module's name may contain a dot**
// (`novox.be` is one on this mesh) while a resource's own id never does, so the owner is everything before the *last*
// dot. False for what the mesh declares in its own right: the foundation's resources carry no dot, and the
// adoption's are named for the mesh. This is the guess an older controller's declaration is read by; a declaration
// that says `module` is read by ModuleOfID, which never guesses for a resource that says (issue 1132).
func ModuleGuess(id string) (string, bool) {
if strings.HasPrefix(id, AdoptionPrefix) {
return "", false
}
at := strings.LastIndex(id, ".")
if at <= 0 {
return "", false
}
return id[:at], true
}
// ModuleOfID is the module a resource of this declaration belongs to: the module it says (novox/hq ADR 0429 §4,
// issue 1132), and otherwise the guess from its id (ModuleGuess), so a declaration from an older controller reads as
// it always did. False for what belongs to no module.
func (d *Declaration) ModuleOfID(id string) (string, bool) {
if d != nil {
if d.declared == nil {
for _, r := range d.Resources {
if r.Identity() == id && r.DeclaredModule() != "" {
return r.DeclaredModule(), true
}
}
} else if m, ok := d.declared[id]; ok {
return m, true
}
}
return ModuleGuess(id)
}
// indexModules keeps what each resource says of its module, so ModuleOfID is a lookup.
func (d *Declaration) indexModules() {
d.declared = map[string]string{}
for _, r := range d.Resources {
if m := r.DeclaredModule(); m != "" {
d.declared[r.Identity()] = m
}
}
}
// checkProvisioner refuses a provisioner client's account that is no account name, or is root: the account whose
@@ -2126,6 +2231,10 @@ type envelope struct {
// Provisioner is optional too (novox/hq ADR 0349 §4). **An older host refuses this key**; a controller sends it
// only to a host whose health says link.ProvisionerContract.
Provisioner string `json:"provisioner_account,omitempty"`
// Builds is optional too (novox/hq ADR 0429 §4, issue 1132): the build id of each module the declaration names, and
// every resource's `module`. **An older host refuses these keys**; a controller sends them only to a host whose
// reports say `reads_modules`.
Builds map[string]string `json:"builds,omitempty"`
}
func parse(raw []byte, allowActions bool) (*Declaration, error) {
@@ -2145,7 +2254,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries, Filled: env.Filled,
Provisioner: env.Provisioner}
Provisioner: env.Provisioner, Builds: env.Builds}
problems := checkIdentities(env.Identities, env.Groups)
problems = append(problems, checkProvisioner(env.Provisioner)...)
problems = append(problems, checkLibraries(env.Groups, env.Libraries)...)
@@ -2264,6 +2373,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
if len(problems) > 0 {
return nil, &RefusalError{Problems: problems}
}
d.indexModules()
return d, nil
}
+60
View File
@@ -0,0 +1,60 @@
package declaration
import "testing"
// 1132/AC1: a resource says its module, and the declaration says each module's build; a resource that does
// not say gets today's guess, so an older controller still works.
func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,
"builds":{"novox.be":"b-9","web":"b-4"},
"resources":[
{"id":"novox.be.data","type":"directory","path":"/srv/a","module":"novox.be"},
{"id":"web.conf","type":"file","path":"/etc/w","content":"x","module":"web"},
{"id":"old.dir","type":"directory","path":"/srv/old"},
{"id":"base","type":"directory","path":"/srv/base"}]}`))
if err != nil {
t.Fatal(err)
}
for id, want := range map[string]string{"novox.be.data": "novox.be", "web.conf": "web", "old.dir": "old"} {
got, ok := d.ModuleOfID(id)
if !ok || got != want {
t.Errorf("%s belongs to %q (%v), want %q", id, got, ok, want)
}
}
if m, ok := d.ModuleOfID("base"); ok {
t.Errorf("a resource with no module and no dot belongs to %q, want none", m)
}
if d.Builds["web"] != "b-4" || d.Builds["novox.be"] != "b-9" || len(d.Builds) != 2 {
t.Errorf("builds read as %v", d.Builds)
}
}
// 1132/AC1: the module the declaration gives wins over the guess, even when the id would guess another.
func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
d, err := Parse([]byte(`{"declaration":1,"resources":[
{"id":"a.b.c","type":"directory","path":"/srv/a","module":"a.b"},
{"id":"x.y","type":"directory","path":"/srv/x","module":"other"}]}`))
if err != nil {
t.Fatal(err)
}
if m, _ := d.ModuleOfID("x.y"); m != "other" {
t.Errorf("x.y belongs to %q, want the declared %q", m, "other")
}
}
// 1132/AC1: every kind accepts the field.
func TestEveryKindAcceptsAModule(t *testing.T) {
for _, typ := range []Type{TypeDirectory, TypeFile, TypeUser, TypeNetwork, TypeAccess, TypeArchive, TypeProcess,
TypeService, TypeOpening, TypePackage, TypeContainer, TypeAction} {
r := newOf(typ)
if r == nil {
t.Fatalf("no kind %s", typ)
}
if _, ok := r.(interface{ DeclaredModule() string }); !ok {
t.Errorf("%s does not say its module", typ)
}
if unknown := unknownFields([]byte(`{"id":"m.x","type":"`+string(typ)+`","module":"m"}`), r); len(unknown) != 0 {
t.Errorf("%s refuses %v", typ, unknown)
}
}
}