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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user