diff --git a/cmd/mesh-controller/modules_wire_test.go b/cmd/mesh-controller/modules_wire_test.go new file mode 100644 index 00000000..518ab17f --- /dev/null +++ b/cmd/mesh-controller/modules_wire_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/novox/mesh-controller/internal/chain" + "github.com/novox/mesh-host/validate" +) + +// The module of each resource and the build of each module (novox/hq issue 1145, design 47 release 1-2). The +// composer's owner of an id and the module's build go on the wire only to a machine whose node-engine said it reads +// them: an older one decodes strictly and refuses a key it does not know, whole. + +func modulesDeclaration(reads bool) sendable { + return sendable{ + Resources: []map[string]any{ + {"id": "web.app.conf", "type": "file", "path": "/etc/app.conf", "content": "x\n"}, + {"id": "web.app.data", "type": "file", "path": "/etc/data", "content": "d\n"}, + {"id": "mesh.guard", "type": "file", "path": "/etc/guard", "content": "g\n"}}, + moduleOf: map[string]string{"web.app.conf": "web.app", "web.app.data": "web.app"}, + buildIDs: map[string]string{"web.app": "build-7"}, + ReadsModules: reads, + } +} + +// 1145/AC1 +func TestAMachineThatReadsModulesIsSentEachResourcesModuleAndEachModulesBuild(t *testing.T) { + keys := bodyKeys(t, modulesDeclaration(true)) + builds, _ := keys["builds"].(map[string]any) + if len(builds) != 1 || builds["web.app"] != "build-7" { + t.Fatalf("1145/AC1: the body carries builds %v", keys["builds"]) + } + want := map[string]any{"web.app.conf": "web.app", "web.app.data": "web.app", "mesh.guard": nil} + for _, r := range keys["resources"].([]any) { + r := r.(map[string]any) + if r["module"] != want[r["id"].(string)] { + t.Errorf("1145/AC1: resource %v carries module %v, want %v", r["id"], r["module"], want[r["id"].(string)]) + } + } +} + +// 1145/AC2: the body is byte for byte what it is today. +func TestAMachineThatDoesNotReadModulesIsSentTheBodyItWasSentBefore(t *testing.T) { + d := modulesDeclaration(false) + got, err := d.Body() + if err != nil { + t.Fatal(err) + } + before := sendable{Resources: d.Resources} + want, _ := before.Body() + if string(got) != string(want) { + t.Fatalf("1145/AC2: the body changed:\n%s\nwas\n%s", got, want) + } +} + +// 1145/AC4: the node-engine's own validator, as the gate runs it, takes the two words. +func TestTheNodeEnginesValidatorTakesAModulePerResourceAndABuildPerModule(t *testing.T) { + raw, err := modulesDeclaration(true).Body() + if err != nil { + t.Fatal(err) + } + var generic map[string]any + if err := json.Unmarshal(raw, &generic); err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(raw); len(problems) != 0 { + t.Fatalf("1145/AC4: refused: %q\n%s", problems, fmt.Sprint(generic)) + } +} + +// The build a module carries is the one its assignment names when that is the commit carried, else the one built from +// the commit carried; none when neither is known. +func TestTheBuildOfACarriedCommitIsTheAssignmentsOrTheOneBuiltFromIt(t *testing.T) { + runs := map[string]chain.Ref{"a": {Build: "b-a", Commit: "c1"}, "b": {Build: "b-old", Commit: "c0"}} + looked := map[string]string{"b/c2": "b-new"} + got, err := wireBuilds(map[string]string{"a": "c1", "b": "c2", "c": "c3", "d": ""}, runs, + func(module, commit string) (string, error) { return looked[module+"/"+commit], nil }) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"a": "b-a", "b": "b-new"} + if len(got) != len(want) || got["a"] != want["a"] || got["b"] != want["b"] { + t.Fatalf("1145: builds %v, want %v", got, want) + } +} diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 82b58544..a0efccf0 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -16,6 +16,7 @@ import ( "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/chain" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/licences" "github.com/novox/mesh-controller/internal/link" @@ -500,33 +501,75 @@ func declarationWith(ctx context.Context, open *stores, node string, // And which build of each module it carries, for the send to record (novox/hq issue 259, ADR // 0221). Read only on the send path: a question about what would be sent records nothing. if choosing == Allocating { - current, err := open.inventory.CurrentBuilds(ctx) + carried, err := carriedOn(ctx, open, node, plan, composed.LeftOut, true) if err != nil { return sendable{}, err } - before, known, err := open.inventory.SentBuilds(ctx, node) - if err != nil { - return sendable{}, err - } - if !known { - before = nil - } - names := make([]string, 0, len(plan.Modules)) - for _, m := range plan.Modules { - names = append(names, m.Module) - } - out.Builds = carriedBuilds(names, composed.LeftOut, current, before) - // A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245). - kept, err := recordedKept(ctx, open, node) - if err != nil { - return sendable{}, err - } - for m, was := range kept { - if _, carried := out.Builds[m]; carried { - out.Builds[m] = was + out.Builds = carried + out.Bindings = boundToData(plan, composed.LeftOut) + } + // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them + // (novox/hq issue 1145) and to no other; the same whatever the composition is for, so what a question about the + // send digests is what the send digests. + reads, err := open.inventory.ReadsModules(ctx, record.ID) + if err != nil { + return sendable{}, err + } + if reads { + carried := out.Builds + if choosing != Allocating { + if carried, err = carriedOn(ctx, open, node, plan, composed.LeftOut, false); err != nil { + return sendable{}, err } } - out.Bindings = boundToData(plan, composed.LeftOut) + runs, err := open.inventory.AssignmentRuns(ctx, node) + if err != nil { + return sendable{}, err + } + builds, err := wireBuilds(carried, runs, func(module, commit string) (string, error) { + return open.inventory.BuildOf(ctx, module, commit) + }) + if err != nil { + return sendable{}, err + } + out.ReadsModules, out.moduleOf, out.buildIDs = true, composed.Owner, builds + } + return out, nil +} + +// carriedOn is the build of each module a declaration for node carries (see carriedBuilds), the module kept at the +// build a machine runs included (ADR 0245). Only a send (strict) fails for a kept module that cannot be read: a question +// about it is answered without. +func carriedOn(ctx context.Context, open *stores, node string, plan catalogue.Resolution, leftOut map[string]string, + strict bool) (map[string]string, error) { + current, err := open.inventory.CurrentBuilds(ctx) + if err != nil { + return nil, err + } + before, known, err := open.inventory.SentBuilds(ctx, node) + if err != nil { + return nil, err + } + if !known { + before = nil + } + names := make([]string, 0, len(plan.Modules)) + for _, m := range plan.Modules { + names = append(names, m.Module) + } + out := carriedBuilds(names, leftOut, current, before) + // A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245). + kept, err := recordedKept(ctx, open, node) + if err != nil { + if strict { + return nil, err + } + kept = nil + } + for m, was := range kept { + if _, carried := out[m]; carried { + out[m] = was + } } return out, nil } @@ -2109,3 +2152,27 @@ func sortedNames[V any](m map[string]V) []string { sort.Strings(out) return out } + +// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment +// names when that assignment is of the commit carried, else the build made from that commit; a module with neither +// is not named. +func wireBuilds(carried map[string]string, runs map[string]chain.Ref, lookup func(module, commit string) (string, error)) (map[string]string, error) { + out := map[string]string{} + for module, commit := range carried { + if commit == "" { + continue + } + if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" { + out[module] = ref.Build + continue + } + build, err := lookup(module, commit) + if err != nil { + return nil, err + } + if build != "" { + out[module] = build + } + } + return out, nil +} diff --git a/cmd/mesh-controller/sendable.go b/cmd/mesh-controller/sendable.go index 264a46c2..6bea488a 100644 --- a/cmd/mesh-controller/sendable.go +++ b/cmd/mesh-controller/sendable.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "fmt" "sort" "strings" @@ -38,6 +39,13 @@ type sendable struct { // the send; never on the wire. composedFrom int64 actingEpoch uint64 + // ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue + // 1145): only then are they sent, because an older node-engine refuses a key it does not know, whole. + ReadsModules bool + // moduleOf is the composer's owner of each resource id, and buildIDs the build each module carries (its id): + // kept beside the resources, and on the wire only to a machine that reads them. + moduleOf map[string]string + buildIDs map[string]string // modules are the modules this declaration names, for the record of the send; never on the wire. modules []string // localNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR @@ -111,6 +119,28 @@ type adoptionEnvelope struct { // Body is the declaration's bytes, as sent and as digested. func (s sendable) Body() ([]byte, error) { envelope := map[string]any{"declaration": 1, "resources": s.Resources} + if s.ReadsModules { + // The module of each resource the composer owns (what the mesh adds of its own has none), and the build of each + // module (novox/hq issue 1145): to a machine that said it reads them, and no other. + named := make([]map[string]any, len(s.Resources)) + for n, r := range s.Resources { + module, owned := s.moduleOf[fmt.Sprint(r["id"])] + if !owned { + named[n] = r + continue + } + with := make(map[string]any, len(r)+1) + for k, v := range r { + with[k] = v + } + with["module"] = module + named[n] = with + } + envelope["resources"] = named + if len(s.buildIDs) > 0 { + envelope["builds"] = s.buildIDs + } + } if s.Adoption != nil { envelope["adoption"] = s.Adoption } diff --git a/go.mod b/go.mod index 19d19d1c..1d89ffe6 100644 --- a/go.mod +++ b/go.mod @@ -36,4 +36,4 @@ require ( // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // `go mod vendor` fails loudly, at once, everywhere. -replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261018171005-0b391f49a25f diff --git a/go.sum b/go.sum index e7529a66..31accf5b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 h1:zUtpGbiH9DLjCPY+KJsm+PZ25yW6k/NmGVS3haGFfMM= -git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443/go.mod h1:5QOm3cIfu/k9fN6QqgDbKGCcDst7Ul1UlcfsiMVm9j4= +git.novox.be/novox/mesh-host v0.0.0-20261018171005-0b391f49a25f h1:dOIHI3EmIbqRDkw7kj86XCHpG4rVC5SiZCjDhfEQF9g= +git.novox.be/novox/mesh-host v0.0.0-20261018171005-0b391f49a25f/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83 h1:BrHyE3ifFMUQLIza6I/iNlEgdcysB9/Y+1FO6Q9xws8= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018164924-2b420603fe83/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/internal/inventory/chain_source_test.go b/internal/inventory/chain_source_test.go index d5f5cac2..4223f85f 100644 --- a/internal/inventory/chain_source_test.go +++ b/internal/inventory/chain_source_test.go @@ -88,16 +88,21 @@ func TestTheSourceScanFindsATablePlanted(t *testing.T) { } } -// **The last migration drops the tables of the chain, behind its guard** (issue 1096): it names each table, the column and +// **Migration 105 drops the tables of the chain, behind its guard** (issue 1096; it was the last until issue 1145 added 106): it names each table, the column and // the function, drops no build_gate, and raises before it drops anything. -func TestTheLastMigrationDropsTheTablesOfTheChain(t *testing.T) { +func TestMigration105DropsTheTablesOfTheChain(t *testing.T) { migrations, err := Migrations() if err != nil { t.Fatal(err) } - last := migrations[len(migrations)-1] - if last.Number < 105 { - t.Fatalf("the last migration is %d", last.Number) + var last struct{ Number int } + for _, m := range migrations { + if m.Number == 105 { + last.Number = m.Number + } + } + if last.Number != 105 { + t.Fatal("there is no migration 105") } raw, err := os.ReadFile(filepath.Join("migrations", migrationFile(t, last.Number))) if err != nil { diff --git a/internal/inventory/migrations/0106-a-node-says-it-reads-modules.sql b/internal/inventory/migrations/0106-a-node-says-it-reads-modules.sql new file mode 100644 index 00000000..0e9dd4e8 --- /dev/null +++ b/internal/inventory/migrations/0106-a-node-says-it-reads-modules.sql @@ -0,0 +1,4 @@ +-- Whether the machine's node-engine said it reads a module on each resource of a declaration and the build of each +-- module (its reports' `reads_modules`, novox/hq issue 1145): until it has, it is sent neither, because an older +-- node-engine refuses a key it does not know, whole. As reads_generation (migration 0094). +alter table node add column reads_modules boolean not null default false; diff --git a/internal/inventory/sends.go b/internal/inventory/sends.go index db8b7095..6ed98725 100644 --- a/internal/inventory/sends.go +++ b/internal/inventory/sends.go @@ -75,6 +75,21 @@ func (i *Inventory) RecordReadsGeneration(ctx context.Context, id string, reads return err } +// ReadsModules says a machine's node-engine said it reads a module per resource and a build per module (issue 1145). +func (i *Inventory) ReadsModules(ctx context.Context, id string) (bool, error) { + var reads bool + if err := i.store.Pool().QueryRow(ctx, `select reads_modules from node where id = $1`, id).Scan(&reads); err != nil { + return false, fmt.Errorf("reading whether %s reads modules: %w", id, err) + } + return reads, nil +} + +// RecordReadsModules keeps what a machine's latest report said of reading modules. +func (i *Inventory) RecordReadsModules(ctx context.Context, id string, reads bool) error { + _, err := i.store.Pool().Exec(ctx, `update node set reads_modules = $2 where id = $1`, id, reads) + return err +} + // Send is one declaration sent to a machine, as the mesh records it. type Send struct { // Node is the machine's id; NodeName its name, filled where it is read back. diff --git a/internal/inventory/sends_test.go b/internal/inventory/sends_test.go index aba20d88..f36cf94a 100644 --- a/internal/inventory/sends_test.go +++ b/internal/inventory/sends_test.go @@ -323,3 +323,20 @@ func TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend(t *testing.T) { t.Fatalf("an ordered report about sequence 21 is not its answer: %q", a) } } + +// 1145/AC3: whether a machine reads modules is its latest word, as whether it reads a generation is. +func TestWhetherAMachineReadsModulesIsItsLatestWord(t *testing.T) { + inv, node := aNodeWithModules(t) + record, err := inv.NodeByName(t.Context(), node) + if err != nil { + t.Fatal(err) + } + for _, reads := range []bool{false, true, false} { + if err := inv.RecordReadsModules(t.Context(), record.ID, reads); err != nil { + t.Fatal(err) + } + if got, err := inv.ReadsModules(t.Context(), record.ID); err != nil || got != reads { + t.Fatalf("1145/AC3: recorded %v, read %v (%v)", reads, got, err) + } + } +} diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 63a5c9c0..f9b6ee84 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -460,6 +460,10 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err if err := e.Inventory.RecordReadsGeneration(ctx, node.ID, report.ReadsGeneration); err != nil { return false, err } + // And whether it reads a module per resource and a build per module (novox/hq issue 1145), the same way. + if err := e.Inventory.RecordReadsModules(ctx, node.ID, report.ReadsModules); err != nil { + return false, err + } } if report.Ordered() { account := AccountOf(report) diff --git a/internal/link/generation_test.go b/internal/link/generation_test.go index 7a25283b..b93e41f4 100644 --- a/internal/link/generation_test.go +++ b/internal/link/generation_test.go @@ -31,3 +31,18 @@ func TestTheGenerationOnTheWire(t *testing.T) { t.Fatalf("a node-engine that never said it reads a generation reads as one that does: %+v", older) } } + +// 1145/AC3: a report's word that the node-engine reads a module per resource and a build per module, as mesh-host's +// report writes it: `reads_modules`, on every report of a node-engine that reads them. +func TestWhetherTheNodeEngineReadsModulesIsOnItsReport(t *testing.T) { + var reads, older Report + if err := json.Unmarshal([]byte(`{"node":"anchor","applied":["a"],"report_sequence":8,"reads_modules":true}`), &reads); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(`{"node":"anchor","applied":["a"],"report_sequence":3}`), &older); err != nil { + t.Fatal(err) + } + if !reads.ReadsModules || older.ReadsModules { + t.Fatalf("1145/AC3: reads %v, an older node-engine reads %v", reads.ReadsModules, older.ReadsModules) + } +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 276516be..0687ee60 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -202,6 +202,10 @@ type Report struct { // so, because an older node-engine refuses a key it does not know, whole. Mirrors mesh-host // internal/link/messages.go. ReadsGeneration bool `json:"reads_generation,omitempty"` + // ReadsModules says the node-engine reads a module on each resource of a declaration and the build of each module + // (novox/hq issue 1145), on every report it makes: the mesh sends them only to a machine that said so. Mirrors + // mesh-host internal/link/messages.go. + ReadsModules bool `json:"reads_modules,omitempty"` // OlderGeneration is set on a report refusing a declaration composed from an older assignment // generation than the machine applied (novox/hq issue 234). The refused declaration is the report's // own Declared and Order, and its sender is read from the record of sends by that sequence. diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 4a9c6b64..1e5d2b5b 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile -mesh-host bff1ac14a198a1e2181053582728024c758bb800 examples/foundation-first-node.lock +mesh-host 0b391f49a25f9923b348bb26157e99d78d2eed4a examples/foundation-first-node.lock mesh-sdk 2b420603fe835d6b80e2c98080ab3094c6896c50 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index 0475d6ac..1fd3c5bc 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -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:". @@ -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 `.`, 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 } diff --git a/vendor/modules.txt b/vendor/modules.txt index 0db8c5ad..37cb6757 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -80,7 +80,7 @@ github.com/nats-io/nkeys # github.com/nats-io/nuid v1.0.1 ## explicit github.com/nats-io/nuid -# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261018171005-0b391f49a25f ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -140,4 +140,4 @@ golang.org/x/text/width # golang.org/x/time v0.15.0 ## explicit; go 1.25.0 golang.org/x/time/rate -# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261017162006-6469e8238443 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261018171005-0b391f49a25f