Refuse placement files on a forge message and delete placement-seed: no repository holds placement (ADR 0567 §11, issue 1126)

This commit is contained in:
2026-10-28 18:09:38 +01:00
parent c16e6213e1
commit 902e3c9e96
4 changed files with 15 additions and 235 deletions
-139
View File
@@ -1,139 +0,0 @@
// placement-seed writes the catalogue's first placement files (novox/hq ADR 0549 §4, ADR 0555 §6, issue 1126) from the
// answer of the controller's one read-only verb `placement-export`: one `placement/<node>.json` per node, each the
// node's placement as the controller's own code read it from its own store, checked as the catalogue's check checks it
// (delivery.CheckPlacements) before anything is written. The pull request it makes merges only when its composition check
// composes every node equal to what it runs.
//
// placement-seed -catalogue <checkout of mesh-catalog> < export.json
// placement-seed -catalogue <dir> -export export.json
//
// export.json is the verb's answer as it prints it ({"<node>": <placement>, ...}, or the verb's wrapping with it under
// "answer"). It opens no network connection, bus or store; it writes only <dir>/placement/<node>.json, and refuses to
// write over a placement file already there. Exit codes: 0 written; 1 the export cannot be read, is refused by the
// check, or a file is there; 2 the flags are wrong.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func main() {
catalogue := flag.String("catalogue", "", "the checkout of the catalogue to write placement/<node>.json into")
export := flag.String("export", "", "the verb's answer; standard input when absent")
flag.Parse()
if *catalogue == "" || flag.NArg() > 0 {
fmt.Fprintln(os.Stderr, "usage: placement-seed -catalogue <dir> [-export <file>]")
os.Exit(2)
}
in := io.Reader(os.Stdin)
if *export != "" {
f, err := os.Open(*export)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer f.Close()
in = f
}
written, err := Seed(in, *catalogue)
for _, path := range written {
fmt.Println(path)
}
if err != nil {
fmt.Fprintln(os.Stderr, "placement-seed:", err)
os.Exit(1)
}
}
// Seed reads the export and writes each node's placement file under root, answering the paths written. Nothing is
// written when the export does not read, the check refuses it, or a placement file is already there.
func Seed(export io.Reader, root string) ([]string, error) {
raw, err := io.ReadAll(export)
if err != nil {
return nil, err
}
placements, err := read(raw)
if err != nil {
return nil, err
}
all := func(string) bool { return true }
if refused := delivery.CheckPlacements(placements, all, all); len(refused) > 0 {
return nil, fmt.Errorf("the export is refused by the catalogue's check: %v", refused)
}
nodes := make([]string, 0, len(placements))
for node := range placements {
nodes = append(nodes, node)
}
sort.Strings(nodes)
files := map[string][]byte{}
for _, node := range nodes {
p := placements[node]
p.Revision = "" // only the indexer writes a revision, into the key
if _, err := delivery.EncodePlacement(p); err != nil {
return nil, fmt.Errorf("%s: %w", node, err)
}
p.V = delivery.Version
body, err := json.MarshalIndent(p, "", " ")
if err != nil {
return nil, err
}
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
if _, err := os.Stat(path); err == nil {
return nil, fmt.Errorf("%s is already there; the seed is written once (ADR 0549 §4)", path)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
files[path] = append(body, '\n')
}
var written []string
for _, node := range nodes {
path := filepath.Join(root, filepath.FromSlash(delivery.PlacementFile(node)))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return written, err
}
if err := os.WriteFile(path, files[path], 0o644); err != nil {
return written, err
}
written = append(written, path)
}
return written, nil
}
// read is the export as the verb prints it, or wrapped as a verb's answer (the controller's verbAnswer: its JSON under
// "answer").
func read(raw []byte) (map[string]delivery.Placement, error) {
var wrapped struct {
Answer json.RawMessage `json:"answer"`
}
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Answer) > 0 {
raw = wrapped.Answer
}
var byNode map[string]json.RawMessage
if err := json.Unmarshal(raw, &byNode); err != nil {
return nil, fmt.Errorf("the export is not {<node>: <placement>}: %w", err)
}
if len(byNode) == 0 {
return nil, errors.New("the export names no node")
}
out := map[string]delivery.Placement{}
for node, body := range byNode {
p, err := delivery.DecodePlacement(body)
if err != nil {
return nil, fmt.Errorf("%s: %w", node, err)
}
if p.Node != node {
return nil, fmt.Errorf("the export's placement of %s names the node %q", node, p.Node)
}
out[node] = p
}
return out, nil
}
-61
View File
@@ -1,61 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/compose"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Issue 1126 (ADR 0549 §4, ADR 0555 §6): the seed writes one placement file per node of the export, as the catalogue's
// check and the indexer read it, from the verb's answer bare or wrapped; it writes nothing for an export the check
// refuses, and never over a file already there.
func TestTheSeedWritesEachNodesPlacementFileOnce(t *testing.T) {
export := `{"answer":{"ace":{"v":1,"node":"ace","revision":"x","decided":{"address":"10.77.0.2"},"modules":[{"module":"plex","ports":{"32400":32400}}]},` +
`"novox":{"v":1,"node":"novox","decided":{"hub":true,"address":"10.77.0.1"},"modules":[]}}}`
root := t.TempDir()
written, err := Seed(strings.NewReader(export), root)
if err != nil || len(written) != 2 {
t.Fatalf("written %v, %v", written, err)
}
raw, err := os.ReadFile(filepath.Join(root, "placement", "ace.json"))
if err != nil {
t.Fatal(err)
}
p, err := delivery.DecodePlacement(raw)
if err != nil || p.Node != "ace" || p.Revision != "" || p.HostPorts()["plex"][32400] != 32400 {
t.Errorf("ace's file reads %+v, %v", p, err)
}
if _, err := Seed(strings.NewReader(export), root); err == nil || !strings.Contains(err.Error(), "already there") {
t.Errorf("a second seed over the files: %v", err)
}
clash := `{"ace":{"v":1,"node":"ace","decided":{"address":"10.77.0.1"},"modules":[]},"novox":{"v":1,"node":"novox","decided":{"address":"10.77.0.1"},"modules":[]}}`
other := t.TempDir()
if _, err := Seed(strings.NewReader(clash), other); err == nil || !strings.Contains(err.Error(), "both have the address") {
t.Errorf("an export the check refuses: %v", err)
}
if entries, _ := os.ReadDir(filepath.Join(other, "placement")); len(entries) != 0 {
t.Errorf("a refused export wrote %v", entries)
}
// What the controller's placement-export prints, frombus.Export per node, is what the seed takes: a node with no
// module placed included.
in := compose.Input{Nodes: []compose.Node{{Name: "solo", Place: compose.Place{Hub: true, Address: "10.77.0.9"}}}}
exported, err := frombus.Export(in, "solo")
if err != nil {
t.Fatal(err)
}
answer, _ := json.Marshal(map[string]any{"answer": map[string]delivery.Placement{"solo": exported}})
if written, err := Seed(strings.NewReader(string(answer)), t.TempDir()); err != nil || len(written) != 1 {
t.Errorf("Export's answer %s was not seeded: %v, %v", answer, written, err)
}
for _, bad := range []string{`[]`, `{}`, `{"ace":{"v":1,"node":"novox","modules":[]}}`} {
if _, err := Seed(strings.NewReader(bad), t.TempDir()); err == nil {
t.Errorf("%s was seeded", bad)
}
}
}
+7 -16
View File
@@ -9,10 +9,9 @@ const oddManifest = "{\n\t\"module\": \"a\",\n \"z\": 1, \"a\": [ \"x\",\"y\"
func mergedWithInputs() Forge {
return Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", Root: "t0",
Trees: map[string]string{"a": "t1", "b": "t2"},
Manifests: map[string]string{"a": oddManifest},
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}},
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
Trees: map[string]string{"a": "t1", "b": "t2"},
Manifests: map[string]string{"a": oddManifest},
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}}
}
// 1442/AC1
@@ -22,7 +21,7 @@ func TestForgeMergedCarriesManifestsAndLocks(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`, `"placements":{"ace":`, `"placements-read":true`} {
for _, frag := range []string{`"manifests":{"a":`, `"locks":{"a":{"go.sum":`} {
if !strings.Contains(string(data), frag) {
t.Errorf("the encoding lacks %s: %s", frag, data)
}
@@ -48,9 +47,9 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
"locks outside trees": {func(f *Forge) { f.Locks["yy"] = map[string]string{"go.sum": "h"} }, `"yy"`},
"empty manifest": {func(f *Forge) { f.Manifests["b"] = "" }, `"b"`},
"empty lock hash": {func(f *Forge) { f.Locks["a"]["go.sum"] = "" }, `"a"`},
"placements unsaid": {func(f *Forge) { f.PlacementsRead = false }, "placements-read"},
"placement of no node": {func(f *Forge) { f.Placements["a.b"] = "{}" }, "names no node"},
"placements on a pull": {func(f *Forge) { f.Kind, f.Action, f.Merge, f.Root, f.Trees = ForgePull, PullOpened, "", "", nil }, "only a merged message"},
// No repository holds placement (ADR 0567 §11): a message carrying placement files is refused, on a merge or a pull.
"placement files": {func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) }, "mesh-placement"},
"placements read": {func(f *Forge) { f.RetiredRead = true }, "mesh-placement"},
} {
f := mergedWithInputs()
tc.mut(&f)
@@ -86,14 +85,6 @@ func TestForgePullCarriesManifestsAndLocks(t *testing.T) {
if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"c"`) {
t.Errorf("locks outside Dirs: %v", err)
}
// A pull message of the catalogue carries its head's placement files whole (placement candidates, issue 1126).
f = Forge{Kind: ForgePull, Repository: CatalogueRepository, Number: 2, Head: "h", Action: PullUpdated,
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
if data, err := EncodeForge(f); err != nil {
t.Errorf("a catalogue pull with placement files: %v", err)
} else if got, err := DecodeForge(data); err != nil || !SameForge(got, withV(f)) {
t.Errorf("a catalogue pull's placement files changed on the way: %+v, %v", got, err)
}
for name, f := range map[string]Forge{
"refused manifests": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}},
"refused locks": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Locks: map[string]map[string]string{"a": {"go.sum": "h"}}},
+8 -19
View File
@@ -537,13 +537,11 @@ type Forge struct {
Act string `json:"act,omitempty"`
Words string `json:"words,omitempty"`
Open []OpenPull `json:"open,omitempty"`
// Placements are, on a merged message of the catalogue repository, every placement file of the merge commit, the
// bytes exact, by node (PlacementFile; novox/hq ADR 0549 §2), and PlacementsRead says they were read, so a catalogue
// whose last placement file was removed is told from a message that read none (older writers, another repository).
// On a pull message of the catalogue (CatalogueRepository) they are every placement file at the pull request's head,
// read whole or not at all, which the indexer writes as placement candidates (PlacementCandidate).
Placements map[string]string `json:"placements,omitempty"`
PlacementsRead bool `json:"placements-read,omitempty"`
// RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is
// the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is
// refused, loudly, rather than read with them dropped.
RetiredPlacements json.RawMessage `json:"placements,omitempty"`
RetiredRead bool `json:"placements-read,omitempty"`
}
func (f Forge) check() error {
@@ -556,18 +554,9 @@ func (f Forge) check() error {
if len(f.Open) > 0 {
return fmt.Errorf("delivery: only an open message names open pull requests")
}
// A pull message of the catalogue carries its head's placement files, so its change of placement is composed and
// answered by the nodes it places before it merges (placement candidates, issue 1126; ADR 0549 §4).
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged && (f.Kind != ForgePull || f.Repository != CatalogueRepository) {
return fmt.Errorf("delivery: only a merged message, or a pull message of the catalogue, carries placement files")
}
if len(f.Placements) > 0 && !f.PlacementsRead {
return fmt.Errorf("delivery: a message with placement files must say it read them (placements-read)")
}
for node := range f.Placements {
if err := checkName("node", node); err != nil {
return fmt.Errorf("delivery: a placement file names no node: %w", err)
}
if len(f.RetiredPlacements) > 0 || f.RetiredRead {
return fmt.Errorf("delivery: a forge message carries placement files, which no repository holds since ADR 0567: " +
"placement is the seat mesh-placement's state")
}
if err := checkRepository(f.Repository); err != nil {
return err