Compare commits

...
Author SHA1 Message Date
jschoubben 575cb82fba A runtime that already dropped a carried module's tools serves them again, read from what runs and its build (issue 1126, review)
mesh/delivery-group group fix/1126-924-held-and-judged rejected: composed together: the checker's verdict at the members' heads (7e4e345e9969): mesh-build-go
mesh/delivery closed: the pull request closed unmerged
mesh/check checking 575cb82fba69 on 394fec8a63c1 | novox/mesh-sdk#130 in group fix/1126-924-held-and-judged
rev-held2: ToolsRunning took a carried module's bundles from the runtime's MESH_TOOL_MODULES alone, and shanks had
already applied the drop of systemd-resolved, so its runs stayed empty and the drop permanent. Now every bundle
archive that runs at its module's BundlePath is a running bundle; one the runtime loads nothing of takes its loads and
words from the module's build (its built bundles, else its build's artifacts, by catalogue.BundleLoads, the rule a
build's resolution keeps, now one function). The runtime process and each archive are matched by their owning module
too, not only by id and path. A row added: the runtime already dropped it.
2026-10-29 01:37:47 +01:00
jschoubben b6768dad25 A data flag that differs alone moves no data: the judgement reads the data items first, and says the flag loudly (issue 1126)
novox's mongodb and mssql asked a yes for moving their data on a toolchain move: the running side, read back from the
kept declaration, carries no data flag, and judge.go returned moves-data on the flag before it compared the data
directories and files, so a flag mismatch both raised a false ask and hid a real move. Now moves-data only when a
data item (a directory or file within the data places, or a process of a module with data) changes; a flag-only
difference is neither, with a WARNING in its words. One row changed and one added in TestJudgeMove.
2026-10-29 01:36:17 +01:00
jschoubben f50e004e8d A module composed as what runs of it serves its tools as they run, so the runtime no longer drops it (issue 1126)
After the take-over of placement on 2026-10-29, shanks's runtime stopped serving the folded systemd-resolved (the
node-resolver seat's verbs answer "nothing serves") and novox's the folded nats. Neither has a want: each runs inside
its node's own composition at a build of the old form (Scope.InOwn), and is composed as Carried of the indexer's
manifest. That manifest is the catalogue's, which no build resolved, so it has no bundles (catalogue/build.go: Bundles
are set only by a build's resolution), and the runtime's MESH_TOOL_MODULES and MESH_TOOL_ENV are made from bundles
(catalogue/runtime.go). The controller composed them from the resolved manifest its store kept.

The kind: a module composed as what runs of it (Stub, Carried: held, or folded at an old build) contributes to the
others from its manifest, not from what runs. For a held module with a new build it is the other way round: its new
bundles are served, which were never delivered.

- The quick fix: read the old-form build's resolved manifest for a folded module. One instance; a held module still
  hands its new bundles.
- The thorough fix, chosen: compose.ToolsRunning reads what the node's runtime serves as it runs (its process's
  MESH_TOOL_MODULES and MESH_TOOL_ENV, each bundle's archive), frombus gives it as Node.RunsTools, and every stubbed
  module of the machine's runtime (held or carried) contributes those bundles, whatever its manifest says.

