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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:<name>`, of no module; a module that uses it waits with it, and is said so.
|
||||
resources = append(resources, libraryVerdictsFor(ctx, inv, node, resources)...)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+25
-26
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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-<module>.
|
||||
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"
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user