From 96e15f86938d205d5eb161f1bdb9907b8fadefc6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 00:03:18 +0200 Subject: [PATCH] Hold a joining module at the build it ran, own its directories by its own group, and take a library only when its act is done (hq ADR 0328 review, ADR 0334) A held module is composed exactly as before it joined, at the build its machine runs, so its port, resources and contributions stay. A joining module's directories belong to its user and its own group, and its tools' directories are refused closed to that group; the runner joins it where the engine can say so. A take records the root and is taken only when the engine states the act done. Libraries' paths and verdicts count modules pulled in by a requirement, and a unit takes the identity. --- cmd/mesh-controller/held_library.go | 95 ++++++++++++ cmd/mesh-controller/held_library_test.go | 144 ++++++++++++++++++ cmd/mesh-controller/kept_libraries.go | 31 +++- cmd/mesh-controller/kept_libraries_test.go | 77 +++++++++- cmd/mesh-controller/module_health.go | 54 ++++++- cmd/mesh-controller/operator_wait.go | 3 +- cmd/mesh-controller/plan.go | 51 +++---- internal/catalogue/declaration.go | 7 +- internal/catalogue/libraries.go | 75 ++++++++- internal/catalogue/libraries_test.go | 143 +++++++++++++---- internal/catalogue/unconsumed_placeholder.go | 2 +- internal/inventory/libraries.go | 59 ++++--- internal/inventory/libraries_test.go | 105 ++++++++++++- ...module-joins-and-a-library-has-a-group.sql | 14 +- internal/link/protocol.go | 5 + testdata/store-tests | 4 + 16 files changed, 763 insertions(+), 106 deletions(-) create mode 100644 cmd/mesh-controller/held_library.go create mode 100644 cmd/mesh-controller/held_library_test.go diff --git a/cmd/mesh-controller/held_library.go b/cmd/mesh-controller/held_library.go new file mode 100644 index 00000000..59061b69 --- /dev/null +++ b/cmd/mesh-controller/held_library.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "sort" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A module that joins keeps the user it ran as until every library it uses is taken (novox/hq ADR 0328 §8). +// +// **Held at the build it ran before, composed exactly as then.** A module whose registered build uses a library not yet +// taken, on a machine that was last sent another build of it, is composed at that build — the manifest the build +// records hold, in place of the registered one, as a recorded module is kept (recorded_kept.go): its resources, its +// ports in the filter, its contributions and bindings are what they were, and nothing of the new contract is sent for +// it. The send records that the machine still carries that build. Once the library is taken, the next composition +// takes the new build. A module the machine never ran has nothing to keep, and is composed as registered; a build the +// records no longer hold cannot be kept, and the module is composed as registered (it then waits as joining modules do). +// +// shelf, when given, has each held module's manifest replaced. It answers what it holds: module → the commit kept. +func holdJoining(ctx context.Context, inv *inventory.Inventory, node string, + shelf map[string]catalogue.Manifest) (map[string]string, error) { + registered := shelf + if registered == nil { + var err error + if registered, err = inv.Catalogue(ctx); err != nil { + return nil, err + } + } + sent, known, err := inv.SentBuilds(ctx, node) + if err != nil || !known { + return nil, err + } + var joining []string + for m := range sent { + if man, has := registered[m]; has && len(man.Libraries) > 0 { + joining = append(joining, m) + } + } + if len(joining) == 0 { + return nil, nil + } + sort.Strings(joining) + libs, err := inv.Libraries(ctx) + if err != nil { + return nil, err + } + taken, err := librariesTaken(ctx, inv, libs) + if err != nil { + return nil, err + } + current, err := inv.CurrentBuilds(ctx) + if err != nil { + return nil, err + } + out := map[string]string{} + for _, m := range joining { + was := sent[m] + if was == "" || sameCommit(was, current[m].Commit) { + continue // the machine already runs the registered build: nothing before it to keep + } + untaken := "" + for _, u := range registered[m].Libraries { + if !taken[u.Library] { + untaken = u.Library + break + } + } + if untaken == "" { + continue + } + before, found, err := inv.ManifestAt(ctx, m, was) + if err != nil { + return nil, err + } + if !found || usesLibrary(before, untaken) { + continue + } + if shelf != nil { + shelf[m] = before + } + out[m] = was + } + return out, nil +} + +func usesLibrary(m catalogue.Manifest, library string) bool { + for _, u := range m.Libraries { + if u.Library == library { + return true + } + } + return false +} diff --git a/cmd/mesh-controller/held_library_test.go b/cmd/mesh-controller/held_library_test.go new file mode 100644 index 00000000..0da64639 --- /dev/null +++ b/cmd/mesh-controller/held_library_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +const heldImage = "x@sha256:2222222222222222222222222222222222222222222222222222222222222222" + +// radarrBefore is radarr as it ran before it joined: its own port, its own data, PUID from a setting. +func radarrBefore() catalogue.Manifest { + return catalogue.Manifest{Module: "radarr", Version: "1", Capabilities: []string{"container-runtime"}, + Listens: []catalogue.Listening{{Port: 7878, From: catalogue.FromMesh, Name: "web", Why: "its web UI"}}, + Resources: []map[string]any{ + {"id": "config", "type": "directory", "mode": "0750"}, + {"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"}, + "env": map[string]any{"PUID": "1000"}, "volumes": []any{"${dir:config}:/config"}}, + }} +} + +// radarrJoined is the same module's next build, joining the media library. +func radarrJoined() catalogue.Manifest { + m := radarrBefore() + m.Libraries = []catalogue.LibraryUse{{ID: "movies", Library: "media", Path: "movies", Use: catalogue.AccessReadWrite}} + m.Resources = []map[string]any{ + {"id": "config", "type": "directory", "mode": "0750"}, + {"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"}, + "env": map[string]any{"PUID": "${identity:uid}"}, + "volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}, + } + return m +} + +// novox/hq ADR 0328 §8 (the review of 7ea6d36e, item 1): a joining module that ran on a machine before is held until +// its libraries are taken — composed EXACTLY as before it joined, at the build the machine runs: its resources, its +// port in the filter, its contributions; only the new build waits. The record says the machine still carries that build. +func TestAHeldModuleIsComposedAsBeforeItJoined(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + controller := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"} + register(t, open, controller) + if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{ + "media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil { + t.Fatal(err) + } + before, joined := radarrBefore(), radarrJoined() + raw, _ := json.Marshal(before) + b1 := inventory.Build{ID: "b1", Repository: "https://forge.invalid/media.git", Ref: "main", Module: "radarr", + Commit: "c1c1c1c1", On: "builder", Manifest: raw} + if err := inv.RecordBuild(ctx, b1); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, before, inventory.Source{Repository: b1.Repository, Ref: "main", BuiltFrom: b1.Commit}); err != nil { + t.Fatal(err) + } + if _, err := assign(ctx, open, "anchor", "radarr"); err != nil { + t.Fatal(err) + } + gens, err := generators(ctx, open) + if err != nil { + t.Fatal(err) + } + // What anchor composes for radarr before it joined. + plan, settings, err := planFor(ctx, open, "anchor") + if err != nil { + t.Fatal(err) + } + was, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading) + if err != nil { + t.Fatal(err) + } + wasRules, err := plan.Rules(catalogue.Rendering{Settings: settings}) + if err != nil { + t.Fatal(err) + } + record, err := inv.NodeByName(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": b1.Commit}, 1, 1, + inventory.Send{Sequence: 1}); err != nil { + t.Fatal(err) + } + // Anchor's engine keeps libraries; the next build joins media, which is not taken. + if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.LibraryContract, + SaidAt: time.Now(), HeardAt: time.Now()}); err != nil { + t.Fatal(err) + } + raw, _ = json.Marshal(joined) + b2 := b1 + b2.ID, b2.Commit, b2.Manifest = "b2", "c2c2c2c2", raw + if err := inv.RecordBuild(ctx, b2); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, joined, inventory.Source{Repository: b2.Repository, Ref: "main", BuiltFrom: b2.Commit}); err != nil { + t.Fatal(err) + } + + plan, settings, err = planFor(ctx, open, "anchor") + if err != nil { + t.Fatal(err) + } + held, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading) + if err != nil { + t.Fatal(err) + } + if why, left := held.leftOutWhy["radarr"]; left { + t.Fatalf("radarr was left out while held: %s", why) + } + if !reflect.DeepEqual(radarrResources(was.Resources), radarrResources(held.Resources)) { + t.Fatalf("radarr held is composed otherwise than before it joined:\n %v\n %v", radarrResources(was.Resources), + radarrResources(held.Resources)) + } + rules, err := plan.Rules(catalogue.Rendering{Settings: settings}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(rules, wasRules) || !strings.Contains(asJSON(rules), "7878") { + t.Fatalf("radarr's port is not in the filter as before: %s", asJSON(rules)) + } +} + +func radarrResources(rs []map[string]any) []map[string]any { + var out []map[string]any + for _, r := range rs { + if strings.HasPrefix(r["id"].(string), "radarr.") { + out = append(out, r) + } + } + return out +} + +func asJSON(v any) string { + raw, _ := json.Marshal(v) + return string(raw) +} diff --git a/cmd/mesh-controller/kept_libraries.go b/cmd/mesh-controller/kept_libraries.go index cac22a78..d5d7922b 100644 --- a/cmd/mesh-controller/kept_libraries.go +++ b/cmd/mesh-controller/kept_libraries.go @@ -57,8 +57,11 @@ type libraryTake struct { // takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant. const takeVerb = askerName + ".take" -// key is the library on its home and filesystem: what one ask is kept for. A library that moved is asked again. -func (t libraryTake) key() string { return t.Library + "\x00" + t.Node + "\x00" + t.Placed } +// key is the library on its home, at its root, on its filesystem: what one ask is kept for. A library that moved is +// asked again. +func (t libraryTake) key() string { + return t.Library + "\x00" + t.Node + "\x00" + t.Root + "\x00" + t.Placed +} // digest binds everything the take does and was said for. func (t libraryTake) digest() string { @@ -264,12 +267,14 @@ func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessAct if answer.Refused != "" { return "", fmt.Errorf("%s refused to take the library %s: %s", t.Node, t.Library, answer.Refused) } - if err := open.inventory.RecordTaken(ctx, inventory.TakenLibrary{Library: t.Library, Home: t.Node, - Placed: t.Placed, By: by, Ask: askID, Warrant: askID, Said: answer.Said}); err != nil { + // Started, not taken: the library is taken when the engine states the act done (stateHealth, TakeDone). + if err := open.inventory.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: t.Library, Home: t.Node, + Root: t.Root, Placed: t.Placed, By: by, Ask: askID, Warrant: askID, Said: answer.Said}); err != nil { return "", fmt.Errorf("%s started the take of %s (%s), and it could NOT be recorded: %w", t.Node, t.Library, answer.Said, err) } - return fmt.Sprintf("%s takes the library %s: %s", t.Node, t.Library, answer.Said), nil + return fmt.Sprintf("%s started taking the library %s: %s; it is taken when the engine says the act is done", + t.Node, t.Library, answer.Said), nil } } @@ -341,10 +346,24 @@ func foldersIn(open *stores) func(ctx context.Context) ([]libraryTake, error) { return nil, err } waiting, f := foldersWaiting(ctx, open.inventory, healths) - return takesOf(waiting, f.libraries), nil + return withoutTakesUnderWay(takesOf(waiting, f.libraries), f.taken), nil } } +// withoutTakesUnderWay is the takes not already under way: a take started on the same home, root and filesystem, which +// the engine has not yet stated done or failed, is not asked about again (the review of 7ea6d36e, item 5). +func withoutTakesUnderWay(takes []libraryTake, recorded map[string]inventory.TakenLibrary) []libraryTake { + var out []libraryTake + for _, t := range takes { + r, was := recorded[t.Library] + if was && !r.Done && r.Home == t.Node && r.Root == t.Root && (t.Placed == "" || r.Placed == "" || r.Placed == t.Placed) { + continue + } + out = append(out, t) + } + return out +} + // takesOf is one take per library and filesystem from the waits gathered, sorted. A library no longer defined is // asked about nowhere. func takesOf(waiting map[string][]waitingFolder, libraries map[string]catalogue.Library) []libraryTake { diff --git a/cmd/mesh-controller/kept_libraries_test.go b/cmd/mesh-controller/kept_libraries_test.go index f65ff83a..f832d2dd 100644 --- a/cmd/mesh-controller/kept_libraries_test.go +++ b/cmd/mesh-controller/kept_libraries_test.go @@ -49,7 +49,7 @@ func diskWait(access string) inventory.Wait { func libraryFactsWith(taken ...string) operatorWaitFacts { t := map[string]inventory.TakenLibrary{} for _, l := range taken { - t[l] = inventory.TakenLibrary{Library: l, Home: "ace", Placed: "fs-000000000001"} + t[l] = inventory.TakenLibrary{Library: l, Home: "ace", Root: "/storage/media", Placed: "fs-000000000001", Done: true} } return operatorWaitFacts{ manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest}, @@ -82,6 +82,12 @@ func TestALibraryWaitIsExcusedOnlyForALibraryNotYetTaken(t *testing.T) { libraryFactsWith("media"), "taken already"}, {"taken on another filesystem: it moved", "ace", placedWait("library:media", "fs-0000000000ff"), libraryFactsWith("media"), ""}, + {"taken at another root: its root changed", "ace", keepWait("movies"), func() operatorWaitFacts { + f := libraryFactsWith("media") + f.taken["media"] = inventory.TakenLibrary{Library: "media", Home: "ace", Root: "/storage/old-media", Done: true, + Placed: "fs-000000000001"} + return f + }(), ""}, {"a machine it does not reach", "far", keepWait("movies"), libraryFactsWith(), "does not reach far"}, {"undefined", "ace", keepWait("library:books"), libraryFactsWith(), "not defined"}, {"not a library", "ace", keepWait("downloads"), libraryFactsWith(), "only a library waits"}, @@ -432,10 +438,21 @@ func TestALibrarysVerdictIsSaidForTheModulesThatUseIt(t *testing.T) { if err := inv.RegisterModule(ctx, m, inventory.Source{}); err != nil { t.Fatal(err) } + if m.Module == "plex" { + continue // pulled in by a requirement, not assigned: it is in what the machine was sent (item 4) + } if _, err := inv.Assign(ctx, "ace", m.Module); err != nil { t.Fatal(err) } } + record, err := inv.NodeByName(ctx, "ace") + if err != nil { + t.Fatal(err) + } + if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1", "plex": "c2", "plain": "c3"}, + 1, 1, inventory.Send{Sequence: 1}); err != nil { + t.Fatal(err) + } lib := inventory.ResourceHealth{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media", State: link.StateWaiting, Waits: []inventory.Wait{{Part: "media", Access: "library:media", Reason: link.WaitKeep, Placed: "fs-000000000001", What: "let the mesh keep the library media"}}} @@ -447,6 +464,13 @@ func TestALibrarysVerdictIsSaidForTheModulesThatUseIt(t *testing.T) { if strings.Join(modules, ",") != "plex,radarr" { t.Fatalf("the library's verdict is said for %v; want plex and radarr, the modules using it", modules) } + paths, err := inv.LibraryPaths(ctx) + if err != nil { + t.Fatal(err) + } + if strings.Join(paths["media"], ",") != "movies" { + t.Fatalf("the paths kept of media are %v", paths["media"]) + } now := time.Now() f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false}, accessesChanged: map[string]bool{"radarr": false}, @@ -468,3 +492,54 @@ func TestALibrarysVerdictIsSaidForTheModulesThatUseIt(t *testing.T) { f.accessesChanged = map[string]bool{"radarr": false} } } + +// The review of 7ea6d36e, item 5: the engine saying it started a take does not make the library taken. While the act +// runs the library still waits and the operator is not asked again; it is taken when the engine states the act done +// under that warrant. +func TestALibraryIsTakenOnlyWhenTheEngineSaysTheActIsDone(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"}) + if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{ + "media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil { + t.Fatal(err) + } + take := takeFolderIn(open, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) { + return link.HandOverAnswer{Said: "the take of library:media started"}, nil + }) + lt := libraryTake{Library: "media", Node: "anchor", Root: "/storage/media", Placed: "fs-000000000001"} + if _, err := take(ctx, lt, "c01", "the operator"); err != nil { + t.Fatal(err) + } + libs, err := inv.Libraries(ctx) + if err != nil { + t.Fatal(err) + } + taken, err := librariesTaken(ctx, inv, libs) + if err != nil { + t.Fatal(err) + } + if taken["media"] { + t.Fatal("a take only started made the library taken") + } + // While it runs: the wait is still excused, and no new ask is made for it. + var f operatorWaitFacts + readWaitFacts(ctx, inv, "anchor", nil, nil, &f) + if err := checkWait("radarr", "anchor", placedWait("library:media", "fs-000000000001"), f); err != nil { + t.Fatalf("the wait while the take runs: %v", err) + } + if got := withoutTakesUnderWay([]libraryTake{lt}, f.taken); len(got) != 0 { + t.Fatalf("a library whose take runs is asked about again: %+v", got) + } + // The engine states the act done under that warrant. + act, _ := json.Marshal(map[string]any{"act": "take", "access": "library:media", "warrant": "c01", "state": "done"}) + if err := stateHealth(ctx, inv, nil, "anchor", link.Health{Contract: link.LibraryContract, At: time.Now(), + Resources: []link.ResourceHealth{{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media", + State: link.StateHealthy, Since: time.Now(), Act: act}}}, time.Now()); err != nil { + t.Fatal(err) + } + if taken, err = librariesTaken(ctx, inv, libs); err != nil || !taken["media"] { + t.Fatalf("the act done did not make the library taken (%v)", err) + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 032f6ad8..de1946fc 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -2,7 +2,9 @@ package main import ( "context" + "encoding/json" "fmt" + "slices" "sort" "strings" "sync/atomic" @@ -41,7 +43,7 @@ const ( ) // libraryVerdictsFor is, for each library verdict in a statement (kind library, no module), one copy for each module -// assigned to the machine whose manifest uses that library: what the module is judged and said by. None when the +// on the machine — assigned, or pulled in by a requirement — whose manifest uses that library: what the module is judged and said by. None when the // assignments or the catalogue cannot be read: the library's own verdict stands alone, and no module is excused by it. func libraryVerdictsFor(ctx context.Context, inv *inventory.Inventory, node string, rs []inventory.ResourceHealth) []inventory.ResourceHealth { @@ -59,6 +61,18 @@ func libraryVerdictsFor(ctx context.Context, inv *inventory.Inventory, node stri // empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make return nil } + // And the modules a requirement pulled in: what the machine was last sent (the review of 7ea6d36e, item 4). + sent, _, err := inv.SentBuilds(ctx, node) + if err != nil { + // empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make + return nil + } + for m := range sent { + if !slices.Contains(assigned, m) { + assigned = append(assigned, m) + } + } + sort.Strings(assigned) shelf, err := inv.Catalogue(ctx) if err != nil { // empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make @@ -81,6 +95,39 @@ func libraryVerdictsFor(ctx context.Context, inv *inventory.Inventory, node stri return out } +// takesStated keeps what a statement says of each library's take act: done under a warrant, the library is taken; +// failed, the take is forgotten and the operator asked again. Running or stopped (it goes on) changes nothing. +func takesStated(ctx context.Context, inv *inventory.Inventory, rs []link.ResourceHealth) error { + if inv == nil { + return nil + } + for _, r := range rs { + lib, isLibrary := strings.CutPrefix(r.Resource, libraryAccess) + if !isLibrary || r.Kind != link.KindLibrary || len(r.Act) == 0 { + continue + } + var act struct { + Act string `json:"act"` + Warrant string `json:"warrant"` + State string `json:"state"` + } + if err := json.Unmarshal(r.Act, &act); err != nil || act.Act != link.ActTake || act.Warrant == "" { + continue + } + var err error + switch act.State { + case "done": + err = inv.TakeDone(ctx, lib, act.Warrant) + case "failed": + err = inv.TakeFailed(ctx, lib, act.Warrant) + } + if err != nil { + return err + } + } + return nil +} + // healthRefused counts the statements refused as older than the one kept, for the log and a test. var healthRefused atomic.Int64 @@ -126,6 +173,11 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke } resources = append(resources, kept) } + // **A library's take is done, or failed, when its engine says so** (novox/hq ADR 0328 §7): only then is it taken, + // or asked about again. + if err := takesStated(ctx, inv, h.Resources); err != nil { + return err + } // **A library's verdict is said for the modules that use it** (novox/hq ADR 0328 §8): the engine states each // library once, as `library:`, of no module; a module that uses it waits with it, and is said so. resources = append(resources, libraryVerdictsFor(ctx, inv, node, resources)...) diff --git a/cmd/mesh-controller/operator_wait.go b/cmd/mesh-controller/operator_wait.go index cfebe241..5202600a 100644 --- a/cmd/mesh-controller/operator_wait.go +++ b/cmd/mesh-controller/operator_wait.go @@ -138,7 +138,8 @@ func checkLibraryWait(machine, lib string, w inventory.Wait, f operatorWaitFacts if machine != l.Home { placed = "" } - if t, was := f.taken[lib]; was && t.Home == l.Home && (placed == "" || t.Placed == "" || t.Placed == placed) { + if t, was := f.taken[lib]; was && t.Done && t.Home == l.Home && t.Root == l.Root && + (placed == "" || t.Placed == "" || t.Placed == placed) { return fmt.Errorf("says it waits for the library %s, which is taken already: a module that uses a taken "+ "library is never asked about it", lib) } diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index a5d6630d..a487aadd 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -134,6 +134,10 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil { return catalogue.Resolution{}, nil, err } + // And a module that joins a library not yet taken, at the build it ran before (novox/hq ADR 0328 §8). + if _, err := holdJoining(ctx, inv, nodeName, shelf); err != nil { + return catalogue.Resolution{}, nil, err + } resolved, err := catalogue.Resolve(shelf, assigned, catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities, @@ -473,11 +477,22 @@ func declarationWith(ctx context.Context, open *stores, node string, 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). + // A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245), and so + // is a joining module held at the build it ran before (ADR 0328 §8). kept, err := recordedKept(ctx, open, node) if err != nil { return sendable{}, err } + held, err := holdJoining(ctx, open.inventory, node, nil) + if err != nil { + return sendable{}, err + } + if kept == nil && len(held) > 0 { + kept = map[string]string{} + } + for m, was := range held { + kept[m] = was + } for m, was := range kept { if _, carried := out.Builds[m]; carried { out.Builds[m] = was @@ -987,7 +1002,7 @@ func renderingFor(ctx context.Context, open *stores, node string, ReadsHealth: readsHealth, JudgesRoot: judgesRoot, KeepsLibraries: lf.keeps, Identities: lf.identities, Libraries: lf.libraries, LibraryGroups: lf.groups, - TakenLibraries: lf.taken, LibraryPaths: lf.paths, Held: lf.held, + TakenLibraries: lf.taken, LibraryPaths: lf.paths, RunnerJoinsOwnGroups: lf.ownGroups, BusMembership: memberships[node], Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, @@ -1039,12 +1054,11 @@ type libraryRendering struct { groups map[string]int taken map[string]bool paths map[string][]string - held map[string]string + ownGroups bool } // libraryFacts reads what a machine's composition needs of ADR 0328: the libraries and their group numbers, the -// numbers of the modules here that join, which libraries are taken, the sub-paths every module asks of each, and which -// joining modules here are held at the user they ran as. **A number is allocated only on the send path** (Allocating), +// numbers of the modules here that join, which libraries are taken, and the sub-paths every module asks of each. **A number is allocated only on the send path** (Allocating), // as a port is: a question about what would be sent reads what was allocated. The libraries are read for every engine, // so a module that joins is left out, naming why, where its engine cannot be told it does. func libraryFacts(ctx context.Context, inv *inventory.Inventory, node string, plan catalogue.Resolution, @@ -1069,6 +1083,11 @@ func libraryFacts(ctx context.Context, inv *inventory.Inventory, node string, pl if lf.keeps, err = engineKeepsLibraries(ctx, inv, node); err != nil || !lf.keeps { return lf, err } + if stated, had, err := inv.HealthOf(ctx, node); err != nil { + return lf, err + } else { + lf.ownGroups = had && stated.Contract >= link.OwnGroupContract + } lf.identities = map[string]catalogue.ModuleIdentity{} for _, m := range plan.Modules { if !catalogue.Joins(m) { @@ -1107,26 +1126,6 @@ func libraryFacts(ctx context.Context, inv *inventory.Inventory, node string, pl if lf.taken, err = librariesTaken(ctx, inv, libs); err != nil { return lf, err } - // **A joining module that ran here before keeps the user it ran as until every library it uses is taken** (§8): - // held, it is left out of the declaration, its containers untouched. One new here has nothing to keep. - sent, known, err := inv.SentBuilds(ctx, node) - if err != nil { - return lf, err - } - for _, m := range plan.Modules { - if _, ran := sent[m.Module]; !known || !ran || !catalogue.Joins(m) { - continue - } - for _, u := range m.Libraries { - if !lf.taken[u.Library] { - if lf.held == nil { - lf.held = map[string]string{} - } - lf.held[m.Module] = u.Library - break - } - } - } return lf, nil } @@ -1140,7 +1139,7 @@ func librariesTaken(ctx context.Context, inv *inventory.Inventory, libs map[stri out := map[string]bool{} for name, l := range libs { t, was := recorded[name] - taken := was && t.Home == l.Home + taken := was && t.Done && t.Home == l.Home && t.Root == l.Root stated, had, err := inv.HealthOf(ctx, l.Home) if err != nil { return nil, err diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index e29af6b7..6a0be40c 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -261,9 +261,10 @@ type Rendering struct { LibraryGroups map[string]int TakenLibraries map[string]bool LibraryPaths map[string][]string - // Held is, by module, the library a joining module that ran here before waits on: until every library it uses - // is taken its containers keep the user they ran as, so it is left out of the declaration (§8). - Held map[string]string + // RunnerJoinsOwnGroups says this machine's engine puts the tool runner's account in a joining module's own group + // (novox/hq ADR 0334 §2: its statement's contract is link.OwnGroupContract or later); to an older one the + // identity's runner is not sent. + RunnerJoinsOwnGroups bool } // RootField is a user resource's field saying the account must never become root without a person diff --git a/internal/catalogue/libraries.go b/internal/catalogue/libraries.go index dcd74d87..7c8ea78e 100644 --- a/internal/catalogue/libraries.go +++ b/internal/catalogue/libraries.go @@ -371,6 +371,7 @@ func libraryProblems(m Manifest) []string { if !Joins(m) { return problems } + problems = append(problems, toolsDirectoryProblems(m)...) for _, r := range m.Resources { if fmt.Sprint(r["type"]) == "directory" { if owner, said := r["owner"]; said { @@ -383,6 +384,55 @@ func libraryProblems(m Manifest) []string { return problems } +// toolsDirectoryProblems refuses a joining module whose tools name one of its directories whose mode closes it to its +// group (novox/hq ADR 0334 §3): the tool runner reads it through the module's own group, so it needs group read and +// enter. +func toolsDirectoryProblems(m Manifest) []string { + modes := map[string]string{} + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "directory" { + mode, _ := r["mode"].(string) + modes[fmt.Sprint(r["id"])] = mode + } + } + var words []string + for _, b := range m.Bundles { + for _, v := range b.Env { + words = append(words, v) + } + } + if m.Build != nil { + for _, a := range m.Build.Artifacts { + for _, v := range a.Env { + words = append(words, v) + } + } + } + seen := map[string]bool{} + var problems []string + for _, w := range words { + for _, match := range dirRef.FindAllStringSubmatch(w, -1) { + id := match[1] + mode, declared := modes[id] + if !declared || seen[id] { + continue + } + seen[id] = true + var n uint64 + if _, err := fmt.Sscanf(mode, "%o", &n); err != nil || mode == "" { + continue // its mode is judged where the directory is + } + if n&0o050 != 0o050 { + problems = append(problems, fmt.Sprintf("%s's tools name its directory %s, whose mode %s closes it to its "+ + "own group: the tool runner reads it through that group, so it needs group read and enter (0750) "+ + "(novox/hq ADR 0334 §3)", m.Module, id, mode)) + } + } + } + sort.Strings(problems) + return problems +} + // LibraryWarnings is what the module check warns of a module's libraries (§4): an image that drops added groups // reaches only the first library the module uses. func LibraryWarnings(m Manifest) []string { @@ -511,8 +561,9 @@ func joinedOf(m Manifest, with Rendering) (Joined, error) { } // libraryLeftOut is why module m is left out of this machine's declaration for its libraries, or "": a library it -// uses not defined or not reaching the machine (§2), an engine that cannot be told it joins, or a container held at -// the user it ran as until every library it uses is taken (§8). +// uses not defined or not reaching the machine (§2), or an engine that cannot be told it joins. A module held at the +// user it ran as until its libraries are taken (§8) is not left out: it is composed at the build it ran before it +// joined (cmd/mesh-controller holdJoining). func libraryLeftOut(m Manifest, node string, with Rendering) string { if !Joins(m) { return "" @@ -534,10 +585,6 @@ func libraryLeftOut(m Manifest, node string, with Rendering) string { if _, has := with.Identities[m.Module]; !has { return fmt.Sprintf("%s joins, and its number is allocated at its first send", m.Module) } - if lib := with.Held[m.Module]; lib != "" { - return fmt.Sprintf("%s keeps the user it ran as until the library %s is taken: the operator is asked once "+ - "(%s §8)", m.Module, lib, ADR0328) - } return "" } @@ -583,8 +630,10 @@ func joinedInto(resource map[string]any, m Manifest, j Joined, paths map[string] } resource[k] = filled } + // Its own directories belong to its user and its own group (ADR 0334 §1): the library's group is its processes' + // primary, so what they make in a library keeps that group, and its own data stays its own. if fmt.Sprint(resource["type"]) == "directory" { - resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.PrimaryGID) + resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.UID) } if fmt.Sprint(resource["type"]) != "container" { return nil @@ -734,6 +783,9 @@ type IdentityEntry struct { UID int `json:"uid"` GID int `json:"gid"` Groups []int `json:"groups,omitempty"` + // Runner is the tool runner's account, put in this module's own group, where its tools run there (ADR 0334 §2). + // Only to an engine that reads it (Rendering.RunnerJoinsOwnGroups). + Runner string `json:"runner,omitempty"` } // libraryEnvelope is what a machine's declaration says of libraries and joining modules: the group of every library @@ -753,13 +805,20 @@ func (r Resolution) libraryEnvelope(with Rendering, joined []Joined) ([]GroupEnt for _, j := range joined { e := IdentityEntry{Module: j.Module, Name: IdentityName(j.Module), UID: j.UID, GID: j.PrimaryGID, Groups: append([]int(nil), j.AddedGIDs...)} + if with.RunnerJoinsOwnGroups { + for _, m := range r.Modules { + if m.Module == j.Module { + e.Runner = r.toolsAccount(m, with) + } + } + } ids = append(ids, e) } sort.Slice(ids, func(a, b int) bool { return ids[a].Module < ids[b].Module }) used := map[string]map[string]bool{} runner := map[string]string{} for _, m := range r.Modules { - if with.Held[m.Module] == "" && libraryLeftOut(m, r.Node, with) != "" { + if libraryLeftOut(m, r.Node, with) != "" { continue } for _, u := range m.Libraries { diff --git a/internal/catalogue/libraries_test.go b/internal/catalogue/libraries_test.go index bbe6a999..2b0ab87b 100644 --- a/internal/catalogue/libraries_test.go +++ b/internal/catalogue/libraries_test.go @@ -88,8 +88,8 @@ func TestAJoiningModuleIsComposedWithItsIdentityAndLibraries(t *testing.T) { "PUID=50020\nPGID=50010\nUSER=mesh-radarr\nGROUP=mesh-media\nUMASK=002\n" { t.Errorf("the identity file reads %q", content) } - if owner := got["radarr.config"]["owner"]; owner != "50020:50010" { - t.Errorf("its directory is owned by %v; want its user and primary group", owner) + if owner := got["radarr.config"]["owner"]; owner != "50020:50020" { + t.Errorf("its directory is owned by %v; want its user and own group (ADR 0334 §1)", owner) } if len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr", Name: "mesh-radarr", UID: 50020, GID: 50010, Groups: []int{50011}}) { @@ -144,37 +144,46 @@ func TestALibraryNotReachingTheMachineLeavesItsModuleOut(t *testing.T) { } } -// Row 8: a joining module that ran here before keeps the user it ran as until its libraries are taken — left out, its -// containers untouched — while its libraries are still named, so the engine states the wait and the take can come. -func TestAJoiningModuleIsHeldUntilItsLibrariesAreTaken(t *testing.T) { - with := librariesRendering() - with.TakenLibraries["media"] = false - with.Held = map[string]string{"radarr": "media"} - composed := composeLibraries(t, Resolution{Node: "ace", Account: "jochen", - Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with) - if why := composed.LeftOut["radarr"]; !strings.Contains(why, "keeps the user it ran as until the library media is taken") { - t.Fatalf("radarr while media is not taken: %q", why) - } - if byID(composed.Resources)["radarr.app"] != nil { - t.Fatal("a held module's container was sent") - } - found := false - for _, l := range composed.Libraries { - found = found || (l.Library == "media" && !l.Taken) - } - if !found { - t.Fatalf("the library a held module waits on is not named: %+v", composed.Libraries) - } -} - -// Row 14: a module that neither declares libraries nor uses its identity is composed exactly as before, on an engine -// that keeps libraries or not; and a joining module on an older engine is left out, naming why. +// Row 14: a module that neither declares libraries nor uses its identity is composed exactly as before — the whole +// declaration: its resources, the filter its port reaches, the contributions it makes — on an engine that keeps +// libraries or not; on a machine a library reaches, only that library's group is added. A joining module on an older +// engine is left out, naming why. func TestAModuleThatDoesNotJoinIsComposedAsBefore(t *testing.T) { - r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aPlain(), theRuntime(t)}} - before := composeLibraries(t, r, Rendering{}) - after := composeLibraries(t, r, librariesRendering()) - if !reflect.DeepEqual(before.Resources, after.Resources) { - t.Fatalf("a module that does not join composed otherwise:\n %v\n %v", before.Resources, after.Resources) + plain := aPlain() + plain.Listens = []Listening{{Port: 8080, From: FromEverywhere, Name: "web", Why: "its page"}} + plain.Contributes = map[string]map[string]any{"reverse-proxy": {"host": "plain", "port": 8080}} + plain.Requires = []string{"reverse-proxy"} + modules := []Manifest{plain, proxy(), {Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}}, + theRuntime(t)} + base := Rendering{Mesh: []string{"198.51.100.2"}, OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"} + keeping := func(node string) Rendering { + with := librariesRendering() + with.Mesh, with.OutwardLinks, with.TunnelInterface = base.Mesh, base.OutwardLinks, base.TunnelInterface + return with + } + for _, node := range []string{"far", "ace"} { + r := Resolution{Node: node, Account: "jochen", Modules: modules} + before := composeLibraries(t, r, base) + after := composeLibraries(t, r, keeping(node)) + if node == "far" { + if !reflect.DeepEqual(before, after) { + t.Fatalf("on a machine no library reaches, a module that does not join composed otherwise:\n %+v\n %+v", + before, after) + } + continue + } + groups := after.Groups + after.Groups, after.Libraries = nil, nil + if !reflect.DeepEqual(before, after) { + t.Fatalf("on %s, a module that does not join composed otherwise:\n %+v\n %+v", node, before, after) + } + if len(groups) == 0 || len(after.Identities) > 0 { + t.Fatalf("on the library's home: groups %v, identities %v", groups, after.Identities) + } + filter := fmt.Sprint(byID(before.Resources)["nftables."+FilteringID()]["content"]) + if !strings.Contains(filter, "8080") || received(t, before.Resources)[0].From != "plain" { + t.Fatalf("the comparison held no filter or contribution of the module: %s", filter) + } } if Joins(aPlain()) || !Joins(aRadarr(t)) { t.Fatal("joining is not read from the libraries and the identity") @@ -338,3 +347,73 @@ func TestTheEngineTakesWhatIsComposedForLibraries(t *testing.T) { } } } + +// novox/hq ADR 0334 §1, §2 (the review of 7ea6d36e, item 2): a joining module's own directories are owned by its user +// and its own group, not the library's; and the tool runner's account joins the own group of every joining module whose +// tools run on the machine — sent only to an engine that reads it (link.OwnGroupContract). +func TestAJoiningModulesOwnDirectoriesAreItsOwnGroups(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}} + got := byID(composeLibraries(t, r, librariesRendering()).Resources) + if owner := got["radarr.config"]["owner"]; owner != "50020:50020" { + t.Errorf("its directory is owned by %v; want its user and its own group", owner) + } + if env := got["radarr.app"]["env"].(map[string]any); env["PGID"] != "50010" { + t.Errorf("its process's primary group is %v; want the library's, so what it makes there keeps the group", env["PGID"]) + } + older := composeLibraries(t, r, librariesRendering()) + if older.Identities[0].Runner != "" { + t.Errorf("an engine that cannot put the runner in a module's own group was told %q", older.Identities[0].Runner) + } + with := librariesRendering() + with.RunnerJoinsOwnGroups = true + if got := composeLibraries(t, r, with).Identities[0].Runner; got != "jochen" { + t.Errorf("the runner joins %q's own group; want jochen, whose tools it runs", got) + } +} + +// novox/hq ADR 0334 §3: a joining module whose tools name a directory whose mode closes it to its group is refused at +// the module check, naming the directory. +func TestAToolsDirectoryIsOpenToItsGroup(t *testing.T) { + closed := map[string]any{ + "libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read"}}, + "resources": []any{ + map[string]any{"id": "config", "type": "directory", "mode": "0700"}, + map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage, + "volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}}, + "build": map[string]any{"artifacts": []any{map[string]any{"name": "tools", "kind": "bundle", + "language": "go", "system": "arch", "from": "cmd/app", "binary": "app", "loads": []any{"app"}, + "env": map[string]any{"APP_CONFIG": "${dir:config}/config.xml"}}}}} + _, err := ParseManifest(libraryManifest(t, closed)) + if err == nil || !strings.Contains(err.Error(), "config") || !strings.Contains(err.Error(), "ADR 0334") { + t.Fatalf("a tools' directory closed to its group: %v", err) + } + open := closed + open["resources"] = []any{ + map[string]any{"id": "config", "type": "directory", "mode": "0750"}, + map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage, + "volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}} + if _, err := ParseManifest(libraryManifest(t, open)); err != nil { + t.Fatalf("a tools' directory open to its group was refused: %v", err) + } +} + +// The review of 7ea6d36e, item 6: a service's unit takes the identity too — `User=${identity:user}` — at the module +// check and in what is composed. +func TestAServicesUnitTakesTheIdentity(t *testing.T) { + unit := "[Service]\nUser=${identity:user}\nGroup=${identity:group}\nUMask=0002\nExecStart=/usr/bin/importer\n" + raw := libraryManifest(t, map[string]any{"resources": []any{ + map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage, + "volumes": []any{"${library:movies}:/movies"}}, + map[string]any{"id": "watch", "type": "service", "name": "app-watch", "unit": unit}}}) + m, err := ParseManifest(raw) + if err != nil { + t.Fatalf("a unit naming the identity was refused: %v", err) + } + with := librariesRendering() + with.Identities["app"] = ModuleIdentity{Module: "app", UID: 50030} + composed := composeLibraries(t, Resolution{Node: "ace", Modules: []Manifest{m}}, with) + got := fmt.Sprint(byID(composed.Resources)["app.watch"]["unit"]) + if !strings.Contains(got, "User=mesh-app\nGroup=mesh-media\n") { + t.Fatalf("the unit reads %q", got) + } +} diff --git a/internal/catalogue/unconsumed_placeholder.go b/internal/catalogue/unconsumed_placeholder.go index 95c12921..c34eb911 100644 --- a/internal/catalogue/unconsumed_placeholder.go +++ b/internal/catalogue/unconsumed_placeholder.go @@ -61,7 +61,7 @@ var fillers = []filler{ {namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}}, // A joining module's identity, wherever its resources take values; a library's host path, only in a container's // volumes, whose source libraryProblems holds it to (novox/hq ADR 0328 §5). - {namespace: "identity", patterns: []*regexp.Regexp{identityRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file", "run", "command", "user", "name"}}}, + {namespace: "identity", patterns: []*regexp.Regexp{identityRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file", "run", "command", "user", "name", "unit"}}}, {namespace: "library", patterns: []*regexp.Regexp{libraryRef}, fields: map[string][]string{"container": {"volumes"}}, why: "a library's path is a host path, filled only in a container's volume source; the process reads the " + "container side its volume gives (novox/hq ADR 0328 §5)"}, diff --git a/internal/inventory/libraries.go b/internal/inventory/libraries.go index b86270ff..522dc9ab 100644 --- a/internal/inventory/libraries.go +++ b/internal/inventory/libraries.go @@ -179,13 +179,17 @@ func (i *Inventory) LibraryNamed(ctx context.Context, module string) error { return nil } -// LibraryPaths is every sub-path a module assigned anywhere asks of each library (§6): what its home keeps. +// LibraryPaths is every sub-path a module on any machine asks of each library (§6): what its home keeps. A module on a +// machine is one assigned there or one a requirement pulled in — what the machine was last sent. func (i *Inventory) LibraryPaths(ctx context.Context) (map[string][]string, error) { shelf, err := i.Catalogue(ctx) if err != nil { return nil, err } - rows, err := i.store.Pool().Query(ctx, `select distinct module from assignment`) + // Every module assigned anywhere, and every module a machine was last sent — those a requirement pulled in too. + rows, err := i.store.Pool().Query(ctx, + `select module from assignment + union select jsonb_object_keys(sent_builds) from node where sent_builds is not null and jsonb_typeof(sent_builds) = 'object'`) if err != nil { return nil, err } @@ -219,17 +223,20 @@ func (i *Inventory) LibraryPaths(ctx context.Context) (map[string][]string, erro type TakenLibrary struct { Library string Home string + Root string Placed string By string Ask string Warrant string Said string + // Done is whether the engine stated the take's act done: only then is the library taken. + Done bool } -// TakenLibraries is every library taken, by name. +// TakenLibraries is every library whose take was started, by name; Done says it is taken. func (i *Inventory) TakenLibraries(ctx context.Context) (map[string]TakenLibrary, error) { rows, err := i.store.Pool().Query(ctx, - `select library, home, placed, taken_by, ask, warrant, said from kept_library`) + `select library, home, root, placed, taken_by, ask, warrant, said, done from kept_library`) if err != nil { return nil, err } @@ -237,7 +244,7 @@ func (i *Inventory) TakenLibraries(ctx context.Context) (map[string]TakenLibrary out := map[string]TakenLibrary{} for rows.Next() { var t TakenLibrary - if err := rows.Scan(&t.Library, &t.Home, &t.Placed, &t.By, &t.Ask, &t.Warrant, &t.Said); err != nil { + if err := rows.Scan(&t.Library, &t.Home, &t.Root, &t.Placed, &t.By, &t.Ask, &t.Warrant, &t.Said, &t.Done); err != nil { return nil, err } out[t.Library] = t @@ -245,23 +252,41 @@ func (i *Inventory) TakenLibraries(ctx context.Context) (map[string]TakenLibrary return out, rows.Err() } -// RecordTaken keeps that a library was taken on its home, under the operator's warrant. Once per home and filesystem: -// a second take of the same keeps the first, and one on another home or filesystem (it moved, and the operator was -// asked again) replaces it. -func (i *Inventory) RecordTaken(ctx context.Context, t TakenLibrary) error { +// RecordTakeStarted keeps that a take of a library was started on its home, under the operator's warrant: the +// engine said it began. The library is taken only once the engine states the act done (TakeDone). Once per home, root +// and filesystem: a second start of the same keeps the first, and one on another (it moved, and the operator was asked +// again) replaces it, not done. +func (i *Inventory) RecordTakeStarted(ctx context.Context, t TakenLibrary) error { switch { - case t.Library == "" || t.Home == "": - return errors.New("a taken library names itself and its home") + case t.Library == "" || t.Home == "" || !strings.HasPrefix(t.Root, "/"): + return errors.New("a taken library names itself, its home and its root") case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "": return errors.New("a library is taken only on the operator's warrant, and this names none") } _, err := i.store.Pool().Exec(ctx, - `insert into kept_library (library, home, placed, taken_by, ask, warrant, said) - values ($1, $2, $3, $4, $5, $6, $7) + `insert into kept_library (library, home, root, placed, taken_by, ask, warrant, said, done) + values ($1, $2, $3, $4, $5, $6, $7, $8, false) on conflict (library) do update - set home = excluded.home, placed = excluded.placed, taken_at = now(), taken_by = excluded.taken_by, - ask = excluded.ask, warrant = excluded.warrant, said = excluded.said - where kept_library.home <> excluded.home or kept_library.placed <> excluded.placed`, - t.Library, t.Home, t.Placed, t.By, t.Ask, t.Warrant, t.Said) + set home = excluded.home, root = excluded.root, placed = excluded.placed, taken_at = now(), + taken_by = excluded.taken_by, ask = excluded.ask, warrant = excluded.warrant, said = excluded.said, + done = false + where kept_library.home <> excluded.home or kept_library.root <> excluded.root + or kept_library.placed <> excluded.placed`, + t.Library, t.Home, t.Root, t.Placed, t.By, t.Ask, t.Warrant, t.Said) + return err +} + +// TakeDone keeps that the engine stated a library's take done under a warrant: the library is taken. A warrant the +// recorded take was not started under changes nothing. +func (i *Inventory) TakeDone(ctx context.Context, library, warrant string) error { + _, err := i.store.Pool().Exec(ctx, + `update kept_library set done = true where library = $1 and warrant = $2 and not done`, library, warrant) + return err +} + +// TakeFailed forgets a take the engine stated failed under a warrant, never one done: the library is asked about again. +func (i *Inventory) TakeFailed(ctx context.Context, library, warrant string) error { + _, err := i.store.Pool().Exec(ctx, + `delete from kept_library where library = $1 and warrant = $2 and not done`, library, warrant) return err } diff --git a/internal/inventory/libraries_test.go b/internal/inventory/libraries_test.go index 7a3d3f77..0bc904a3 100644 --- a/internal/inventory/libraries_test.go +++ b/internal/inventory/libraries_test.go @@ -3,6 +3,7 @@ package inventory import ( "encoding/json" "fmt" + "sort" "strings" "testing" @@ -157,17 +158,17 @@ func TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps(t *testing.T) { func TestALibraryIsTakenOnceUnderAWarrant(t *testing.T) { inv := fresh(t) ctx := t.Context() - if err := inv.RecordTaken(ctx, TakenLibrary{Library: "media", Home: "ace", By: "the operator"}); err == nil { + if err := inv.RecordTakeStarted(ctx, TakenLibrary{Library: "media", Home: "ace", By: "the operator"}); err == nil { t.Fatal("a library was recorded taken with no warrant") } - first := TakenLibrary{Library: "media", Home: "ace", Placed: "fs-000000000001", By: "the operator", Ask: "c01", - Warrant: "c01"} - if err := inv.RecordTaken(ctx, first); err != nil { + first := TakenLibrary{Library: "media", Home: "ace", Root: "/storage/media", Placed: "fs-000000000001", + By: "the operator", Ask: "c01", Warrant: "c01"} + if err := inv.RecordTakeStarted(ctx, first); err != nil { t.Fatal(err) } again := first again.Ask, again.Warrant, again.By = "c02", "c02", "someone else" - if err := inv.RecordTaken(ctx, again); err != nil { + if err := inv.RecordTakeStarted(ctx, again); err != nil { t.Fatal(err) } taken, err := inv.TakenLibraries(ctx) @@ -179,12 +180,25 @@ func TestALibraryIsTakenOnceUnderAWarrant(t *testing.T) { } moved := first moved.Placed, moved.Ask, moved.Warrant = "fs-0000000000ff", "c03", "c03" - if err := inv.RecordTaken(ctx, moved); err != nil { + if err := inv.RecordTakeStarted(ctx, moved); err != nil { t.Fatal(err) } if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c03" { t.Fatalf("a take on another filesystem: %+v (%v)", taken["media"], err) } + // The review of 7ea6d36e, item 3: the root is part of what was taken — a library whose root changed is taken + // again, and the new take replaces the old. + if got := taken["media"].Root; got != "/storage/media" { + t.Fatalf("the take recorded the root %q", got) + } + rooted := moved + rooted.Root, rooted.Ask, rooted.Warrant = "/storage/media2", "c04", "c04" + if err := inv.RecordTakeStarted(ctx, rooted); err != nil { + t.Fatal(err) + } + if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c04" || taken["media"].Root != "/storage/media2" { + t.Fatalf("a take at another root: %+v (%v)", taken["media"], err) + } } // novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it — @@ -298,3 +312,82 @@ func TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead(t *testing.T) { t.Fatalf("a module with no branch recorded: %v", err) } } + +// The review of 7ea6d36e, item 4: every sub-path a module asks of a library is kept on its home, whether the module +// was assigned or pulled in by a requirement (it is in what its machine was last sent). +func TestTheSubPathsOfModulesPulledInByARequirementAreKept(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + record, err := inv.AddNode(ctx, "ace") + if err != nil { + t.Fatal(err) + } + for _, m := range []catalogue.Manifest{ + {Module: "radarr", Version: "1", Libraries: []catalogue.LibraryUse{{ID: "m", Library: "media", Path: "movies", Use: "read-write"}}}, + {Module: "importer", Version: "1", Libraries: []catalogue.LibraryUse{{ID: "i", Library: "media", Path: "incoming", Use: "read-write"}}}, + } { + if err := inv.RegisterModule(ctx, m, Source{}); err != nil { + t.Fatal(err) + } + } + if _, err := inv.Assign(ctx, "ace", "radarr"); err != nil { + t.Fatal(err) + } + if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1", "importer": "c2"}, 1, 1, + Send{Sequence: 1}); err != nil { + t.Fatal(err) + } + paths, err := inv.LibraryPaths(ctx) + if err != nil { + t.Fatal(err) + } + got := append([]string{}, paths["media"]...) + sort.Strings(got) + if strings.Join(got, ",") != "incoming,movies" { + t.Fatalf("the paths kept of media are %v; want the importer's, pulled in by a requirement, too", got) + } +} + +// The review of 7ea6d36e, item 5: a take the engine says it started is recorded as started, not taken; it is taken when +// the engine states the act done under that warrant, and forgotten when it states it failed, so the operator is asked +// again. +func TestALibraryIsTakenWhenTheActIsDone(t *testing.T) { + inv := fresh(t) + ctx := t.Context() + started := TakenLibrary{Library: "media", Home: "ace", Root: "/storage/media", Placed: "fs-000000000001", + By: "the operator", Ask: "c01", Warrant: "c01"} + if err := inv.RecordTakeStarted(ctx, started); err != nil { + t.Fatal(err) + } + taken, err := inv.TakenLibraries(ctx) + if err != nil { + t.Fatal(err) + } + if got := taken["media"]; got.Done || got.Ask != "c01" { + t.Fatalf("a take only started reads %+v; want started, not done", got) + } + if err := inv.TakeDone(ctx, "media", "c99"); err != nil { + t.Fatal(err) + } + if taken, _ = inv.TakenLibraries(ctx); taken["media"].Done { + t.Fatal("another warrant's act made the take done") + } + if err := inv.TakeDone(ctx, "media", "c01"); err != nil { + t.Fatal(err) + } + if taken, _ = inv.TakenLibraries(ctx); !taken["media"].Done { + t.Fatal("the act done under the take's warrant did not make it taken") + } + // A failed act forgets a take not done, so it is asked again; never one done. + again := started + again.Root, again.Ask, again.Warrant = "/storage/media2", "c02", "c02" + if err := inv.RecordTakeStarted(ctx, again); err != nil { + t.Fatal(err) + } + if err := inv.TakeFailed(ctx, "media", "c02"); err != nil { + t.Fatal(err) + } + if taken, _ = inv.TakenLibraries(ctx); taken["media"].Ask == "c02" { + t.Fatalf("a failed take is still recorded: %+v", taken["media"]) + } +} diff --git a/internal/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql b/internal/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql index 6b8008e9..b7100380 100644 --- a/internal/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql +++ b/internal/inventory/migrations/0096-a-module-joins-and-a-library-has-a-group.sql @@ -26,12 +26,14 @@ create table library_group ( -- **A library taken** (§7). The first time a library's root on its home holds data not yet in its group, the operator -- is asked once for that library; on the warrant the node-engine sets the group, group write and setgid under the root, -- and the library is taken: kept from then on without asking. The row is the record of that act — on which machine --- and filesystem, who approved it, under which warrant. A library whose home or filesystem changed since (it moved to --- another machine or another disk) is not taken by that warrant, and the operator is asked again; a new take replaces --- the row. +-- root and filesystem, who approved it, under which warrant. A library whose home, root or filesystem changed since +-- (it moved to another machine, path or disk) is not taken by that warrant, and the operator is asked again; a new take +-- replaces the row. create table kept_library ( library text primary key, home text not null, + -- The root it was taken at: a library whose root changed since is taken again. + root text not null check (root like '/%'), -- The filesystem the root was on when taken, as the engine names it without a path ("fs-<12 hex>"); empty when -- the engine named none. placed text not null default '', @@ -40,5 +42,9 @@ create table kept_library ( ask text not null, warrant text not null, -- What the node-engine answered when it started the take. - said text not null default '' + said text not null default '', + -- Whether the engine has stated the take's act done under this warrant: only then is the library taken. Until + -- then the take is under way — the library still waits, and the operator is not asked again — and an act stated + -- failed forgets the row, so the operator is asked again. + done boolean not null default false ); diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 6c9e9c5a..ff2450ea 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -451,6 +451,11 @@ const IdentityContract = 5 // it sent; five, ADR 0325's, is sent nothing new. const LibraryContract = 6 +// OwnGroupContract is the statement of an engine that also puts the tool runner's account in a joining module's own +// group, named by an identity's `runner` (novox/hq ADR 0334 §2). Not stated by any engine yet: the engine must add +// `runner` (an account's name) to its declaration's Identity, and put that account in the group mesh-. +const OwnGroupContract = 7 + // KindLibrary is a library's own verdict on its home, as the engine may state it (novox/hq ADR 0328): healthy once // kept. Read beside an access verdict naming the library until the engine's shape is settled. const KindLibrary = "library" diff --git a/testdata/store-tests b/testdata/store-tests index 3ded28a5..0757a123 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -43,3 +43,7 @@ ./internal/inventory TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess ./internal/inventory TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead ./cmd/mesh-controller TestALibrarysVerdictIsSaidForTheModulesThatUseIt +./cmd/mesh-controller TestAHeldModuleIsComposedAsBeforeItJoined +./cmd/mesh-controller TestALibraryIsTakenOnlyWhenTheEngineSaysTheActIsDone +./internal/inventory TestTheSubPathsOfModulesPulledInByARequirementAreKept +./internal/inventory TestALibraryIsTakenWhenTheActIsDone