Archived
Libraries and the modules that join them, on the controller's side (hq ADR 0328)
This commit is contained in:
@@ -117,6 +117,10 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
}
|
||||
}
|
||||
}
|
||||
// A module never takes a defined library's name: mesh-<module> and mesh-<library> never collide (ADR 0328 §4).
|
||||
if err := i.LibraryNamed(ctx, m.Module); err != nil {
|
||||
return fmt.Errorf("%s is not registered: %w", m.Module, err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -929,6 +933,10 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
|
||||
// the mesh-wide layer under it when the layer is one node's, and refuses the first thing that
|
||||
// cannot work (ADR 0163, rule 6). The same judgement composition makes; what passes here composes.
|
||||
func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
// The libraries the controller's own layer defines (novox/hq ADR 0328 §1, §4, §13).
|
||||
if err := i.judgeLibraries(ctx, nodeName, module, values); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := i.declared(ctx, module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||
@@ -987,9 +995,8 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
|
||||
}
|
||||
m = ahead
|
||||
}
|
||||
// A module that runs as its own identity owns its directories by it: no setting names their owner (novox/hq ADR
|
||||
// 0325 §1, §3). A module that does not yet keeps its placed owner, warned of where it is set.
|
||||
if err := catalogue.PlacesOwnerProblem(m, layers); err != nil {
|
||||
// A module that joins carries no user or group number or owner in a setting (novox/hq ADR 0328 §5).
|
||||
if err := catalogue.JoiningSettingProblem(m, layers[len(layers)-1:]); err != nil {
|
||||
return fmt.Errorf("refused: %w", err)
|
||||
}
|
||||
// And a key that reaches nothing, refused here where somebody can still fix the spelling:
|
||||
|
||||
@@ -36,17 +36,6 @@ type ResourceHealth struct {
|
||||
Root string `json:"root,omitempty"`
|
||||
// Waits is what a waiting resource waits for the operator to give (novox/hq ADR 0283).
|
||||
Waits []Wait `json:"waits,omitempty"`
|
||||
// Entries is a kept folder's listing as the engine resolved it (novox/hq ADR 0325 §4): each user with its uid.
|
||||
Entries []KeptEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
// KeptEntry is one user of a kept folder as the engine lists it, with a note beside the tool runner's.
|
||||
type KeptEntry struct {
|
||||
Identity string `json:"identity"`
|
||||
UID int `json:"uid"`
|
||||
Use string `json:"use"`
|
||||
From string `json:"from"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret, Setting and Access
|
||||
@@ -62,10 +51,8 @@ type Wait struct {
|
||||
// Placed is the filesystem the folder is on now, without its path (ADR 0325 §8): a warrant for another does not
|
||||
// answer the wait.
|
||||
Placed string `json:"placed,omitempty"`
|
||||
// Reason is the engine's reason for an access wait ("keep" or "disk"), ACL what its folder's filesystem says of
|
||||
// access entries ("needs-enabling", "unsupported", or empty when it allows them).
|
||||
// Reason is the engine's reason for an access wait: "keep" or "disk".
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Module identities and taken folders (novox/hq ADR 0325 §3, §8, §11; migration 0096).
|
||||
|
||||
// AllocateIdentity is module's identity, allocated now when it has none: one number from the range of its own,
|
||||
// mesh-wide, kept for ever. Two controllers allocating at once agree: the second's insert finds the first's row.
|
||||
func (i *Inventory) AllocateIdentity(ctx context.Context, module string) (catalogue.ModuleIdentity, error) {
|
||||
if strings.TrimSpace(module) == "" {
|
||||
return catalogue.ModuleIdentity{}, errors.New("an identity is allocated for a module, and none was named")
|
||||
}
|
||||
if id, had, err := i.IdentityOf(ctx, module); err != nil || had {
|
||||
return id, err
|
||||
}
|
||||
// The number is drawn only when the row is new; a draw lost to a concurrent insert is a gap in the range, never
|
||||
// a number given twice.
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`insert into module_identity (module, uid, gid)
|
||||
select $1, n, n from (select nextval('module_identity_number')::integer as n) drawn
|
||||
on conflict (module) do nothing`, module)
|
||||
if err != nil {
|
||||
return catalogue.ModuleIdentity{}, fmt.Errorf("%s could not be given an identity: %w", module, err)
|
||||
}
|
||||
id, had, err := i.IdentityOf(ctx, module)
|
||||
if err == nil && !had {
|
||||
err = fmt.Errorf("%s was given an identity and it cannot be read back", module)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// IdentityOf is module's identity, and false when it was never allocated one.
|
||||
func (i *Inventory) IdentityOf(ctx context.Context, module string) (catalogue.ModuleIdentity, bool, error) {
|
||||
id := catalogue.ModuleIdentity{Module: module}
|
||||
err := i.store.Pool().QueryRow(ctx, `select uid, gid from module_identity where module = $1`, module).
|
||||
Scan(&id.UID, &id.GID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return catalogue.ModuleIdentity{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return catalogue.ModuleIdentity{}, false, err
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// Identities is every module's identity, by module.
|
||||
func (i *Inventory) Identities(ctx context.Context) (map[string]catalogue.ModuleIdentity, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select module, uid, gid from module_identity`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]catalogue.ModuleIdentity{}
|
||||
for rows.Next() {
|
||||
var id catalogue.ModuleIdentity
|
||||
if err := rows.Scan(&id.Module, &id.UID, &id.GID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id.Module] = id
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TakenFolder is one folder the operator let the mesh keep on a machine (ADR 0325 §8).
|
||||
type TakenFolder struct {
|
||||
Path string
|
||||
By string
|
||||
Ask string
|
||||
Warrant string
|
||||
Said string
|
||||
// Placed is the filesystem the folder was on when taken, without its path (ADR 0325 §8).
|
||||
Placed string
|
||||
}
|
||||
|
||||
// KeptFolders is every folder taken on a machine, by its clean path.
|
||||
func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[string]TakenFolder, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select k.path, k.taken_by, k.ask, k.warrant, k.said, k.placed from kept_folder k join node n on n.id = k.node
|
||||
where n.name = $1`, nodeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]TakenFolder{}
|
||||
for rows.Next() {
|
||||
var t TakenFolder
|
||||
if err := rows.Scan(&t.Path, &t.By, &t.Ask, &t.Warrant, &t.Said, &t.Placed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[t.Path] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once per
|
||||
// filesystem: a second for the same folder on the same filesystem keeps the first, and one for the folder on another
|
||||
// filesystem (it moved, and the operator was asked again) replaces it.
|
||||
func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFolder) error {
|
||||
switch {
|
||||
case !strings.HasPrefix(t.Path, "/"):
|
||||
return fmt.Errorf("a taken folder is an absolute path, not %q", t.Path)
|
||||
case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "":
|
||||
return errors.New("a folder is taken only on the operator's warrant, and this names none")
|
||||
}
|
||||
node, err := i.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into kept_folder (node, path, taken_by, ask, warrant, said, placed) values ($1, $2, $3, $4, $5, $6, $7)
|
||||
on conflict (node, path) do update
|
||||
set taken_at = now(), taken_by = excluded.taken_by, ask = excluded.ask, warrant = excluded.warrant,
|
||||
said = excluded.said, placed = excluded.placed
|
||||
where kept_folder.placed is distinct from excluded.placed`, node.ID, filepath.Clean(t.Path), t.By, t.Ask,
|
||||
t.Warrant, t.Said, t.Placed)
|
||||
return err
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0325 §3, §11: a module gets one allocation, the same whoever asks and however often, from the range
|
||||
// of its own; no allocation is 0 or a root group's; a number once given is never given again.
|
||||
func TestAModuleIsGivenOneIdentityOnceAndNeverRoot(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
radarr, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if radarr != again {
|
||||
t.Fatalf("radarr was given %+v, then %+v: one allocation per module", radarr, again)
|
||||
}
|
||||
plex, err := inv.AllocateIdentity(ctx, "plex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []catalogue.ModuleIdentity{radarr, plex} {
|
||||
if id.UID == 0 || id.GID == 0 || !catalogue.AllocatableID(id.UID) || !catalogue.AllocatableID(id.GID) ||
|
||||
id.UID != id.GID {
|
||||
t.Errorf("%s was given %d:%d: within %d–%d, never 0, uid and gid one number", id.Module, id.UID, id.GID,
|
||||
catalogue.IdentityLow, catalogue.IdentityHigh)
|
||||
}
|
||||
}
|
||||
if radarr.UID == plex.UID {
|
||||
t.Fatalf("radarr and plex share %d", plex.UID)
|
||||
}
|
||||
|
||||
// Read back as composition reads it, from another connection's view: the same numbers (stable across restarts
|
||||
// and machines, since every machine's composition reads this one row).
|
||||
all, err := inv.Identities(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all["radarr"].UID != radarr.UID || all["plex"].UID != plex.UID {
|
||||
t.Fatalf("the identities read back are %+v", all)
|
||||
}
|
||||
|
||||
// A module forgotten keeps its row, and the next module is given a new number: never one given before.
|
||||
if _, err := inv.store.Pool().Exec(ctx, `delete from module_identity where module = 'plex'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sonarr, err := inv.AllocateIdentity(ctx, "sonarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sonarr.UID == plex.UID || sonarr.UID == radarr.UID {
|
||||
t.Fatalf("sonarr was given %d, a number given before", sonarr.UID)
|
||||
}
|
||||
|
||||
// The store itself refuses a number outside the range, root's above all: no write by hand makes one root.
|
||||
for _, n := range []int{0, 10, 1000, 49999, 60000} {
|
||||
if _, err := inv.store.Pool().Exec(ctx,
|
||||
`insert into module_identity (module, uid, gid) values ($1, $2, $2)`, "byhand", n); err == nil {
|
||||
t.Errorf("the store took %d as an identity", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0325 §8: a folder taken is recorded once per machine, under a warrant, and read back by its path.
|
||||
func TestAFolderIsTakenOnceUnderAWarrant(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies/", By: "the operator"}); err == nil {
|
||||
t.Fatal("a folder was recorded taken with no warrant")
|
||||
}
|
||||
first := TakenFolder{Path: "/storage/media/movies/", By: "the operator", Ask: "c01", Warrant: "c01", Said: "started"}
|
||||
if err := inv.RecordTaken(ctx, "ace", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "someone else", Ask: "c02",
|
||||
Warrant: "c02"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err := inv.KeptFolders(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, has := kept["/storage/media/movies"]
|
||||
if !has || got.Ask != "c01" || got.By != "the operator" {
|
||||
t.Fatalf("the taken folder reads %+v (%v): the first take, by its clean path", got, has)
|
||||
}
|
||||
// The folder moved to another filesystem and the operator was asked again: the new take replaces the old.
|
||||
if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "the operator", Ask: "c03",
|
||||
Warrant: "c03", Placed: "fs-0000000000ff"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept, err = inv.KeptFolders(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := kept["/storage/media/movies"]; got.Ask != "c03" || got.Placed != "fs-0000000000ff" {
|
||||
t.Fatalf("after a take on another filesystem the folder reads %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it —
|
||||
// the build the delivery under way brings — takes a proposal placing movies: judged against that build, not refused.
|
||||
// Without such a build it is refused, as before.
|
||||
func TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "media-importer", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "downloads", Path: "/services/media/downloads", Mode: catalogue.AccessReadWrite}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x",
|
||||
"volumes": []any{"${access:downloads}:/downloads"}}}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c0ffee33"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
|
||||
err := inv.JudgeSettings(ctx, "ace", "media-importer", placing)
|
||||
if err == nil || !strings.Contains(err.Error(), `places the access "movies", which it does not declare`) {
|
||||
t.Fatalf("a placement no build declares: %v", err)
|
||||
}
|
||||
|
||||
// main's manifest — the build of #34 — declares movies.
|
||||
ahead := registered
|
||||
ahead.Accesses = append([]catalogue.Access{}, registered.Accesses...)
|
||||
ahead.Accesses = append(ahead.Accesses, catalogue.Access{ID: "movies", Path: "/services/media/movies",
|
||||
Mode: catalogue.AccessReadWrite})
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("b34", "media-importer", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the proposal placing movies, which main's build declares, was refused: %v", err)
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the layer placing movies was not stored: %v", err)
|
||||
}
|
||||
// Stored ahead, it does not cost the registered module its place: composition passes it by.
|
||||
layers, err := inv.SettingsFor(ctx, "ace", "media-importer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalogue.ComposesWith(registered, layers, false); err != nil {
|
||||
t.Fatalf("the registered module no longer composes with the layer stored ahead: %v", err)
|
||||
}
|
||||
// A typo is still a typo: an access neither build declares is refused.
|
||||
typo := map[string]any{catalogue.AccessesSetting: map[string]any{"moveis": "/storage/media/movies"}}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", typo); err == nil {
|
||||
t.Fatal("a placement no build declares was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Review of e1c2b744: only a build of the branch the module's record follows judges a placement ahead of it — never one
|
||||
// from another branch, nor one with no branch, and a module registered with no branch is refused as before, saying so.
|
||||
func TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "importer", Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x"}}}
|
||||
ahead := registered
|
||||
ahead.Accesses = []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}}
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, ref := range []string{"feature", ""} {
|
||||
b := aBuild(fmt.Sprintf("elsewhere%d", i), "importer", "")
|
||||
b.Ref, b.Manifest = ref, raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "importer", placing); err == nil {
|
||||
t.Fatal("a build of another branch, or of none, judged the placement")
|
||||
}
|
||||
// Registered by hand, with no branch: refused, saying why.
|
||||
byHand := registered
|
||||
byHand.Module = "byhand"
|
||||
if err := inv.RegisterModule(ctx, byHand, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aheadByHand := ahead
|
||||
aheadByHand.Module = "byhand"
|
||||
if raw, err = json.Marshal(aheadByHand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("onmain", "byhand", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "byhand", placing); err == nil ||
|
||||
!strings.Contains(err.Error(), "follows no branch") {
|
||||
t.Fatalf("a module with no branch recorded: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Joining modules' numbers, library groups, taken libraries and the libraries themselves (novox/hq ADR 0328 §1, §3,
|
||||
// §4, §7, §15; migration 0096).
|
||||
|
||||
// AllocateIdentity is a joining module's number, allocated now when it has none: one number from the mesh's range,
|
||||
// mesh-wide, kept for ever, its user's and its own group's. Two controllers allocating at once agree: the second's
|
||||
// insert finds the first's row.
|
||||
func (i *Inventory) AllocateIdentity(ctx context.Context, module string) (catalogue.ModuleIdentity, error) {
|
||||
if strings.TrimSpace(module) == "" {
|
||||
return catalogue.ModuleIdentity{}, errors.New("a number is allocated for a module, and none was named")
|
||||
}
|
||||
if id, had, err := i.IdentityOf(ctx, module); err != nil || had {
|
||||
return id, err
|
||||
}
|
||||
// Drawn only when the row is new; a draw lost to a concurrent insert is a gap in the range, never a number given
|
||||
// twice.
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into module_identity (module, uid) select $1, nextval('mesh_number')::integer
|
||||
on conflict (module) do nothing`, module); err != nil {
|
||||
return catalogue.ModuleIdentity{}, fmt.Errorf("%s could not be given its number: %w", module, err)
|
||||
}
|
||||
id, had, err := i.IdentityOf(ctx, module)
|
||||
if err == nil && !had {
|
||||
err = fmt.Errorf("%s was given its number and it cannot be read back", module)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// IdentityOf is a module's number, and false when it was never allocated one.
|
||||
func (i *Inventory) IdentityOf(ctx context.Context, module string) (catalogue.ModuleIdentity, bool, error) {
|
||||
id := catalogue.ModuleIdentity{Module: module}
|
||||
err := i.store.Pool().QueryRow(ctx, `select uid from module_identity where module = $1`, module).Scan(&id.UID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return catalogue.ModuleIdentity{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return catalogue.ModuleIdentity{}, false, err
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// Identities is every joining module's number, by module.
|
||||
func (i *Inventory) Identities(ctx context.Context) (map[string]catalogue.ModuleIdentity, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select module, uid from module_identity`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]catalogue.ModuleIdentity{}
|
||||
for rows.Next() {
|
||||
var id catalogue.ModuleIdentity
|
||||
if err := rows.Scan(&id.Module, &id.UID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id.Module] = id
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AllocateLibraryGroup is a library's group number, allocated now when it has none, from the same sequence as the
|
||||
// modules' numbers (§3).
|
||||
func (i *Inventory) AllocateLibraryGroup(ctx context.Context, library string) (int, error) {
|
||||
if strings.TrimSpace(library) == "" {
|
||||
return 0, errors.New("a group number is allocated for a library, and none was named")
|
||||
}
|
||||
groups, err := i.LibraryGroups(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if gid, had := groups[library]; had {
|
||||
return gid, nil
|
||||
}
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into library_group (library, gid) select $1, nextval('mesh_number')::integer
|
||||
on conflict (library) do nothing`, library); err != nil {
|
||||
return 0, fmt.Errorf("the library %s could not be given its group's number: %w", library, err)
|
||||
}
|
||||
if groups, err = i.LibraryGroups(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
gid, has := groups[library]
|
||||
if !has {
|
||||
return 0, fmt.Errorf("the library %s was given its group's number and it cannot be read back", library)
|
||||
}
|
||||
return gid, nil
|
||||
}
|
||||
|
||||
// LibraryGroups is every library's group number, by library.
|
||||
func (i *Inventory) LibraryGroups(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select library, gid from library_group`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var gid int
|
||||
if err := rows.Scan(&name, &gid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = gid
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Libraries is every library the operator defined (§1): the mesh-wide `libraries` layer of the controller's module.
|
||||
// None when it was never set.
|
||||
func (i *Inventory) Libraries(ctx context.Context) (map[string]catalogue.Library, error) {
|
||||
var raw []byte
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select values from settings where module = $1 and node is null`, catalogue.LibrariesModule).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var layer map[string]any
|
||||
if err := json.Unmarshal(raw, &layer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return catalogue.ParseLibraries(layer[catalogue.LibrariesSetting])
|
||||
}
|
||||
|
||||
// judgeLibraries is what a layer of the controller's module is held to when it defines libraries (§1, §4, §13): only
|
||||
// mesh-wide, well formed, no library named as a registered module, none overlapping another on a machine.
|
||||
func (i *Inventory) judgeLibraries(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
v, defines := values[catalogue.LibrariesSetting]
|
||||
if module != catalogue.LibrariesModule || !defines {
|
||||
return nil
|
||||
}
|
||||
if nodeName != "" {
|
||||
return fmt.Errorf("refused: %s is the mesh's, set on the whole mesh and never on one machine (%s §1)",
|
||||
catalogue.LibrariesSetting, catalogue.ADR0328)
|
||||
}
|
||||
libs, err := catalogue.ParseLibraries(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refused: %w", err)
|
||||
}
|
||||
shelf, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
modules := make([]string, 0, len(shelf))
|
||||
for name := range shelf {
|
||||
modules = append(modules, name)
|
||||
}
|
||||
if problems := catalogue.LibrariesProblems(libs, modules); len(problems) > 0 {
|
||||
return fmt.Errorf("refused: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibraryNamed refuses a module whose name is a defined library's (§4): mesh-<module> and mesh-<library> never collide.
|
||||
func (i *Inventory) LibraryNamed(ctx context.Context, module string) error {
|
||||
libs, err := i.Libraries(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, clash := libs[module]; clash {
|
||||
return fmt.Errorf("the module %s has the name of the library %s: %s would be both (%s §4)", module, module,
|
||||
catalogue.MeshName(module), catalogue.ADR0328)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LibraryPaths is every sub-path a module assigned anywhere asks of each library (§6): what its home keeps.
|
||||
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`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]map[string]bool{}
|
||||
for rows.Next() {
|
||||
var module string
|
||||
if err := rows.Scan(&module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range shelf[module].Libraries {
|
||||
if seen[u.Library] == nil {
|
||||
seen[u.Library] = map[string]bool{}
|
||||
}
|
||||
seen[u.Library][filepath.Clean(u.Path)] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for lib, paths := range seen {
|
||||
for p := range paths {
|
||||
out[lib] = append(out[lib], p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TakenLibrary is one library the operator let the mesh take, on its home (§7).
|
||||
type TakenLibrary struct {
|
||||
Library string
|
||||
Home string
|
||||
Placed string
|
||||
By string
|
||||
Ask string
|
||||
Warrant string
|
||||
Said string
|
||||
}
|
||||
|
||||
// TakenLibraries is every library taken, by name.
|
||||
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`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
out[t.Library] = t
|
||||
}
|
||||
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 {
|
||||
switch {
|
||||
case t.Library == "" || t.Home == "":
|
||||
return errors.New("a taken library names itself and its home")
|
||||
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)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0328 §3, §4, §15: one number per joining module (its user's and its own group's) and one per library's
|
||||
// group, from one sequence and one range — never 0 or a root group's, never shared between a module and a library,
|
||||
// never given twice, the same however often asked.
|
||||
func TestOneNumberPerModuleAndLibraryNeverRoot(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
radarr, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := inv.AllocateIdentity(ctx, "radarr")
|
||||
if err != nil || again != radarr {
|
||||
t.Fatalf("radarr was given %+v, then %+v (%v): one number per module", radarr, again, err)
|
||||
}
|
||||
media, err := inv.AllocateLibraryGroup(ctx, "media")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mediaAgain, err := inv.AllocateLibraryGroup(ctx, "media"); err != nil || mediaAgain != media {
|
||||
t.Fatalf("the media group was given %d, then %d (%v)", media, mediaAgain, err)
|
||||
}
|
||||
plex, err := inv.AllocateIdentity(ctx, "plex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[int]string{}
|
||||
for who, n := range map[string]int{"radarr": radarr.UID, "plex": plex.UID, "mesh-media": media} {
|
||||
if n == 0 || !catalogue.AllocatableID(n) {
|
||||
t.Errorf("%s was given %d: within %d–%d, never 0", who, n, catalogue.IdentityLow, catalogue.IdentityHigh)
|
||||
}
|
||||
if other, twice := seen[n]; twice {
|
||||
t.Errorf("%s and %s share %d: a module's own group and a library's never do", who, other, n)
|
||||
}
|
||||
seen[n] = who
|
||||
}
|
||||
// Read back as every machine's composition reads them: the same numbers.
|
||||
ids, err := inv.Identities(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups, err := inv.LibraryGroups(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ids["radarr"].UID != radarr.UID || groups["media"] != media {
|
||||
t.Fatalf("read back %+v and %v", ids, groups)
|
||||
}
|
||||
// A forgotten row frees no number: the next one is new.
|
||||
if _, err := inv.store.Pool().Exec(ctx, `delete from module_identity where module = 'plex'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sonarr, err := inv.AllocateIdentity(ctx, "sonarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, given := seen[sonarr.UID]; given {
|
||||
t.Fatalf("sonarr was given %d, a number given before", sonarr.UID)
|
||||
}
|
||||
// The store refuses a number outside the range, root's above all.
|
||||
for _, n := range []int{0, 4, 10, 1000, 49999, 60000} {
|
||||
if _, err := inv.store.Pool().Exec(ctx, `insert into module_identity (module, uid) values ('byhand', $1)`, n); err == nil {
|
||||
t.Errorf("the store took %d as a module's number", n)
|
||||
}
|
||||
if _, err := inv.store.Pool().Exec(ctx, `insert into library_group (library, gid) values ('byhand', $1)`, n); err == nil {
|
||||
t.Errorf("the store took %d as a library group's number", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// libraryController registers the controller's own module, whose mesh-wide layer defines the libraries.
|
||||
func libraryController(t *testing.T, inv *Inventory) {
|
||||
t.Helper()
|
||||
m := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "controller", "image": "x"}}}
|
||||
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0328 §1, §4, §13: a library is defined only on the controller's mesh-wide layer, as a trusted setting;
|
||||
// never on one machine; never overlapping another on a machine it reaches; never named as a registered module — and no
|
||||
// module is registered under a defined library's name.
|
||||
func TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
for _, n := range []string{"ace", "novox"} {
|
||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
libraryController(t, inv)
|
||||
radarr := catalogue.Manifest{Module: "radarr", Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "radarr", "image": "x"}}}
|
||||
if err := inv.RegisterModule(ctx, radarr, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := map[string]any{"home": "ace", "root": "/storage/media", "reach": []any{"novox"}}
|
||||
define := func(libs map[string]any) map[string]any {
|
||||
return map[string]any{catalogue.LibrariesSetting: libs}
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, define(map[string]any{"media": media})); err != nil {
|
||||
t.Fatalf("a library was not defined: %v", err)
|
||||
}
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l := libs["media"]; l.Home != "ace" || l.Root != "/storage/media" || !l.Reaches("novox") || !l.Reaches("ace") {
|
||||
t.Fatalf("the library reads %+v", libs["media"])
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
node string
|
||||
libs map[string]any
|
||||
want string
|
||||
}{
|
||||
"on one machine": {"ace", map[string]any{"media": media}, "never on one machine"},
|
||||
"inside another": {"", map[string]any{"media": media,
|
||||
"movies": map[string]any{"home": "novox", "root": "/storage/media/movies"}}, "overlap on"},
|
||||
"around another": {"", map[string]any{"movies": map[string]any{"home": "ace", "root": "/storage/media/movies"},
|
||||
"media": media}, "overlap on"},
|
||||
"named as a module": {"", map[string]any{"radarr": map[string]any{"home": "ace", "root": "/srv/radarr-data"}},
|
||||
"name of the module radarr"},
|
||||
"with an owner": {"", map[string]any{"books": map[string]any{"home": "ace", "root": "/srv/books",
|
||||
"owner": "1000"}}, "nothing about an owner"},
|
||||
} {
|
||||
err := inv.SetSettings(ctx, c.node, catalogue.LibrariesModule, define(c.libs))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v; want refused saying %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
// Two libraries apart on different machines may share a path's prefix only where they do not meet.
|
||||
apart := map[string]any{"media": media, "photos": map[string]any{"home": "elsewhere", "root": "/storage/media/photos"}}
|
||||
if err := inv.JudgeSettings(ctx, "", catalogue.LibrariesModule, define(apart)); err != nil {
|
||||
t.Errorf("libraries that meet on no machine were refused: %v", err)
|
||||
}
|
||||
// A module under a defined library's name is refused.
|
||||
named := catalogue.Manifest{Module: "media", Version: "1",
|
||||
Resources: []map[string]any{{"id": "s", "type": "container", "name": "media", "image": "x"}}}
|
||||
if err := inv.RegisterModule(ctx, named, Source{}); err == nil || !strings.Contains(err.Error(), "name of the library media") {
|
||||
t.Fatalf("a module named as a library: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0328 §7: a library taken is recorded once per home and filesystem, under a warrant; one taken again on
|
||||
// another filesystem replaces it.
|
||||
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 {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := first
|
||||
again.Ask, again.Warrant, again.By = "c02", "c02", "someone else"
|
||||
if err := inv.RecordTaken(ctx, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taken, err := inv.TakenLibraries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := taken["media"]; got.Ask != "c01" {
|
||||
t.Fatalf("a second take of the same overwrote the first: %+v", got)
|
||||
}
|
||||
moved := first
|
||||
moved.Placed, moved.Ask, moved.Warrant = "fs-0000000000ff", "c03", "c03"
|
||||
if err := inv.RecordTaken(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)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it —
|
||||
// the build the delivery under way brings — takes a proposal placing movies: judged against that build, not refused.
|
||||
// Without such a build it is refused, as before.
|
||||
func TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "media-importer", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "downloads", Path: "/services/media/downloads", Mode: catalogue.AccessReadWrite}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x",
|
||||
"volumes": []any{"${access:downloads}:/downloads"}}}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c0ffee33"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
|
||||
err := inv.JudgeSettings(ctx, "ace", "media-importer", placing)
|
||||
if err == nil || !strings.Contains(err.Error(), `places the access "movies", which it does not declare`) {
|
||||
t.Fatalf("a placement no build declares: %v", err)
|
||||
}
|
||||
|
||||
// main's manifest — the build of #34 — declares movies.
|
||||
ahead := registered
|
||||
ahead.Accesses = append([]catalogue.Access{}, registered.Accesses...)
|
||||
ahead.Accesses = append(ahead.Accesses, catalogue.Access{ID: "movies", Path: "/services/media/movies",
|
||||
Mode: catalogue.AccessReadWrite})
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("b34", "media-importer", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the proposal placing movies, which main's build declares, was refused: %v", err)
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "ace", "media-importer", placing); err != nil {
|
||||
t.Fatalf("the layer placing movies was not stored: %v", err)
|
||||
}
|
||||
// Stored ahead, it does not cost the registered module its place: composition passes it by.
|
||||
layers, err := inv.SettingsFor(ctx, "ace", "media-importer")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalogue.ComposesWith(registered, layers, false); err != nil {
|
||||
t.Fatalf("the registered module no longer composes with the layer stored ahead: %v", err)
|
||||
}
|
||||
// A typo is still a typo: an access neither build declares is refused.
|
||||
typo := map[string]any{catalogue.AccessesSetting: map[string]any{"moveis": "/storage/media/movies"}}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "media-importer", typo); err == nil {
|
||||
t.Fatal("a placement no build declares was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Review of e1c2b744: only a build of the branch the module's record follows judges a placement ahead of it — never one
|
||||
// from another branch, nor one with no branch, and a module registered with no branch is refused as before, saying so.
|
||||
func TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := catalogue.Manifest{Module: "importer", Version: "1",
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x"}}}
|
||||
ahead := registered
|
||||
ahead.Accesses = []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}}
|
||||
raw, err := json.Marshal(ahead)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}}
|
||||
if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, ref := range []string{"feature", ""} {
|
||||
b := aBuild(fmt.Sprintf("elsewhere%d", i), "importer", "")
|
||||
b.Ref, b.Manifest = ref, raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "importer", placing); err == nil {
|
||||
t.Fatal("a build of another branch, or of none, judged the placement")
|
||||
}
|
||||
// Registered by hand, with no branch: refused, saying why.
|
||||
byHand := registered
|
||||
byHand.Module = "byhand"
|
||||
if err := inv.RegisterModule(ctx, byHand, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aheadByHand := ahead
|
||||
aheadByHand.Module = "byhand"
|
||||
if raw, err = json.Marshal(aheadByHand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("onmain", "byhand", "")
|
||||
b.Ref, b.Manifest = "main", raw
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.JudgeSettings(ctx, "ace", "byhand", placing); err == nil ||
|
||||
!strings.Contains(err.Error(), "follows no branch") {
|
||||
t.Fatalf("a module with no branch recorded: %v", err)
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
-- Every module is given an identity of its own, and the mesh takes a shared folder once, on the operator's word
|
||||
-- (novox/hq ADR 0325 §3, §8, §11).
|
||||
--
|
||||
-- **One number per module, mesh-wide, allocated once and never again.** The node-engine makes the account
|
||||
-- mesh-<module> on each machine the module is assigned to, with these numbers, so a container that declares
|
||||
-- `identity` runs as the same uid and gid everywhere and a folder's entries mean the same on every machine. The
|
||||
-- numbers come from a sequence of their own, 50000–59999 (catalogue.IdentityLow and IdentityHigh): above the
|
||||
-- numbers distributions give people, below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a
|
||||
-- root group's (0, 4, 10). The checks refuse a row outside the range, so no write, by hand or by a later bug, can make
|
||||
-- an identity root (§11). uid and gid are one number. A sequence never hands a number back, and a row is kept after
|
||||
-- its module is forgotten, so a number once given is never given to another module: a file left owned by a removed
|
||||
-- module's identity is never inherited by a new one.
|
||||
create sequence module_identity_number as integer minvalue 50000 maxvalue 59999 start 50000 no cycle;
|
||||
|
||||
create table module_identity (
|
||||
module text primary key,
|
||||
uid integer not null unique check (uid between 50000 and 59999),
|
||||
gid integer not null unique check (gid between 50000 and 59999),
|
||||
allocated timestamptz not null default now(),
|
||||
check (uid = gid)
|
||||
);
|
||||
|
||||
-- **A folder taken, per machine** (§8). The first time a placed folder that already holds data is to be kept, the
|
||||
-- operator is asked once for that folder on that machine; on the warrant the node-engine adds the entries to
|
||||
-- everything there, and the folder is taken: kept from then on without asking, a module that gains the access
|
||||
-- added with no wait. The row is the record of that act — who approved it, under which warrant — and what tells a
|
||||
-- later module's access that it joins a taken folder (`taken` on the access). The path is the folder as placed,
|
||||
-- clean and absolute. A new warrant for the same folder on another filesystem replaces the row.
|
||||
create table kept_folder (
|
||||
node uuid not null references node (id) on delete cascade,
|
||||
path text not null check (path like '/%'),
|
||||
taken_at timestamptz not null default now(),
|
||||
-- Who approved it, as the hand-act log says it, and the ask and warrant it was taken under.
|
||||
taken_by text not null,
|
||||
ask text not null,
|
||||
warrant text not null,
|
||||
-- What the node-engine answered when it started the take.
|
||||
said text not null default '',
|
||||
-- The filesystem the folder was on when it was taken, as the engine names it without a path ("fs-<12 hex>"):
|
||||
-- a wait naming another (the folder moved to another disk) is not answered by this take, and the operator is
|
||||
-- asked again; until the new warrant the folder is not taken there. Empty from an engine that names none.
|
||||
placed text not null default '',
|
||||
primary key (node, path)
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- A module that joins has a user and a group of its own, each library has a group of its own, and a library's
|
||||
-- existing data is taken once, on the operator's word (novox/hq ADR 0328 §3, §4, §7, §15).
|
||||
--
|
||||
-- **One sequence for every number the mesh gives.** A joining module is given one number, its user's and its own
|
||||
-- group's (mesh-<module>); a library is given one, its group's (mesh-<library>). Both are drawn from this sequence, so
|
||||
-- a module's own group and a library's group never share a number. The range is 50000–59999 (catalogue.IdentityLow and
|
||||
-- IdentityHigh): above the numbers distributions give people, below the 60000s Debian keeps for its daemons, and
|
||||
-- nowhere near root's 0 or a root group's (0, 4, 10). The checks refuse a row outside it, so no write, by hand or by a
|
||||
-- later bug, can make one root's (§15). A sequence never hands a number back, and a row is kept after its module or
|
||||
-- library is gone, so a number once given is never given to another: a file left owned by a removed module's user is
|
||||
-- never inherited by a new one.
|
||||
create sequence mesh_number as integer minvalue 50000 maxvalue 59999 start 50000 no cycle;
|
||||
|
||||
create table module_identity (
|
||||
module text primary key,
|
||||
uid integer not null unique check (uid between 50000 and 59999),
|
||||
allocated timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table library_group (
|
||||
library text primary key,
|
||||
gid integer not null unique check (gid between 50000 and 59999),
|
||||
allocated timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- **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.
|
||||
create table kept_library (
|
||||
library text primary key,
|
||||
home text not null,
|
||||
-- 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 '',
|
||||
taken_at timestamptz not null default now(),
|
||||
taken_by text not null,
|
||||
ask text not null,
|
||||
warrant text not null,
|
||||
-- What the node-engine answered when it started the take.
|
||||
said text not null default ''
|
||||
);
|
||||
Reference in New Issue
Block a user