From 6447a1c6d4515193845b39a18c8643b006f8598a Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 19:17:45 +0100 Subject: [PATCH] =?UTF-8?q?placement-seed=20writes=20each=20node's=20first?= =?UTF-8?q?=20placement=20file=20from=20the=20controller's=20export,=20che?= =?UTF-8?q?cked=20before=20anything=20is=20written,=20once=20(issue=201126?= =?UTF-8?q?,=20ADR=200549=20=C2=A74,=20ADR=200555=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/cmd/placement-seed/main.go | 139 +++++++++++++++++++++++++++++ go/cmd/placement-seed/main_test.go | 47 ++++++++++ go/delivery/delivery_test.go | 3 +- 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 go/cmd/placement-seed/main.go create mode 100644 go/cmd/placement-seed/main_test.go diff --git a/go/cmd/placement-seed/main.go b/go/cmd/placement-seed/main.go new file mode 100644 index 0000000..93fae61 --- /dev/null +++ b/go/cmd/placement-seed/main.go @@ -0,0 +1,139 @@ +// 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/.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 < export.json +// placement-seed -catalogue -export export.json +// +// export.json is the verb's answer as it prints it ({"": , ...}, or the verb's wrapping with it under +// "answer"). It opens no network connection, bus or store; it writes only /placement/.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/.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 [-export ]") + 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 {: }: %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 +} diff --git a/go/cmd/placement-seed/main_test.go b/go/cmd/placement-seed/main_test.go new file mode 100644 index 0000000..6dd4399 --- /dev/null +++ b/go/cmd/placement-seed/main_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "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) + } + 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) + } + } +} diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index 2f3c480..a831685 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -411,6 +411,7 @@ func testPulseTTL(t *testing.T) { // 1131/AC8: equal values say same, different ones do not. func TestSame(t *testing.T) { + t.Run("have removed", testHaveRemoved) a := Have{V: 1, Build: "b", Phase: PhaseRunning, Health: HealthOK} b := a if !SameHave(a, b) { @@ -464,7 +465,7 @@ func TestSame(t *testing.T) { } } -func TestHaveRemoved(t *testing.T) { // 1370/AC3 +func testHaveRemoved(t *testing.T) { // 1370/AC3 in := Have{Build: "b1", Phase: PhaseRemoved, Health: HealthUnknown, Detail: "the declaration no longer holds it"} b, err := EncodeHave(in) if err != nil {