Tests: two rows (folded, held) added to TestCompositionsAreTheDeclarationSplitByModule; no new test function.
2026-10-29 01:30:50 +01:00
mesh-tools 394fec8a63 Merge pull request 'A held module composes only as what runs of it, so its hold no longer spreads to the modules that use it (issue 1126)' (#128) from fix/1126-held-as-runs into main 2026-10-29 00:07:03 +00:00
jschoubben c2999a4e13 A held module is no dependence of its neighbours: its hold stops spreading (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@394fec8a63c1
mesh/delivery-group group fix/1126-held-as-runs merged: every member is merged
mesh/check pass c2999a4e13cc on d4c61ee8ffd0 | unit 3054 run, 234 kept, 36 skipped | integration 3075 run, 266 kept, 29 skipped | over bound: unit (sl…
novox's seed p-20261028t231141-ec84b4 refused 11 modules "what it composes of ..., held, is not what runs of them":
de-spiegel's hold spread through mailu to postgres, nftables and route-proxy, and from postgres to every consumer, and
gitea, held so, refused the node: "its credential of postgres-database is handed to the provisioner and the module is
not: nothing would fill it". Three rounds each moved the error. The kind: a held module still took part in the
composition as a dependence, so its hold spread.

- The quick fix: let heldAsToday also follow dependsOnHeld's words, and skip the handed refusal for gitea. It moves the
  error once more and leaves every node's applying path holding consumers.
- The thorough fix, chosen: a held module composes only as what runs of it. composeEach no longer compares the
  composition with each held module as its stub (dependsOnHeld removed): a consumer or collector composes against what
  runs of the held module like any other module, and a real difference is still a named difference in the answer, a
  module's own refusal still its refusal. A stub (compose.Stub, catalogue.Manifest.Stubbed) leaves the credentials and
  own secrets it would hand the provisioner out of its plan as they run, never refused for want of a plan. Only what a
  held module's new manifest says to others in their own files is still held behind it (dependents: the provider of
  what it contributes to, the filter where it listens), which heldAsToday already follows.

Tests: rows added to existing tests (a held provider with a binding consumer, which the old code held in the seed's
words; a held feeder of a collector; a held module with a handed credential and own secret). No new test function.
2026-10-29 00:49:00 +01:00
mesh-tools d4c61ee8ff Merge pull request 'The seed leaves a module as it runs today when it is held, failed or at an old-form build, and the modules held only behind it too (issue 1126)' (#127) from fix/1126-seed-oldbuild into main 2026-10-28 23:05:04 +00:00
jschoubben 76f7c4d223 A stub keeps its items and their templates, so a held module still contributes to other modules' collections; it drops only the templates for its own files (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d4c61ee8ffd0
mesh/delivery-group group fix/1126-seed-oldbuild merged: every member is merged
mesh/check pass 76f7c4d2238f on d747c1128e42 | unit 3018 run, 270 kept, 36 skipped | integration 3039 run, 302 kept, 29 skipped | over bound: unit (sl…
2026-10-28 23:56:33 +01:00
jschoubben f609be7864 Keep as today what a node keeps today and what is held only behind it, saying how; a stub hands and collects nothing of its own (issue 1126) 2026-10-28 23:49:22 +01:00
16 changed files with 465 additions and 139 deletions
+29 -17
View File
@@ -126,23 +126,7 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
continue
}
made := by[a.Name]
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
loads := BundleLoads(m, a, run)
// **Kept, never routed** (ADR 0155): the builder publishes to the store at the address
// it reached it by, and a manifest carrying that address names an installation —
// registration refused node-tools for exactly this on 2026-10-02. The build record
@@ -715,3 +699,31 @@ func undeliveredBundles(m Manifest) []string {
}
return problems
}
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
// module composed as what runs of it reads again when the runtime already dropped it (issue 1126).
func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
// What the runtime loads: what the artifact said, else every entrypoint of a module
// that declares tools, else nothing (the field's own rule; see Artifact.Loads).
//
// **Never, unasked, a bundle one of the module's own resources runs** (novox/hq issue 213).
// A process the host runs is the module's service, not its tools: the controller declares
// the verbs it answers as `tools` and serves them itself, and its bundle would otherwise
// have been launched a second time by the node's runtime, as an MCP child it is not.
loads := append([]string(nil), a.Loads...)
if a.Loads == nil && len(m.Tools) > 0 && !run[a.Name] {
loads = append([]string(nil), a.Entrypoints...)
// A bundle compiled to a binary has no entrypoints: the binary is what it is, and what
// the runtime starts to serve it (novox/hq ADR 0193). So a Go tools bundle is served
// as Go — the runtime execs it — exactly as a TypeScript one is through its launcher.
if bin := BinaryOf(a); bin != "" {
loads = []string{bin}
}
}
return loads
}
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
+6
View File
@@ -1103,6 +1103,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
}
for _, unsettled := range resources {
if pr, ok := unsettled[HandedProvisionField].(PlanProvision); ok {
if handing == nil && m.Stubbed {
continue // held: its credential file is what runs of it, left out (issue 1126)
}
if handing == nil {
return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+
"not: nothing would fill it", m.Module, pr.Provision)
@@ -1127,6 +1130,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
continue
}
if unsettled[HandedSecretField] != nil {
if handing == nil && m.Stubbed {
continue // held: its own secret's file is what runs of it, left out (issue 1126)
}
if handing == nil {
return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+
"not: nothing would fill it", m.Module, unsettled[HandedSecretField])
+16
View File
@@ -185,6 +185,22 @@ func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
}
// Issue 1126 (novox's seed: "gitea: its credential of postgres-database is handed to the provisioner and the module
// is not"): held, the module is composed as its stub, with no template and no item, so nothing is handed. Its
// credential and its own secret are what runs of it, left out, and the composition is not refused.
r := forgeReader(t, true, true)
for i := range r.Modules {
if r.Modules[i].Module == "bot" {
h := *r.Modules[i].Handed
h.Templates = nil
r.Modules[i].Handed, r.Modules[i].Stubbed, r.Modules[i].Build = &h, true, nil
r.Modules[i].Resources = slices.DeleteFunc(r.Modules[i].Resources, func(x map[string]any) bool { return x["type"] != "directory" })
}
}
c, got = composeWith(t, r, handingTo("store:5000"))
if strings.Contains(got, SecretID("forge-read")) || strings.Contains(got, NeedID("token")) || c.Plans["bot"].Provisions != nil {
t.Fatalf("a held module's handed credential or own secret is composed: %s", got)
}
}
func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) {
+5
View File
@@ -870,6 +870,11 @@ type Manifest struct {
// path its manifest leaves to the mesh, a binding). Nil for any other module. Never in a manifest's JSON.
OwnsPaths []string `json:"-"`
// Stubbed marks a module composed as its stub (compose.Stub: held on its node, issue 1126): none of its own
// composition is applied, so what it hands the provisioner (a credential the vault keeps for it, its own secrets)
// is left out of its plan as it runs, never refused for want of a plan. Never in a manifest's JSON.
Stubbed bool `json:"-"`
// Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31).
// Written into whichever node runs the module, the same way `listens` become that node's rules.
Jails []Jail `json:"jails,omitempty"`
+127
View File
@@ -130,6 +130,22 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
n.Kept = kept
}
}
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
for i := range reading.Nodes {
n := &reading.Nodes[i]
if n.Name != node || n.RunsTools == nil {
continue
}
kept := make(map[string]catalogue.Manifest, len(n.Kept))
for m, man := range n.Kept {
if man.Stubbed && man.RunsAs == "" {
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
}
kept[m] = man
}
n.Kept = kept
}
isCarried := func(module, id string) bool {
if module == "" {
module, _ = declaration.ModuleGuess(id)
@@ -293,6 +309,106 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
return false
}
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
type resource struct {
ID string `json:"id"`
Module string `json:"module"`
Type string `json:"type"`
Path string `json:"path"`
Source string `json:"source"`
Digest string `json:"digest"`
Env map[string]string `json:"env"`
}
var env map[string]string
out := map[string][]catalogue.Bundle{}
for _, c := range running {
for _, raw := range c.Resources {
var r resource
if json.Unmarshal(raw, &r) != nil {
continue
}
owner := r.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(r.ID)
}
switch {
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
env = r.Env
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
if r.Path == catalogue.BundlePath(owner, name) {
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
}
}
}
}
if env == nil {
return nil
}
var words map[string]map[string]string
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
module, path, ok := strings.Cut(entry, "=")
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
continue
}
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
bundles := out[module]
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
if !ok || i < 0 {
continue // no archive of it runs: nothing to load
}
var own map[string]string
for w, v := range words[module] {
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
continue // the mesh's to say, composed again
}
if own == nil {
own = map[string]string{}
}
own[w] = v
}
bundles[i].Loads = append(bundles[i].Loads, load)
bundles[i].Env = own
}
for _, bundles := range out {
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
}
return out
}
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
var out []catalogue.Bundle
for _, b := range runs {
if len(b.Loads) == 0 {
for _, built := range man.Bundles {
if built.Name == b.Name {
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
}
}
if man.Build != nil {
run := catalogue.RunByAResource(man)
for _, a := range man.Build.Artifacts {
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
}
}
}
}
out = append(out, b)
}
return out
}
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
// alone (issue 1126).
@@ -318,6 +434,17 @@ func Stub(m catalogue.Manifest) catalogue.Manifest {
m.OwnsPaths = catalogue.DeclaredPaths(m)
}
m.Build = nil
// What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
m.Stubbed = true
// Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
// refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
// items stay, and the templates they render through: a held module's contributions to other modules' collections
// (seat contributions, shell blocks, seat data) still reach their collectors.
if m.Handed != nil {
h := *m.Handed
h.Templates = nil
m.Handed = &h
}
var dirs []map[string]any
for _, r := range m.Resources {
if r["type"] == "directory" {
+86
View File
@@ -8,6 +8,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
)
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
@@ -96,6 +97,91 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
t.Errorf("a node the mesh does not have was composed")
}
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
// held module's new build's bundles are not delivered.
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
store := catalogue.Manifest{Module: "artifacts", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
if err != nil {
t.Fatal(err)
}
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
runtimeRuns := func(dropped bool) delivery.Composed {
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
if dropped {
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
}
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
}
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
for _, row := range []struct {
name string
set func(n *Node) delivery.Composed // and what runs of lamp
fps map[string]string
dropped bool
}{
{"folded at a build of the old form", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, false},
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, true},
{"held, its new build not delivered", func(n *Node) delivery.Composed {
n.Held = map[string]bool{"lamp": true}
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
return lr
}, map[string]string{"lamp": fpFor('1')}, false},
} {
tools := aMesh(t)
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
anchor := nodeOf(t, &tools, "anchor")
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
anchor.SealingKey = keyOf(t, "anchor")
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
var env map[string]any
for _, c := range out {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Env map[string]any `json:"env"`
}
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
env = r.Env
}
}
}
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
}
}
}
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
+57 -103
View File
@@ -2,7 +2,6 @@ package frombus
import (
"context"
"encoding/json"
"fmt"
"regexp"
"slices"
@@ -11,7 +10,6 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -209,21 +207,59 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
return a
}
// heldAsToday moves each refusal of a module the node holds today (Scope.HeldToday) into Held: the candidate holds it as
// the node does, which is no change (issue 1126: a seed reproduces today, and today holds it too).
func heldAsToday(a *delivery.CandidateAnswer, today []string) {
// heldBehind is why a module is held with a held one it reads (composeEach's dependents).
func heldBehind(m string) string {
return "it reads what " + m + " contributes or listens on, and " + m + " is held"
}
// behind is the held module a refusal's words say it is held behind, or "".
func behind(why string) string {
rest, ok := strings.CutPrefix(why, "it reads what ")
if !ok {
return ""
}
m, _, ok := strings.Cut(rest, " contributes or listens on, and ")
if !ok || why != heldBehind(m) {
return ""
}
return m
}
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
// held, failed, running a build of the old form), and of each module held only behind one of those: the candidate keeps
// them as the node does, which is no change (issue 1126: a seed reproduces today, and today keeps them so). Each says
// how it stands today (ModuleRefused.Today).
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
if len(today) == 0 {
return
}
kept := a.Refusals[:0]
for _, r := range a.Refusals {
if slices.Contains(today, r.Module) {
a.Held = append(a.Held, r)
} else {
kept = append(kept, r)
how := map[string]string{}
for m, w := range today {
how[m] = w
}
for moved := true; moved; {
moved = false
kept := a.Refusals[:0]
for _, r := range a.Refusals {
w, keep := how[r.Module]
if !keep {
if m := behind(r.Why); m != "" {
if _, behindKept := how[m]; behindKept {
w, keep = "held behind "+m, true
}
}
}
if keep {
r.Today = w
how[r.Module] = w
a.Held = append(a.Held, r)
moved = true
} else {
kept = append(kept, r)
}
}
a.Refusals = kept
}
a.Refusals = kept
if len(a.Refusals) == 0 {
a.Refusals = nil
}
@@ -274,11 +310,14 @@ func standsInFor(x, node string) (string, string) {
// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others
// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own
// composition is in out: the node keeps what it last applied. **What depends on how it is held is held too:** a module
// whose composition differs when the held ones are composed as their stubs instead is held with them, and a node's own
// composition that differs (but for the held modules' own resources) refuses the node. And since a held module is
// composed from its new manifest, which may not be the build's that runs, what reads that manifest is held with it
// (dependents): the providers of what it contributes to (the proxy its routes), and, when it listens, the filter.
// composition is in out: the node keeps what it last applied. **A held module is no dependence of its neighbours**
// (issue 1126, the spreading hold): a consumer of what it provides, or a collector of its items, composes against what
// runs of it like any other module, and is never held for how the held one would compose as its stub (that comparison
// held every postgres consumer behind de-spiegel's hold on novox, three rounds in a row). What it composes is compared
// with what runs as any module's is: a real difference is still named, and a refusal of its own still refuses it. Only
// what a held module's new manifest says to others, which may not be the build's that runs, is held with it
// (dependents): the providers of what it contributes to in their own files (the proxy its routes), and, when it
// listens, the filter; its items reach their collectors through its own plan, which it keeps as it runs.
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
mode := compose.Foreseeing
if applied {
@@ -313,7 +352,7 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
// What the others read of its manifest (what it contributes, what it listens on) is its new manifest's, not
// necessarily the build's that runs: what reads it is held with it (review of the apply, round 2).
for _, r := range dependents(in, node, src[m], failed) {
hold(r, "it reads what "+m+" contributes or listens on, and "+m+" is held", false)
hold(r, heldBehind(m), false)
}
}
for _, m := range sortedKeys(failed) {
@@ -354,17 +393,6 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", "))
}
}
if !again && len(failed) > 0 {
dependent, own := dependsOnHeld(in, node, fps, mode, out, src, bareOf, failed)
if own {
return nil, nil, nil, fmt.Errorf("frombus: %s's own composition depends on how %s, held, is composed, and is not applied",
node, strings.Join(sortedKeys(failed), ", "))
}
for _, m := range dependent {
hold(m, "what it composes of "+strings.Join(sortedKeys(failed), ", ")+", held, is not what runs of them", false)
again = true
}
}
if !again {
break
}
@@ -373,80 +401,6 @@ func composeEach(in compose.Input, node string, fps map[string]string, applied b
return out, skipped, stood, nil
}
// dependsOnHeld composes in again with each held module as its stub rather than as what runs of it, and answers the
// modules, not held, whose composition differs between the two (sorted), and own when the node's own composition does
// but for the held modules' own resources. A composition of stubs that fails says nothing more: what runs composed.
func dependsOnHeld(in compose.Input, node string, fps map[string]string, mode compose.Mode, asRuns map[string]delivery.Composed,
src map[string]catalogue.Manifest, bareOf map[string]bool, failed map[string]string) (dependent []string, own bool) {
stubs := in
for _, m := range sortedKeys(failed) {
if man, ok := src[m]; ok {
stubs = heldAs(stubs, node, m, man, bareOf[m], nil)
}
}
asStubs, _, _, err := compose.CompositionsIn(stubs, node, fps, mode)
if err != nil {
return nil, false
}
isHeld := func(m string) bool { _, ok := failed[m]; return ok }
keys := map[string]bool{}
for k := range asRuns {
keys[k] = true
}
for k := range asStubs {
keys[k] = true
}
for _, k := range sortedKeys(keys) {
a, inA := asRuns[k]
b, inB := asStubs[k]
m := a.Module
if !inA {
m = b.Module
}
if m != "" && isHeld(m) {
continue
}
if m == "" {
a, b = withoutHeld(a, isHeld), withoutHeld(b, isHeld)
}
da, errA := delivery.ComposedDigestOf(a)
db, errB := delivery.ComposedDigestOf(b)
if inA == inB && errA == nil && errB == nil && da == db {
continue
}
if m == "" {
own = true
continue
}
dependent = append(dependent, m)
}
return dependent, own
}
// withoutHeld is the node's own composition c without the resources of a held module (its id naming it) and without
// its filled paths, for comparing the rest.
func withoutHeld(c delivery.Composed, isHeld func(string) bool) delivery.Composed {
out := c
out.Filled = nil
out.Resources = nil
for _, raw := range c.Resources {
var head struct {
ID string `json:"id"`
Module string `json:"module"`
}
_ = json.Unmarshal(raw, &head)
owner := head.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(head.ID)
}
if owner != "" && isHeld(owner) {
continue
}
out.Resources = append(out.Resources, raw)
}
return out
}
// dependents is, of the modules placed on node and not held, those that read what man says to them: each that provides
// what man contributes to (the proxy its routes), and, when man listens on a port, each that holds the filter (the
// openings).
+84 -4
View File
@@ -93,17 +93,25 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
}
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
// refused module held today the verdict is no refusal, and each is named under Held.
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api", "spiegel", "web"}},
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
t.Errorf("held as today: %+v", today)
}
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: []string{"api"}},
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
t.Errorf("held as today in part: %+v", part)
}
// A module held only behind one kept today is kept as today too, saying behind which (novox's mailu behind de-spiegel).
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
{Module: "mailu", Why: heldBehind("de-spiegel")}, {Module: "proxy", Why: heldBehind("mailu")}, {Module: "web", Why: "no build"}}}); true {
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
if len(a.Refusals) != 1 || a.Refusals[0].Module != "web" || len(a.Held) != 3 || a.Held[2].Today != "held behind mailu" {
t.Errorf("held behind a module kept today: %+v", a)
}
}
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
@@ -286,6 +294,31 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
}
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
// "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
// contributes to other modules' collections still reaches them.
for _, row := range []struct {
name string
handed *catalogue.Handed
items []catalogue.Item
}{
{"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
{"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
} {
kept := f1
kept.Handed, kept.Items = row.handed, row.items
st := compose.Stub(kept)
if st.Handed != nil && st.Handed.Templates != nil {
t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
}
if !reflect.DeepEqual(st.Items, row.items) {
t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
}
if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
}
}
var k1, k2 any
_ = json.Unmarshal(keptComposed, &k1)
_ = json.Unmarshal(keptFile, &k2)
@@ -400,8 +433,8 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
// compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. A module whose
// composition depends on how site is held (here the filter) is held with it.
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
// reads what site's manifest listens on, is held with it (dependents).
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
@@ -437,6 +470,53 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
}
}
}
// (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
// app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
// consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
b2.manifests[delivery.ManifestDigest(raw)] = raw
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p2 := b2.placements["n1"]
p2.Decided.Endpoint = "n1.example:51820"
for i := range p2.Modules {
if p2.Modules[i].Module == "site" {
p2.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b2.placements["n1"] = p2
b2.records["n1"] = rec
b2.held = true
fps["app"] = fp("9")
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
}
if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
}
delete(fps, "app")
// A held feeder of a collector holds no collector: its items reach their collection through its own plan, which it
// keeps as it runs. Only a provider it contributes to in that provider's own files is held behind it (the proxy its
// routes), as is the filter where it listens.
withCollector := compose.Input{Catalogue: map[string]catalogue.Manifest{
"feeder": {Module: "feeder", Items: []catalogue.Item{{Collection: "routes", Content: "x"}}},
"col": {Module: "col", Collections: []catalogue.Collection{{Name: "routes"}}},
"proxy": {Module: "proxy", Provides: []catalogue.Offer{{Name: "route"}}}},
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder", "col", "proxy"}}}}
feeder := withCollector.Catalogue["feeder"]
if d := dependents(withCollector, "n1", feeder, map[string]string{"feeder": "held"}); len(d) != 0 {
t.Errorf("a held feeder of a collector holds %v, want none", d)
}
feeder.Contributes = map[string]map[string]any{"route": {"name": "x"}}
if d := dependents(withCollector, "n1", feeder, map[string]string{"feeder": "held"}); !reflect.DeepEqual(d, []string{"proxy"}) {
t.Errorf("a held module contributing routes holds %v, want the proxy", d)
}
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
// build that runs listens on 8080: the filter, the holder of what site listens on, is held with it, and nothing
+6 -4
View File
@@ -134,10 +134,10 @@ type Scope struct {
Placements map[string]*delivery.Placement
InOwn []string
Running []delivery.Composed
// HeldToday is, in an answer, the modules the node holds today ("node" for its own composition): their have's phase
// is held, or they are held inside its own composition. A candidate that holds one too holds it as today, which is
// said under CandidateAnswer.Held and is no refusal (issue 1126).
HeldToday []string
// HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with
// how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only
// behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126).
HeldToday map[string]string
// SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own
// key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them
// as differing.
@@ -313,6 +313,8 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
}
}
if node == s.Node {
// What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126).
n.RunsTools = compose.ToolsRunning(s.Running)
for _, module := range s.InOwn {
if n.InOwn == nil {
n.InOwn = map[string]bool{}
+14 -2
View File
@@ -45,6 +45,8 @@ type bus struct {
served map[string]map[string]any
servedRead []string
buildsRead []string
// held answers every credential asked as held, sealed to the consumer.
held bool
}
func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
@@ -89,7 +91,7 @@ func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error
return b.own, b.ownErr
}
func (b *bus) Credentials(_ context.Context, node string, asConsumer, _ []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
b.asked = append(b.asked, asConsumer...)
for _, k := range asConsumer {
placed := false
@@ -100,7 +102,17 @@ func (b *bus) Credentials(_ context.Context, node string, asConsumer, _ []compos
return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
}
}
return nil, nil, nil
var out []compose.Credential
var issued []compose.Issued
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
if b.held {
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
}
}
return out, issued, nil
}
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
+6
View File
@@ -210,6 +210,12 @@ type Node struct {
// (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
// never drops it.
Held map[string]bool
// RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the
// node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a
// build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no
// bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost
// the folded systemd-resolved, novox's the folded nats).
RunsTools map[string][]catalogue.Bundle
}
// Place is where a node is, as its placement says (inv.Overlay).
+6 -2
View File
@@ -258,9 +258,13 @@ func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
if held := v.Answers[n].Held; len(held) > 0 {
var names []string
for _, h := range held {
names = append(names, h.Module)
today := h.Today
if today == "" {
today = "held"
}
names = append(names, h.Module+" ("+today+")")
}
fmt.Fprintf(&b, "; stays held as today: %s", strings.Join(names, ", "))
fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", "))
}
}
return b.String()
+1 -1
View File
@@ -79,7 +79,7 @@ func TestJudge(t *testing.T) {
t.Errorf("the difference is named %q", got)
}
}
if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays held as today: spiegel") {
if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") {
t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v))
}
if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) {
+13 -4
View File
@@ -3,6 +3,7 @@ package delivery
import (
"bytes"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
@@ -84,9 +85,6 @@ func judged(from, to Composed) (Judgement, string) {
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
}
if from.Data != to.Data {
return JudgedMovesData, "its data declaration changes"
}
places := dataPlaces(before, after)
held := func(kind string) bool {
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
@@ -104,9 +102,20 @@ func judged(from, to Composed) (Judgement, string) {
if to.Data || from.Data {
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
}
// A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
// a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
// moving its data). It is said loudly in the words, and never hides an item that moved.
if len(moved) > 0 {
sort.Strings(moved)
return JudgedMovesData, "it changes where its data is kept: " + strings.Join(moved, ", ")
words := "it changes where its data is kept: " + strings.Join(moved, ", ")
if from.Data != to.Data {
words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
}
return JudgedMovesData, words
}
if from.Data != to.Data {
return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
"(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
}
return JudgedNeither, "it recreates no container and moves no data"
}
+5 -1
View File
@@ -48,7 +48,8 @@ func TestJudgeMove(t *testing.T) {
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false},
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false},
{"its data declaration", mail('a', false, container, spool), mail('b', true, container, spool), JudgedMovesData, false},
{"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false},
{"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false},
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false},
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false},
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false},
@@ -61,6 +62,9 @@ func TestJudgeMove(t *testing.T) {
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
}
if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") {
t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words)
}
}
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
t.Errorf("the node's own composition: %+v", got)
+4 -1
View File
@@ -190,10 +190,13 @@ type ModuleAdded struct {
Unissued []string `json:"unissued,omitempty"`
}
// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with.
// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with. Today,
// in CandidateAnswer.Held, is how the node keeps it today (held, failed, runs a build of the old form, held behind
// another), which the candidate keeps.
type ModuleRefused struct {
Module string `json:"module"`
Why string `json:"why"`
Today string `json:"today,omitempty"`
}
// VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or