Libraries and the modules that join them, on the controller's side (hq ADR 0328)
This commit is contained in:
@@ -247,19 +247,23 @@ type Rendering struct {
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// KeepsFolders says this machine's node-engine gives modules their identities and keeps shared folders
|
||||
// (novox/hq ADR 0325: its statement's contract is link.IdentityContract or later). To an older, strict engine none of
|
||||
// it is sent — no `identities`, no container `identity`, no access `users` or `taken`, no owner derived from an
|
||||
// identity — and what it runs is composed as before.
|
||||
KeepsFolders bool
|
||||
// Identities is each module's allocated identity, by module (ADR 0325 §3): the same on every machine. A module
|
||||
// that declares `identity` and has none here is composed without one, as to an older engine.
|
||||
// KeepsLibraries says this machine's node-engine makes the mesh's users and library groups and keeps libraries
|
||||
// (novox/hq ADR 0328: its statement's contract is link.LibraryContract or later). To an older, strict engine none
|
||||
// of it is sent, and a module that joins is left out of its declaration, naming why.
|
||||
KeepsLibraries bool
|
||||
// Identities is each joining module's allocated number, by module (ADR 0328 §4): the same on every machine. A
|
||||
// joining module with none here is left out, as at a question asked before its first send.
|
||||
Identities map[string]ModuleIdentity
|
||||
// KeptFolders is every folder the operator let the mesh keep on this machine (ADR 0325 §8), by its clean path.
|
||||
KeptFolders map[string]bool
|
||||
// ReadyFolders is every folder the engine states it keeps on this machine (its access healthy), by clean path: a
|
||||
// folder it found empty and kept, or took itself. With KeptFolders, what lets a container switch to its identity.
|
||||
ReadyFolders map[string]bool
|
||||
// Libraries is every library the operator defined, by name (§1); LibraryGroups each one's allocated group number
|
||||
// (§3); TakenLibraries those whose data the operator let the mesh take on their home, on the filesystem it is on
|
||||
// now (§7); LibraryPaths every sub-path any module in the mesh asks of each, kept on its home (§6).
|
||||
Libraries map[string]Library
|
||||
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
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -334,9 +338,11 @@ type Composed struct {
|
||||
// naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told
|
||||
// everything else. Push and plan say it; the merge gate refuses a change that adds one.
|
||||
Unplaced []string
|
||||
// Identities is the declaration's `identities` (novox/hq ADR 0325 §3): [{module, name, uid, gid}] for each
|
||||
// module composed with an identity. Empty to an engine that does not keep folders.
|
||||
Identities []map[string]any
|
||||
// Groups, Identities and Libraries are what the declaration says of libraries and joining modules (novox/hq ADR
|
||||
// 0328): composed only for an engine that keeps libraries, and sent only when not empty.
|
||||
Groups []GroupEntry
|
||||
Identities []IdentityEntry
|
||||
Libraries []LibraryEntry
|
||||
}
|
||||
|
||||
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
|
||||
@@ -399,8 +405,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
received := map[string]map[string][]Contribution{}
|
||||
leftOut := map[string]string{}
|
||||
var foreseen, unplaced []string
|
||||
var identities []ModuleIdentity
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &identities)
|
||||
var joined []Joined
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &joined)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -416,9 +422,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
sort.Strings(unplaced)
|
||||
composed := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced}
|
||||
if len(identities) > 0 {
|
||||
composed.Identities = identitiesOf(identities)
|
||||
}
|
||||
composed.Groups, composed.Identities, composed.Libraries = r.libraryEnvelope(with, joined)
|
||||
return composed, nil
|
||||
}
|
||||
|
||||
@@ -430,13 +434,26 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||
|
||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
received map[string]map[string][]Contribution, leftOut map[string]string,
|
||||
foreseen, unplaced *[]string, identities *[]ModuleIdentity) ([]map[string]any, error) {
|
||||
foreseen, unplaced *[]string, joinedOut *[]Joined) ([]map[string]any, error) {
|
||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||
// containers untouched, the machine told so by name — and the machine is told everything else.
|
||||
// Before placing, because a placement is a setting too.
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
// And a module whose libraries cannot be composed here, or that waits for one to be taken (novox/hq ADR 0328 §2,
|
||||
// §8): left out, naming why, its containers untouched — so a container keeps the user it ran as.
|
||||
for _, m := range r.Modules {
|
||||
if _, already := left[m.Module]; already {
|
||||
continue
|
||||
}
|
||||
if why := libraryLeftOut(m, r.Node, with); why != "" {
|
||||
if left == nil {
|
||||
left = map[string]string{}
|
||||
}
|
||||
left[m.Module] = why
|
||||
}
|
||||
}
|
||||
kept := make([]Manifest, 0, len(r.Modules))
|
||||
var stillBackedUp []Manifest
|
||||
for _, m := range r.Modules {
|
||||
@@ -577,18 +594,19 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
resources := m.Resources
|
||||
|
||||
// The identity it runs as here (novox/hq ADR 0325 §3, §11): none to an older engine, nor without an allocation.
|
||||
ident, hasIdent, err := identityFor(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Its identity, where it joins (novox/hq ADR 0328 §4): its user and groups, and where its libraries are here.
|
||||
var joined Joined
|
||||
joins := Joins(m)
|
||||
var libPaths map[string]string
|
||||
if joins {
|
||||
if joined, err = joinedOf(m, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libPaths = libraryPaths(m, with)
|
||||
if joinedOut != nil {
|
||||
*joinedOut = append(*joinedOut, joined)
|
||||
}
|
||||
}
|
||||
if hasIdent && identities != nil {
|
||||
*identities = append(*identities, ident)
|
||||
}
|
||||
mountedByIdentity := dirsMountedByIdentity(m)
|
||||
// Its containers run as it only once every read-write folder they mount is taken (the operator's question on
|
||||
// ADR 0325): until then the identity is allocated, sent and named in the take's users, and not switched to.
|
||||
runsAsIdent := hasIdent && !identityHeld(m, with)
|
||||
|
||||
// What the mesh computes for this module goes FIRST, before the module's own resources.
|
||||
//
|
||||
@@ -1040,10 +1058,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, err
|
||||
}
|
||||
ownerInto(copied, placed)
|
||||
// And its identity (novox/hq ADR 0325 §2, §3): a container's resolved, or taken out where none is composed;
|
||||
// the owner of a directory its identity's containers mount. After the placement's owner: the identity wins.
|
||||
if err := identityInto(copied, m, ident, runsAsIdent, mountedByIdentity); err != nil {
|
||||
return nil, err
|
||||
// And its identity and libraries, where it joins (novox/hq ADR 0328 §4, §5, §10): after the placement's
|
||||
// owner, which a joining module may not set.
|
||||
if joins {
|
||||
if err := joinedInto(copied, m, joined, libPaths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// **After settings, and that is the whole reason it is here.** A module's file
|
||||
// content is where a setting lands, so a placeholder may only exist once the setting
|
||||
@@ -1162,12 +1182,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
out = append(out, copied)
|
||||
}
|
||||
|
||||
// **Who uses each of its shared folders** (novox/hq ADR 0325 §4, §5, §8): the access's `users` and `taken`, to
|
||||
// an engine that keeps folders.
|
||||
if with.KeepsFolders {
|
||||
usersInto(out, owner, m, ident, hasIdent, r.toolsAccount(m, with), with)
|
||||
}
|
||||
|
||||
// **What only the mesh knows, where this module asked for it.** The graph is the control
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Libraries, and the modules that join them (novox/hq ADR 0328).
|
||||
//
|
||||
// **A library is a named tree of shared data with one home** (§1). The operator defines it — its name, the machine it
|
||||
// lives on, its root there and the machines it reaches — as the mesh-wide `libraries` setting of the controller's own
|
||||
// module, a trusted setting set at the controller's terminal or on the operator's Approve of a proposal (ADR 0277),
|
||||
// never through a verb alone. Libraries never overlap on a machine (§13), and a library never shares a name with a
|
||||
// module (§4), so `mesh-<library>` and `mesh-<module>` never collide.
|
||||
//
|
||||
// **A module asks for a library by name, sub-path and use** (§2): a manifest's `libraries` entries {id, library,
|
||||
// path, use}. On each machine an entry resolves to the library's root joined with the path; a module assigned to a
|
||||
// machine the library does not reach is left out there, naming the library. `${library:<id>}` is that host path, and
|
||||
// is filled only where a host path belongs (§5): a container's volume source, and a word of the module's tools, which
|
||||
// run on the machine. A `read` entry is mounted read-only (§10).
|
||||
//
|
||||
// **A module joins by using the model** (§14): it declares `libraries` or uses `${identity:…}`. It is given one number
|
||||
// from the mesh's range, as its user's and its own group's (§4); its user's primary group is the group of the first
|
||||
// library it uses, the others are added groups, and with no library its own group is primary. `${identity:user}`,
|
||||
// `${identity:uid}`, `${identity:group}` and `${identity:gid}` are filled wherever its resources take values (§5), and
|
||||
// its directories are owned by its user and primary group (§4). A module that does neither is composed exactly as
|
||||
// before.
|
||||
//
|
||||
// **The range is 50000–59999** for every number the mesh allocates — a joining module's and a library's group — from
|
||||
// one sequence, so a module's own group and a library's never share a number. It lies above the numbers distributions
|
||||
// give people and below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a root group's (0, adm's 4,
|
||||
// wheel's 10): no allocated number is root's (§15). The store refuses a row outside it.
|
||||
//
|
||||
// **Only to an engine that reads it** (Rendering.KeepsLibraries: its statement's contract is link.LibraryContract or
|
||||
// later), the declaration carries `groups`, `identities` and `libraries` (libraryEnvelope). To an older engine a module
|
||||
// that joins is left out of the declaration, naming why — its placeholders could not be filled — and everything else is
|
||||
// composed as before.
|
||||
|
||||
// The range the mesh allocates user and group numbers from (novox/hq ADR 0328 §3, §4, §15). The store holds the same
|
||||
// bounds.
|
||||
const (
|
||||
IdentityLow = 50000
|
||||
IdentityHigh = 59999
|
||||
)
|
||||
|
||||
// AllocatableID says whether n is a number the mesh may give: inside the range, so never 0 nor a root group's.
|
||||
func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh }
|
||||
|
||||
// ADR0328 is how the checks name the decision their rules come from.
|
||||
const ADR0328 = "novox/hq ADR 0328"
|
||||
|
||||
// MeshName is the user or group a module or a library is on every machine: mesh-<name>.
|
||||
func MeshName(name string) string { return "mesh-" + name }
|
||||
|
||||
// IdentityName is the user a joining module runs as, and its own group.
|
||||
func IdentityName(module string) string { return MeshName(module) }
|
||||
|
||||
// ---- libraries, as the operator defines them -----------------------------------------------------------------
|
||||
|
||||
// LibrariesSetting is the key of the controller's mesh-wide settings layer that defines the libraries (§1).
|
||||
const LibrariesSetting = "libraries"
|
||||
|
||||
// LibrariesModule is the module whose mesh-wide layer holds LibrariesSetting: the controller's own.
|
||||
const LibrariesModule = "mesh-controller"
|
||||
|
||||
// Library is one library as the operator defined it.
|
||||
type Library struct {
|
||||
Name string `json:"-"`
|
||||
// Home is the machine the data lives on; Root its path there, the same on every machine it reaches.
|
||||
Home string `json:"home"`
|
||||
Root string `json:"root"`
|
||||
// Reach is every machine the library reaches, its home among them.
|
||||
Reach []string `json:"reach,omitempty"`
|
||||
}
|
||||
|
||||
// Reaches says whether the library reaches a machine: its home, or one it names.
|
||||
func (l Library) Reaches(node string) bool { return node == l.Home || hasName(l.Reach, node) }
|
||||
|
||||
// Machines is every machine the library reaches, its home first.
|
||||
func (l Library) Machines() []string {
|
||||
out := []string{l.Home}
|
||||
for _, n := range l.Reach {
|
||||
if n != l.Home && !hasName(out, n) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Group is the library's group: mesh-<library>.
|
||||
func (l Library) Group() string { return MeshName(l.Name) }
|
||||
|
||||
var libraryName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
// ParseLibraries reads the `libraries` setting: {<name>: {home, root, reach}}, or refuses it in words.
|
||||
func ParseLibraries(v any) (map[string]Library, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
blocks, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a { <library>: {home, root, reach} } map (%s §1)", LibrariesSetting, ADR0328)
|
||||
}
|
||||
out := map[string]Library{}
|
||||
for name, raw := range blocks {
|
||||
if !libraryName.MatchString(name) {
|
||||
return nil, fmt.Errorf("the library %q: a library's name is lowercase letters, digits and dashes, starting "+
|
||||
"with a letter", name)
|
||||
}
|
||||
fields, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the library %s is {home, root, reach}, not %v", name, raw)
|
||||
}
|
||||
l := Library{Name: name}
|
||||
for k, v := range fields {
|
||||
switch k {
|
||||
case "home":
|
||||
l.Home, _ = v.(string)
|
||||
case "root":
|
||||
l.Root, _ = v.(string)
|
||||
case "reach":
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the library %s's reach is a list of machines", name)
|
||||
}
|
||||
for _, n := range list {
|
||||
s, _ := n.(string)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("the library %s's reach names a machine that is not a name: %v", name, n)
|
||||
}
|
||||
l.Reach = append(l.Reach, s)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("the library %s says %q; a library is its home, its root and its reach, and "+
|
||||
"nothing about an owner, a group, a mode or a number (%s §1)", name, k, ADR0328)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(l.Home) == "" {
|
||||
return nil, fmt.Errorf("the library %s names no home machine", name)
|
||||
}
|
||||
if !strings.HasPrefix(l.Root, "/") {
|
||||
return nil, fmt.Errorf("the library %s's root %q is not an absolute path", name, l.Root)
|
||||
}
|
||||
l.Root = filepath.Clean(l.Root)
|
||||
if why := systemPath(l.Root); why != "" {
|
||||
return nil, fmt.Errorf("the library %s's root is %s: %s, and the node-engine keeps a library's root in its "+
|
||||
"group — no library is ever there", name, l.Root, why)
|
||||
}
|
||||
sort.Strings(l.Reach)
|
||||
out[name] = l
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LibrariesProblems refuses what no library may be together (§4, §13): a library named as a registered module, and two
|
||||
// libraries on one machine whose roots lie inside each other — each named.
|
||||
func LibrariesProblems(libs map[string]Library, modules []string) []string {
|
||||
var problems []string
|
||||
names := make([]string, 0, len(libs))
|
||||
for n := range libs {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if hasName(modules, n) {
|
||||
problems = append(problems, fmt.Sprintf("the library %s has the name of the module %s: %s would be both "+
|
||||
"(%s §4)", n, n, MeshName(n), ADR0328))
|
||||
}
|
||||
}
|
||||
for i, a := range names {
|
||||
for _, b := range names[i+1:] {
|
||||
la, lb := libs[a], libs[b]
|
||||
shared := ""
|
||||
for _, m := range la.Machines() {
|
||||
if lb.Reaches(m) {
|
||||
shared = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if shared == "" {
|
||||
continue
|
||||
}
|
||||
if within(la.Root, lb.Root) || within(lb.Root, la.Root) {
|
||||
problems = append(problems, fmt.Sprintf("the libraries %s (%s) and %s (%s) overlap on %s: a library's "+
|
||||
"root lies neither inside another's nor around it — divide one library by the paths its modules "+
|
||||
"ask for (%s §13)", a, la.Root, b, lb.Root, shared, ADR0328))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// within says path is root or lies below it.
|
||||
func within(path, root string) bool {
|
||||
return path == root || strings.HasPrefix(path, strings.TrimSuffix(root, "/")+"/")
|
||||
}
|
||||
|
||||
func hasName(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- a module's use of them ---------------------------------------------------------------------------------
|
||||
|
||||
// LibraryUse is one `libraries` entry of a manifest: the id the module's resources name it by, the library, the
|
||||
// sub-path within it, and the use.
|
||||
type LibraryUse struct {
|
||||
ID string `json:"id"`
|
||||
Library string `json:"library"`
|
||||
Path string `json:"path"`
|
||||
Use string `json:"use"`
|
||||
}
|
||||
|
||||
// At is the entry's use, read by default.
|
||||
func (u LibraryUse) At() string {
|
||||
if u.Use == "" {
|
||||
return AccessRead
|
||||
}
|
||||
return u.Use
|
||||
}
|
||||
|
||||
// libraryRef is how a module names one of its library entries: ${library:<id>}.
|
||||
var libraryRef = regexp.MustCompile(`\$\{library:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
// identityRef is a module's identity by name: ${identity:user|uid|group|gid}.
|
||||
var identityRef = regexp.MustCompile(`\$\{identity:(user|uid|group|gid)\}`)
|
||||
|
||||
// Joins says a module joins the model (§14): it declares libraries or uses its identity anywhere.
|
||||
func Joins(m Manifest) bool {
|
||||
if len(m.Libraries) > 0 {
|
||||
return true
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
walkStrings(r, func(s string) {
|
||||
found = found || identityRef.MatchString(s) || strings.Contains(s, "${identity:")
|
||||
})
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
for _, v := range b.Env {
|
||||
found = found || strings.Contains(v, "${identity:")
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// walkStrings calls see with every string a value holds, however deep.
|
||||
func walkStrings(v any, see func(string)) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
see(v)
|
||||
case []any:
|
||||
for _, x := range v {
|
||||
walkStrings(x, see)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, x := range v {
|
||||
walkStrings(x, see)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStrings is v with every string it holds replaced by fill's answer, however deep; the first error stops it.
|
||||
func mapStrings(v any, fill func(string) (string, error)) (any, error) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return fill(v)
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, x := range v {
|
||||
y, err := mapStrings(x, fill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = y
|
||||
}
|
||||
return out, nil
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for k, x := range v {
|
||||
y, err := mapStrings(x, fill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = y
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var libraryEntryID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// numberWords are the setting names that carry a user or group number or an owner: none for a module that joins (§5).
|
||||
// "user" and "group" are left out: an application's own login or group name is a setting like any other.
|
||||
var numberWords = map[string]bool{"uid": true, "gid": true, "puid": true, "pgid": true, "user-id": true,
|
||||
"group-id": true, "owner": true}
|
||||
|
||||
// libraryProblems is what the module check refuses of a manifest's libraries and identity (§2, §4, §5, §14):
|
||||
//
|
||||
// - an entry that is not {id, library, path, use} with a relative sub-path inside its library, or names a host
|
||||
// path, an owner, a group, a mode or a number (the entry's shape refuses any other key);
|
||||
// - two entries with one id, or a `${library:<id>}` naming none;
|
||||
// - `${library:<id>}` anywhere but a container's volume source (and a word of its tools, a bundle's env);
|
||||
// - `${identity:…}` other than user, uid, group or gid;
|
||||
// - in a module that joins: a directory naming its owner. A setting of it naming a number or an owner is refused
|
||||
// where it is set (JoiningSettingProblem).
|
||||
func libraryProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
ids := map[string]bool{}
|
||||
for _, u := range m.Libraries {
|
||||
where := fmt.Sprintf("%s's library entry %q", m.Module, u.ID)
|
||||
switch {
|
||||
case !libraryEntryID.MatchString(u.ID):
|
||||
problems = append(problems, where+": an id is lowercase letters, digits and dashes")
|
||||
case ids[u.ID]:
|
||||
problems = append(problems, where+": two entries share the id")
|
||||
}
|
||||
ids[u.ID] = true
|
||||
if !libraryName.MatchString(u.Library) {
|
||||
problems = append(problems, fmt.Sprintf("%s names the library %q, which is not a library's name", where,
|
||||
u.Library))
|
||||
}
|
||||
clean := filepath.Clean(u.Path)
|
||||
if u.Path == "" || strings.HasPrefix(u.Path, "/") || clean == ".." || strings.HasPrefix(clean, "../") ||
|
||||
strings.ContainsAny(u.Path, "${}") {
|
||||
problems = append(problems, fmt.Sprintf("%s's path %q is a sub-path inside the library, relative and "+
|
||||
"staying inside it — never a host path (%s §2)", where, u.Path, ADR0328))
|
||||
}
|
||||
if u.At() != AccessRead && u.At() != AccessReadWrite {
|
||||
problems = append(problems, fmt.Sprintf("%s is used %q; a use is %q or %q", where, u.Use, AccessRead,
|
||||
AccessReadWrite))
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
kind := fmt.Sprint(r["type"])
|
||||
for field, v := range r {
|
||||
walkStrings(v, func(s string) {
|
||||
for _, tok := range regexp.MustCompile(`\$\{identity:[^}]*\}`).FindAllString(s, -1) {
|
||||
if !identityRef.MatchString(tok) {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says %s: the identity is "+
|
||||
"${identity:user}, ${identity:uid}, ${identity:group} or ${identity:gid} (%s §5)", m.Module,
|
||||
r["id"], tok, ADR0328))
|
||||
}
|
||||
}
|
||||
for _, match := range libraryRef.FindAllStringSubmatch(s, -1) {
|
||||
if !ids[match[1]] {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says ${library:%s}, and declares no "+
|
||||
"library entry %q", m.Module, r["id"], match[1], match[1]))
|
||||
}
|
||||
source := ""
|
||||
if kind == "container" && field == "volumes" {
|
||||
source = volumeSource(s)
|
||||
}
|
||||
if !strings.Contains(source, match[0]) {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says %s in its %s: 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 (%s §5)", m.Module, r["id"], match[0], field, ADR0328))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if !Joins(m) {
|
||||
return problems
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
if owner, said := r["owner"]; said {
|
||||
problems = append(problems, fmt.Sprintf("%s: the directory %v names its owner %v; a module that joins "+
|
||||
"owns its directories by its user and primary group, and names no owner (%s §4)", m.Module,
|
||||
r["id"], owner, ADR0328))
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
seen := map[string]bool{}
|
||||
var libs []string
|
||||
for _, u := range m.Libraries {
|
||||
if !seen[u.Library] {
|
||||
seen[u.Library] = true
|
||||
libs = append(libs, u.Library)
|
||||
}
|
||||
}
|
||||
if len(libs) < 2 {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s uses the libraries %s: its user's primary group is %s, and %s are added groups — an "+
|
||||
"image that drops added groups when it changes user reaches only %s (%s §4)", m.Module, strings.Join(libs, ", "),
|
||||
MeshName(libs[0]), strings.Join(meshNames(libs[1:]), ", "), libs[0], ADR0328)}
|
||||
}
|
||||
|
||||
func meshNames(names []string) []string {
|
||||
out := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = MeshName(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JoiningSettingProblem refuses, where a setting is set, a layer of a module that joins that carries a user or group
|
||||
// number or an owner (§5): a key whose name says so, or an owner a `places` setting names — its directories are owned
|
||||
// by its user and primary group, and it reads its own as ${identity:…}.
|
||||
func JoiningSettingProblem(m Manifest, layers []Layer) error {
|
||||
if !Joins(m) {
|
||||
return nil
|
||||
}
|
||||
for _, l := range layers {
|
||||
for k := range l.Values {
|
||||
if numberWords[k] {
|
||||
return fmt.Errorf("%s joins, and the layer %q sets %q: no setting of a module that joins carries a user "+
|
||||
"or group number or an owner — it reads its own as ${identity:…} (%s §5)", m.Module, l.From, k, ADR0328)
|
||||
}
|
||||
}
|
||||
}
|
||||
return PlacesOwnerProblem(m, layers)
|
||||
}
|
||||
|
||||
// PlacesOwnerProblem refuses an owner a `places` setting names for a module that joins (§4, §5): its directories are
|
||||
// owned by its user and primary group.
|
||||
func PlacesOwnerProblem(m Manifest, layers []Layer) error {
|
||||
if !Joins(m) {
|
||||
return nil
|
||||
}
|
||||
placed, err := Places(m, layers)
|
||||
if err != nil {
|
||||
return nil // refused by name where it is judged
|
||||
}
|
||||
var owned []string
|
||||
for id, p := range placed {
|
||||
if p.Owner != "" {
|
||||
owned = append(owned, id)
|
||||
}
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(owned)
|
||||
return fmt.Errorf("%s places %s with an owner, and it joins: its directories are owned by its user and primary "+
|
||||
"group, and no setting of it carries an owner (%s §4, §5)", m.Module, strings.Join(owned, ", "), ADR0328)
|
||||
}
|
||||
|
||||
// ---- composition ------------------------------------------------------------------------------------------------
|
||||
|
||||
// ModuleIdentity is a joining module's allocated number: its user's and its own group's.
|
||||
type ModuleIdentity struct {
|
||||
Module string
|
||||
UID int
|
||||
}
|
||||
|
||||
// Joined is a joining module's identity on a machine, as its placeholders are filled and its directories owned.
|
||||
type Joined struct {
|
||||
Module string
|
||||
UID int
|
||||
// Primary is its user's primary group, by name and number; Added its other groups, by name, in the order used.
|
||||
Primary string
|
||||
PrimaryGID int
|
||||
Added []string
|
||||
}
|
||||
|
||||
// librariesOf is the libraries a module uses, by name, in the order its entries first name them.
|
||||
func librariesOf(m Manifest) []string {
|
||||
var out []string
|
||||
for _, u := range m.Libraries {
|
||||
if !hasName(out, u.Library) {
|
||||
out = append(out, u.Library)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// joinedOf is module m's identity here: its number, and its groups from the libraries it uses. Refused, naming
|
||||
// what, where a number is missing or outside the range (§15).
|
||||
func joinedOf(m Manifest, with Rendering) (Joined, error) {
|
||||
id, has := with.Identities[m.Module]
|
||||
if !has {
|
||||
return Joined{}, fmt.Errorf("%s joins, and has no number yet: it is allocated at its first send", m.Module)
|
||||
}
|
||||
if !AllocatableID(id.UID) {
|
||||
return Joined{}, fmt.Errorf("%s's number is %d, outside %d–%d: no number the mesh gives is root's or a root "+
|
||||
"group's, and none is sent (%s §15)", m.Module, id.UID, IdentityLow, IdentityHigh, ADR0328)
|
||||
}
|
||||
j := Joined{Module: m.Module, UID: id.UID, Primary: IdentityName(m.Module), PrimaryGID: id.UID}
|
||||
for i, lib := range librariesOf(m) {
|
||||
gid, has := with.LibraryGroups[lib]
|
||||
if !has || !AllocatableID(gid) {
|
||||
return Joined{}, fmt.Errorf("%s uses the library %s, whose group has no number the mesh may give (%s §3, "+
|
||||
"§15)", m.Module, lib, ADR0328)
|
||||
}
|
||||
if i == 0 {
|
||||
j.Primary, j.PrimaryGID = MeshName(lib), gid
|
||||
continue
|
||||
}
|
||||
j.Added = append(j.Added, MeshName(lib))
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
func libraryLeftOut(m Manifest, node string, with Rendering) string {
|
||||
if !Joins(m) {
|
||||
return ""
|
||||
}
|
||||
if !with.KeepsLibraries {
|
||||
return fmt.Sprintf("%s joins the mesh's users and libraries (%s), and the node-engine on %s is older than "+
|
||||
"them: it is left out until that engine is updated", m.Module, ADR0328, node)
|
||||
}
|
||||
for _, lib := range librariesOf(m) {
|
||||
l, defined := with.Libraries[lib]
|
||||
switch {
|
||||
case !defined:
|
||||
return fmt.Sprintf("%s uses the library %s, which is not defined: the operator defines it (%s §1)",
|
||||
m.Module, lib, ADR0328)
|
||||
case !l.Reaches(node):
|
||||
return fmt.Sprintf("%s uses the library %s, which does not reach %s (%s §2)", m.Module, lib, node, ADR0328)
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
// libraryPaths is where each of module m's library entries is on this machine: its library's root joined with the
|
||||
// entry's path, by entry id.
|
||||
func libraryPaths(m Manifest, with Rendering) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, u := range m.Libraries {
|
||||
if l, ok := with.Libraries[u.Library]; ok {
|
||||
out[u.ID] = filepath.Join(l.Root, filepath.Clean(u.Path))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityFill fills ${identity:…} in one string.
|
||||
func identityFill(s string, j Joined) string {
|
||||
return identityRef.ReplaceAllStringFunc(s, func(tok string) string {
|
||||
switch identityRef.FindStringSubmatch(tok)[1] {
|
||||
case "user":
|
||||
return IdentityName(j.Module)
|
||||
case "uid":
|
||||
return fmt.Sprint(j.UID)
|
||||
case "group":
|
||||
return j.Primary
|
||||
default:
|
||||
return fmt.Sprint(j.PrimaryGID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// joinedInto composes one resource of a joining module (§4, §5, §10): ${identity:…} filled everywhere, a container's
|
||||
// ${library:<id>} volume sources filled with the host path and a read entry mounted read-only, and a directory owned by
|
||||
// the module's user and primary group.
|
||||
func joinedInto(resource map[string]any, m Manifest, j Joined, paths map[string]string) error {
|
||||
for k, v := range resource {
|
||||
if k == "id" || k == "type" {
|
||||
continue
|
||||
}
|
||||
filled, err := mapStrings(v, func(s string) (string, error) { return identityFill(s, j), nil })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resource[k] = filled
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.PrimaryGID)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) != "container" {
|
||||
return nil
|
||||
}
|
||||
volumes, _ := resource["volumes"].([]any)
|
||||
out := make([]any, len(volumes))
|
||||
for i, v := range volumes {
|
||||
out[i] = v
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source := volumeSource(s)
|
||||
match := libraryRef.FindStringSubmatch(source)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
path, has := paths[match[1]]
|
||||
if !has {
|
||||
return fmt.Errorf("%s says ${library:%s}, and has no library entry %q", m.Module, match[1], match[1])
|
||||
}
|
||||
filled := strings.Replace(source, match[0], path, 1) + strings.TrimPrefix(s, source)
|
||||
for _, u := range m.Libraries {
|
||||
if u.ID == match[1] && u.At() == AccessRead {
|
||||
filled = readOnly(filled)
|
||||
}
|
||||
}
|
||||
out[i] = filled
|
||||
}
|
||||
if len(volumes) > 0 {
|
||||
resource["volumes"] = out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readOnly is a volume mounted read-only: "src:dst" becomes "src:dst:ro", and options gain ro in place of rw.
|
||||
func readOnly(v string) string {
|
||||
parts := strings.Split(v, ":")
|
||||
if len(parts) < 2 {
|
||||
return v
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return v + ":ro"
|
||||
}
|
||||
opts := strings.Split(parts[len(parts)-1], ",")
|
||||
kept := []string{"ro"}
|
||||
for _, o := range opts {
|
||||
if o != "ro" && o != "rw" && o != "" {
|
||||
kept = append(kept, o)
|
||||
}
|
||||
}
|
||||
parts[len(parts)-1] = strings.Join(kept, ",")
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so
|
||||
// "${dir:config}:/config" is "${dir:config}".
|
||||
func volumeSource(v string) string {
|
||||
depth := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r == '{' && i > 0 && v[i-1] == '$':
|
||||
depth++
|
||||
case r == '}' && depth > 0:
|
||||
depth--
|
||||
case r == ':' && depth == 0:
|
||||
return v[:i]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// bundleLibraryFill fills a joining module's tool words (a bundle's env): ${identity:…}, and ${library:<id>} with the
|
||||
// host path, since its tools run on the machine (§5).
|
||||
func bundleLibraryFill(value string, m Manifest, with Rendering) (string, error) {
|
||||
if !strings.Contains(value, "${identity:") && !strings.Contains(value, "${library:") {
|
||||
return value, nil
|
||||
}
|
||||
j, err := joinedOf(m, with)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
paths := libraryPaths(m, with)
|
||||
var missing error
|
||||
value = libraryRef.ReplaceAllStringFunc(identityFill(value, j), func(tok string) string {
|
||||
id := libraryRef.FindStringSubmatch(tok)[1]
|
||||
p, has := paths[id]
|
||||
if !has {
|
||||
missing = fmt.Errorf("%s's tools say ${library:%s}, and it has no library entry %q", m.Module, id, id)
|
||||
return tok
|
||||
}
|
||||
return p
|
||||
})
|
||||
return value, missing
|
||||
}
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no
|
||||
// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// LibraryEntry is one library as a machine's declaration names it (§3, §6, §7, §11).
|
||||
type LibraryEntry struct {
|
||||
Library string `json:"library"`
|
||||
Root string `json:"root"`
|
||||
Group string `json:"group"`
|
||||
GID int `json:"gid"`
|
||||
// Home says this machine is the library's home, where the engine keeps it; Taken that the operator let the mesh
|
||||
// take its existing data there (§7).
|
||||
Home bool `json:"home"`
|
||||
Taken bool `json:"taken"`
|
||||
// Paths are the sub-paths its modules ask for, relative to its root.
|
||||
Paths []string `json:"paths"`
|
||||
// Runner is the tool runner's account where a module whose tools run there uses the library: a member of its
|
||||
// group (§11). Empty otherwise.
|
||||
Runner string `json:"runner,omitempty"`
|
||||
}
|
||||
|
||||
// GroupEntry is one group a machine's declaration names: a library's, made on every machine the library reaches (§3).
|
||||
type GroupEntry struct {
|
||||
Name string `json:"name"`
|
||||
GID int `json:"gid"`
|
||||
}
|
||||
|
||||
// IdentityEntry is one joining module's user as a machine's declaration names it (§4): its own group has its uid,
|
||||
// and Groups are its library groups, the primary first.
|
||||
type IdentityEntry struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// libraryEnvelope is what a machine's declaration says of libraries and joining modules: the group of every library
|
||||
// that reaches it, each joining module composed here, and each library a module here uses or that has its home here.
|
||||
func (r Resolution) libraryEnvelope(with Rendering, joined []Joined) ([]GroupEntry, []IdentityEntry, []LibraryEntry) {
|
||||
if !with.KeepsLibraries {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var groups []GroupEntry
|
||||
for name, l := range with.Libraries {
|
||||
if gid, has := with.LibraryGroups[name]; has && l.Reaches(r.Node) && AllocatableID(gid) {
|
||||
groups = append(groups, GroupEntry{Name: l.Group(), GID: gid})
|
||||
}
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
|
||||
var ids []IdentityEntry
|
||||
for _, j := range joined {
|
||||
e := IdentityEntry{Module: j.Module, Name: IdentityName(j.Module), UID: j.UID, GID: j.UID}
|
||||
if j.Primary != IdentityName(j.Module) {
|
||||
e.Groups = append([]string{j.Primary}, j.Added...)
|
||||
}
|
||||
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) != "" {
|
||||
continue
|
||||
}
|
||||
for _, u := range m.Libraries {
|
||||
if used[u.Library] == nil {
|
||||
used[u.Library] = map[string]bool{}
|
||||
}
|
||||
used[u.Library][filepath.Clean(u.Path)] = true
|
||||
if a := r.toolsAccount(m, with); a != "" && runner[u.Library] == "" {
|
||||
runner[u.Library] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
var libs []LibraryEntry
|
||||
for name, l := range with.Libraries {
|
||||
home := l.Home == r.Node
|
||||
if used[name] == nil && !home {
|
||||
continue
|
||||
}
|
||||
gid, has := with.LibraryGroups[name]
|
||||
if !has || !l.Reaches(r.Node) {
|
||||
continue
|
||||
}
|
||||
paths := map[string]bool{}
|
||||
for p := range used[name] {
|
||||
paths[p] = true
|
||||
}
|
||||
if home {
|
||||
// On its home the engine keeps every sub-path any module asks for, wherever it runs (§6).
|
||||
for _, p := range with.LibraryPaths[name] {
|
||||
paths[filepath.Clean(p)] = true
|
||||
}
|
||||
}
|
||||
e := LibraryEntry{Library: name, Root: l.Root, Group: l.Group(), GID: gid, Home: home,
|
||||
Taken: with.TakenLibraries[name], Paths: []string{}, Runner: runner[name]}
|
||||
for p := range paths {
|
||||
e.Paths = append(e.Paths, p)
|
||||
}
|
||||
sort.Strings(e.Paths)
|
||||
libs = append(libs, e)
|
||||
}
|
||||
sort.Slice(libs, func(i, j int) bool { return libs[i].Library < libs[j].Library })
|
||||
return groups, ids, libs
|
||||
}
|
||||
|
||||
// LibraryUseShape is a module's libraries and whether it joins, in a form that compares: a move that changes either
|
||||
// changes who uses its libraries (the gate's reading of an unknown or a held library).
|
||||
func LibraryUseShape(m Manifest) string {
|
||||
var out []string
|
||||
for _, u := range m.Libraries {
|
||||
out = append(out, u.ID+"\x00"+u.Library+"\x00"+filepath.Clean(u.Path)+"\x00"+u.At())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return fmt.Sprint(Joins(m), out)
|
||||
}
|
||||
|
||||
// LibraryOfEntry is the library a module's entry id names, or "".
|
||||
func LibraryOfEntry(m Manifest, id string) string {
|
||||
for _, u := range m.Libraries {
|
||||
if u.ID == id {
|
||||
return u.Library
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the
|
||||
// definition's default; false where neither says, or the module declares no such access.
|
||||
func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) {
|
||||
_, byID, err := accessesFor(m, layers)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
path, has := byID[id]
|
||||
if !has || path == "" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(path), true
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0328 on the controller's side, row by row of its "How it is checked" table where the row is the
|
||||
// controller's: libraries defined (1, 13), entries resolved (2), groups and numbers (3, 4, 15), the identity and library
|
||||
// placeholders (5), a read use mounted read-only (10), the tool runner in a library's group (11), a joining module held
|
||||
// until its libraries are taken (8), and a module that does not join composed exactly as before (14).
|
||||
|
||||
const libraryImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
var mediaLibrary = Library{Name: "media", Home: "ace", Root: "/storage/media", Reach: []string{"novox"}}
|
||||
|
||||
// aRadarr writes movies and reads books, tells its image its identity by PUID and PGID, and has tools on the runner.
|
||||
func aRadarr(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "radarr", "tools/index.js")
|
||||
m.Libraries = []LibraryUse{
|
||||
{ID: "movies", Library: "media", Path: "movies", Use: AccessReadWrite},
|
||||
{ID: "books", Library: "books", Path: "shelf", Use: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "env", "type": "file", "path": "${dir:config}/identity.env", "mode": "0644",
|
||||
"content": "PUID=${identity:uid}\nPGID=${identity:gid}\nUSER=${identity:user}\nGROUP=${identity:group}\nUMASK=002\n"},
|
||||
{"id": "app", "type": "container", "name": "radarr", "image": libraryImage,
|
||||
"env": map[string]any{"PUID": "${identity:uid}", "PGID": "${identity:gid}"},
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies", "${library:books}:/books:z"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// aPlain is a module that neither declares libraries nor uses its identity.
|
||||
func aPlain() Manifest {
|
||||
return Manifest{Module: "plain", Version: "1", Resources: []map[string]any{
|
||||
{"id": "data", "type": "directory", "mode": "0700", "owner": "999:999"},
|
||||
{"id": "app", "type": "container", "name": "plain", "image": libraryImage, "volumes": []any{"${dir:data}:/data"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func librariesRendering() Rendering {
|
||||
books := Library{Name: "books", Home: "ace", Root: "/storage/books"}
|
||||
return Rendering{KeepsLibraries: true,
|
||||
Libraries: map[string]Library{"media": mediaLibrary, "books": books},
|
||||
LibraryGroups: map[string]int{"media": 50010, "books": 50011},
|
||||
Identities: map[string]ModuleIdentity{"radarr": {Module: "radarr", UID: 50020}},
|
||||
TakenLibraries: map[string]bool{"media": true, "books": true},
|
||||
LibraryPaths: map[string][]string{"media": {"movies", "series"}},
|
||||
}
|
||||
}
|
||||
|
||||
func composeLibraries(t *testing.T, r Resolution, with Rendering) Composed {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return composed
|
||||
}
|
||||
|
||||
// Rows 2, 4, 5, 10: a joining module's identity is filled everywhere, its library entries resolve to root + path in its
|
||||
// volume sources, a read entry mounts read-only, its directories are owned by its user and primary group — the first
|
||||
// library it uses — and its other libraries are added groups.
|
||||
func TestAJoiningModuleIsComposedWithItsIdentityAndLibraries(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
composed := composeLibraries(t, r, librariesRendering())
|
||||
got := byID(composed.Resources)
|
||||
|
||||
app := got["radarr.app"]
|
||||
if fmt.Sprint(app["env"]) != fmt.Sprint(map[string]any{"PUID": "50020", "PGID": "50010"}) {
|
||||
t.Errorf("the container's environment is %v", app["env"])
|
||||
}
|
||||
want := []any{"/var/lib/radarr/config:/config", "/storage/media/movies:/movies", "/storage/books/shelf:/books:ro,z"}
|
||||
if !reflect.DeepEqual(app["volumes"], want) {
|
||||
t.Errorf("the volumes are %v; want %v", app["volumes"], want)
|
||||
}
|
||||
if content := fmt.Sprint(got["radarr.env"]["content"]); content !=
|
||||
"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 len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr",
|
||||
Name: "mesh-radarr", UID: 50020, GID: 50020, Groups: []string{"mesh-media", "mesh-books"}}) {
|
||||
t.Errorf("the identities are %+v", composed.Identities)
|
||||
}
|
||||
if w := LibraryWarnings(aRadarr(t)); len(w) != 1 || !strings.Contains(w[0], "reaches only media") {
|
||||
t.Errorf("a module of two libraries is warned %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 3, 7, 11: every library reaching the machine has its group, with its number; each library used here or homed
|
||||
// here is named with its root, whether this is its home, whether it is taken, the sub-paths kept on its home, and the
|
||||
// tool runner's account where a module whose tools run there uses it.
|
||||
func TestTheDeclarationNamesTheLibrariesAndTheirGroups(t *testing.T) {
|
||||
with := librariesRendering()
|
||||
with.TakenLibraries = map[string]bool{"media": false, "books": true}
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
composed := composeLibraries(t, r, with)
|
||||
raw, _ := json.Marshal(map[string]any{"groups": composed.Groups, "libraries": composed.Libraries})
|
||||
want := `{"groups":[{"name":"mesh-books","gid":50011},{"name":"mesh-media","gid":50010}],` +
|
||||
`"libraries":[{"library":"books","root":"/storage/books","group":"mesh-books","gid":50011,"home":true,` +
|
||||
`"taken":true,"paths":["shelf"],"runner":"jochen"},` +
|
||||
`{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,"taken":false,` +
|
||||
`"paths":["movies","series"],"runner":"jochen"}]}`
|
||||
if string(raw) != want {
|
||||
t.Errorf("the declaration says\n %s\nwant\n %s", raw, want)
|
||||
}
|
||||
// On a machine it reaches but is not home: the group, the library as used there, and no sub-path from elsewhere.
|
||||
elsewhere := composeLibraries(t, Resolution{Node: "novox", Account: "jochen",
|
||||
Modules: []Manifest{aPlain(), theRuntime(t)}}, with)
|
||||
raw, _ = json.Marshal(map[string]any{"groups": elsewhere.Groups, "libraries": elsewhere.Libraries})
|
||||
if string(raw) != `{"groups":[{"name":"mesh-media","gid":50010}],"libraries":null}` {
|
||||
t.Errorf("on a machine it only reaches: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 2: a module assigned to a machine its library does not reach is left out there, naming the library; one whose
|
||||
// library is not defined likewise.
|
||||
func TestALibraryNotReachingTheMachineLeavesItsModuleOut(t *testing.T) {
|
||||
with := librariesRendering()
|
||||
with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: 50020}
|
||||
composed := composeLibraries(t, Resolution{Node: "far", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "does not reach far") {
|
||||
t.Errorf("radarr on a machine media does not reach: %q", why)
|
||||
}
|
||||
delete(with.Libraries, "books")
|
||||
composed = composeLibraries(t, Resolution{Node: "ace", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "books, which is not defined") {
|
||||
t.Errorf("radarr with an undefined library: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
if Joins(aPlain()) || !Joins(aRadarr(t)) {
|
||||
t.Fatal("joining is not read from the libraries and the identity")
|
||||
}
|
||||
older := librariesRendering()
|
||||
older.KeepsLibraries = false
|
||||
composed := composeLibraries(t, Resolution{Node: "ace", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), aPlain(), theRuntime(t)}}, older)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "older than them") {
|
||||
t.Fatalf("a joining module on an older engine: %q", why)
|
||||
}
|
||||
if len(composed.Groups)+len(composed.Identities)+len(composed.Libraries) > 0 {
|
||||
t.Fatal("an older engine was sent groups, identities or libraries")
|
||||
}
|
||||
if byID(composed.Resources)["plain.app"] == nil {
|
||||
t.Fatal("a module that does not join was left out beside one that does")
|
||||
}
|
||||
}
|
||||
|
||||
// Row 15: no number outside the range is sent, root's above all; the composition refuses, naming why.
|
||||
func TestNoNumberTheMeshGivesIsRoot(t *testing.T) {
|
||||
for _, n := range []int{0, 4, 10, IdentityLow - 1, IdentityHigh + 1} {
|
||||
if AllocatableID(n) {
|
||||
t.Errorf("%d is allocatable", n)
|
||||
}
|
||||
with := librariesRendering()
|
||||
with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: n}
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§15") {
|
||||
t.Errorf("a module numbered %d composed: %v", n, err)
|
||||
}
|
||||
with = librariesRendering()
|
||||
with.LibraryGroups["media"] = n
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§15") {
|
||||
t.Errorf("a library group numbered %d composed: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func libraryManifest(t *testing.T, extra map[string]any) []byte {
|
||||
t.Helper()
|
||||
m := map[string]any{"module": "app", "version": "1", "capabilities": []any{"container-runtime"},
|
||||
"libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"}},
|
||||
"resources": []any{map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${library:movies}:/movies"}}}}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// Rows 2 and 5 at the module check: library entries are accepted by name, sub-path and use, and refused with a host
|
||||
// path, an owner, a group, a mode or a number; ${library:<id>} only in a volume source; ${identity:…} only by its four
|
||||
// names; a joining module names no directory owner and declares no number setting.
|
||||
func TestTheModuleCheckHoldsLibrariesAndIdentityToTheDecision(t *testing.T) {
|
||||
if _, err := ParseManifest(libraryManifest(t, nil)); err != nil {
|
||||
t.Fatalf("a library entry was refused: %v", err)
|
||||
}
|
||||
entry := func(extra map[string]any) map[string]any {
|
||||
e := map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"}
|
||||
for k, v := range extra {
|
||||
e[k] = v
|
||||
}
|
||||
return map[string]any{"libraries": []any{e}}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
extra map[string]any
|
||||
want string
|
||||
}{
|
||||
"a host path": {entry(map[string]any{"path": "/storage/media/movies"}), "never a host path"},
|
||||
"out of it": {entry(map[string]any{"path": "../other"}), "never a host path"},
|
||||
"an owner": {entry(map[string]any{"owner": "1000"}), "owner"},
|
||||
"a group": {entry(map[string]any{"group": "media"}), "group"},
|
||||
"a mode": {entry(map[string]any{"mode": "0775"}), "mode"},
|
||||
"a number": {entry(map[string]any{"gid": 1000}), "gid"},
|
||||
"a use": {entry(map[string]any{"use": "write"}), "is used \"write\""},
|
||||
"in the env": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "env": map[string]any{"MOVIES": "${library:movies}"}}}}, "volume source"},
|
||||
"in a file": {map[string]any{"resources": []any{map[string]any{"id": "f", "type": "file", "path": "/etc/app.conf",
|
||||
"mode": "0644", "content": "movies=${library:movies}"}}}, "volume source"},
|
||||
"on the container side": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "volumes": []any{"/srv:${library:movies}"}}}}, "volume source"},
|
||||
"an unknown identity": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "env": map[string]any{"HOME": "${identity:home}"}}}}, "${identity:home}"},
|
||||
"a directory owner": {map[string]any{"resources": []any{map[string]any{"id": "d", "type": "directory",
|
||||
"mode": "0750", "owner": "1000:1000"}}}, "names its owner"},
|
||||
} {
|
||||
_, err := ParseManifest(libraryManifest(t, c.extra))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v; want refused saying %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
// A module that does not join keeps its directory owner and its settings as they are (§14).
|
||||
plain := map[string]any{"module": "plain", "version": "1",
|
||||
"resources": []any{map[string]any{"id": "d", "type": "directory", "mode": "0750", "owner": "1000:1000"}}}
|
||||
raw, _ := json.Marshal(plain)
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Errorf("a module that does not join was refused: %v", err)
|
||||
}
|
||||
// A setting carrying a number, or a placement's owner, is refused for a joining module, and kept for one that does
|
||||
// not.
|
||||
if err := JoiningSettingProblem(aRadarr(t), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err == nil ||
|
||||
!strings.Contains(err.Error(), "carries a user or group number") {
|
||||
t.Errorf("a joining module's puid setting: %v", err)
|
||||
}
|
||||
if err := JoiningSettingProblem(aPlain(), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err != nil {
|
||||
t.Errorf("a plain module's puid setting: %v", err)
|
||||
}
|
||||
placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/srv/x", "owner": "1000:1000"}}}}}
|
||||
if err := PlacesOwnerProblem(aRadarr(t), placed); err == nil || !strings.Contains(err.Error(), "ADR 0328") {
|
||||
t.Errorf("a joining module's placed owner: %v", err)
|
||||
}
|
||||
if err := PlacesOwnerProblem(aPlain(), placed); err != nil {
|
||||
t.Errorf("a plain module's placed owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 1: the libraries are the controller's own mesh-wide setting, the terminal's or the operator's Approve alone.
|
||||
func TestTheLibrariesAreTheOperatorsSetting(t *testing.T) {
|
||||
controller := Manifest{Module: LibrariesModule, Version: "1"}
|
||||
keys := TerminalKeys(controller)
|
||||
if !hasName(keys, LibrariesSetting) {
|
||||
t.Fatalf("the libraries are not among the terminal's keys: %v", keys)
|
||||
}
|
||||
if hasName(TerminalKeys(Manifest{Module: "radarr"}), LibrariesSetting) {
|
||||
t.Fatal("another module's libraries key is the terminal's")
|
||||
}
|
||||
if err := ComposesWith(controller, []Layer{{From: "ace", Values: map[string]any{LibrariesSetting: map[string]any{}}}},
|
||||
false); err == nil {
|
||||
t.Fatal("libraries set on one machine were accepted")
|
||||
}
|
||||
if _, err := ParseLibraries(map[string]any{"media": map[string]any{"home": "ace", "root": "/etc/media"}}); err == nil {
|
||||
t.Fatal("a library in the machine's own tree was accepted")
|
||||
}
|
||||
}
|
||||
@@ -536,6 +536,9 @@ type Manifest struct {
|
||||
// `directory` resource, which the mesh creates and owns: an access is mounted and nothing
|
||||
// about it is reconciled, and several modules may name the same one without conflict.
|
||||
Accesses []Access `json:"accesses,omitempty"`
|
||||
// Libraries are the libraries this module uses, each by name, sub-path and use (novox/hq ADR 0328 §2): where a
|
||||
// library is, and who may write it, is the operator's definition and the mesh's, never the module's.
|
||||
Libraries []LibraryUse `json:"libraries,omitempty"`
|
||||
|
||||
// Computed names something in the control plane that works this module's resources out per
|
||||
// node, instead of them being fixed here.
|
||||
@@ -1860,8 +1863,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
// How each container's image is told who to run as, and what a module that says so is held to (novox/hq ADR 0325).
|
||||
problems = append(problems, identityProblems(m)...)
|
||||
// Its libraries and its identity, and what a module that joins is held to (novox/hq ADR 0328).
|
||||
problems = append(problems, libraryProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Every module is given an identity of its own, and a container that declares `identity` runs as it (novox/hq ADR
|
||||
// 0325 §2, §3, §11).
|
||||
//
|
||||
// **The manifest says how its image is told who to run as, and nothing else.** A container resource may declare
|
||||
// `identity`: `{"user": "env:PUID", "group": "env:PGID"}` for an image that reads its user and group from its
|
||||
// environment, or `{"user": "run"}` for the container runtime's own user option. It is the image's interface, as its
|
||||
// ports are. The mesh fills it in: the controller allocates the module one user and group number, mesh-wide, once,
|
||||
// from a range of its own (IdentityLow–IdentityHigh), and the node-engine makes the account `mesh-<module>` on each
|
||||
// machine the module is assigned to and tells the image those numbers.
|
||||
//
|
||||
// **The range is 50000–59999, and a module's user and group numbers are one number.** Chosen above the numbers
|
||||
// distributions give people (1000 up) and below the 60000s Debian keeps for its own daemons, and far from root's 0
|
||||
// and every root group a distribution ships (0, adm's 4, wheel's 10): no number in it is 0 or a root group's, so §11
|
||||
// holds by the range alone, and the store refuses a row outside it. Both numbers come from one sequence, so a
|
||||
// module's uid and gid are equal; a number once given is never given again, even after the module is forgotten
|
||||
// (the store keeps the row, inventory.AllocateIdentity).
|
||||
//
|
||||
// **What the controller sends, and only to an engine that reads it** (Rendering.KeepsFolders: its statement's
|
||||
// contract is link.IdentityContract or later):
|
||||
//
|
||||
// - the declaration's `identities`: [{module, name, uid, gid}] for each module of the machine that declares
|
||||
// `identity` on a container;
|
||||
// - a container's `identity` resolved: {"mode": "env", "user_env", "group_env", "uid", "gid"} or {"mode": "run",
|
||||
// "uid", "gid"};
|
||||
// - the owner of each of the module's directories a container declaring `identity` mounts: the identity's
|
||||
// uid:gid (§3). A directory only a process or the tools write keeps the owner it had: what writes it is not the
|
||||
// identity, and owning it by the identity would lock its writer out;
|
||||
// - an access's `users`: the module's identity where a container of it that declares `identity` mounts the
|
||||
// access, and the tool runner's account where the module's tools run there as named, its number the engine reads by name,
|
||||
// each at the access's use — the node-engine keeps one entry per user on a folder at the widest use any access
|
||||
// to it gives (§4, §5), so the runner's entry is the widest of the modules whose tools use it; and `taken`,
|
||||
// whether the operator let the mesh keep that folder on that machine (§8).
|
||||
//
|
||||
// To an older engine none of it is sent, and a container's `identity` is taken out: it runs as its image says, as
|
||||
// before. A module that declares no `identity` is composed exactly as before, on any engine.
|
||||
|
||||
// The range module identities are allocated from (novox/hq ADR 0325 §3, §11). The store holds the same bounds.
|
||||
const (
|
||||
IdentityLow = 50000
|
||||
IdentityHigh = 59999
|
||||
)
|
||||
|
||||
// AllocatableID says whether n is a number the mesh may give a module's identity: inside the range, so never 0
|
||||
// nor a root group's.
|
||||
func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh }
|
||||
|
||||
// IdentityField is a container resource's field saying how its image is told who to run as.
|
||||
const IdentityField = "identity"
|
||||
|
||||
// The two ways an image is told who to run as.
|
||||
const (
|
||||
IdentityEnv = "env"
|
||||
IdentityRun = "run"
|
||||
)
|
||||
|
||||
// IdentitySpec is a container's `identity` as its manifest declares it.
|
||||
type IdentitySpec struct {
|
||||
// Mode is IdentityEnv or IdentityRun.
|
||||
Mode string
|
||||
// UserEnv and GroupEnv are the environment variables an env image reads, such as PUID and PGID.
|
||||
UserEnv, GroupEnv string
|
||||
}
|
||||
|
||||
// ModuleIdentity is one module's allocated identity: its numbers, the same on every machine.
|
||||
type ModuleIdentity struct {
|
||||
Module string
|
||||
UID int
|
||||
GID int
|
||||
}
|
||||
|
||||
// IdentityName is the account a module's identity is on every machine it is assigned to.
|
||||
func IdentityName(module string) string { return "mesh-" + module }
|
||||
|
||||
// Where an access's user comes from (novox/hq ADR 0325 §4, §5).
|
||||
const (
|
||||
FromContainer = "container"
|
||||
FromToolRunner = "tool-runner"
|
||||
)
|
||||
|
||||
// AccessUser is one identity that uses a shared folder, as an access's `users` names it (mesh-host internal/declaration
|
||||
// AccessUser): the account, its user number, the use, and where it runs. The tool runner's account is named with no
|
||||
// number: it is the machine's, and the node-engine resolves it by name, refusing one missing or root's.
|
||||
type AccessUser struct {
|
||||
Identity string `json:"identity"`
|
||||
UID int `json:"uid,omitempty"`
|
||||
Use string `json:"use"`
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
var envName = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`)
|
||||
|
||||
// ParseIdentity reads a container's `identity`, or refuses it in words.
|
||||
func ParseIdentity(v any) (IdentitySpec, error) {
|
||||
fields, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return IdentitySpec{}, fmt.Errorf("identity is {\"user\": \"env:<NAME>\", \"group\": \"env:<NAME>\"} or " +
|
||||
"{\"user\": \"run\"}")
|
||||
}
|
||||
for k := range fields {
|
||||
if k != "user" && k != "group" {
|
||||
return IdentitySpec{}, fmt.Errorf("identity says %q, and it says only how the image is told its user "+
|
||||
"and group: \"user\" and \"group\", never an owner or a number (%s §1, §2)", k, ADR0325)
|
||||
}
|
||||
}
|
||||
user, _ := fields["user"].(string)
|
||||
group, hasGroup := fields["group"]
|
||||
switch {
|
||||
case user == IdentityRun:
|
||||
if hasGroup {
|
||||
return IdentitySpec{}, fmt.Errorf("identity {\"user\": \"run\"} takes no group: the runtime's user " +
|
||||
"option carries both numbers")
|
||||
}
|
||||
return IdentitySpec{Mode: IdentityRun}, nil
|
||||
case strings.HasPrefix(user, "env:"):
|
||||
g, _ := group.(string)
|
||||
if !strings.HasPrefix(g, "env:") {
|
||||
return IdentitySpec{}, fmt.Errorf("identity names its user as %q and its group as %v; an image told "+
|
||||
"its user by its environment is told its group the same way, \"env:<NAME>\"", user, group)
|
||||
}
|
||||
u, gr := strings.TrimPrefix(user, "env:"), strings.TrimPrefix(g, "env:")
|
||||
if !envName.MatchString(u) || !envName.MatchString(gr) || u == gr {
|
||||
return IdentitySpec{}, fmt.Errorf("identity names the variables %q and %q; each is an environment "+
|
||||
"variable's name, upper-case letters, digits and underscores, and the two differ", u, gr)
|
||||
}
|
||||
return IdentitySpec{Mode: IdentityEnv, UserEnv: u, GroupEnv: gr}, nil
|
||||
}
|
||||
return IdentitySpec{}, fmt.Errorf("identity says its user is %q; it is \"env:<NAME>\" or \"run\"", user)
|
||||
}
|
||||
|
||||
// DeclaresIdentity says a module has a container that declares `identity`: it is given one (§3).
|
||||
func DeclaresIdentity(m Manifest) bool {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "container" {
|
||||
if _, has := r[IdentityField]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containerMounts is what a container of the manifest mounts, read from its volumes as written: the module's own
|
||||
// directories by id, and its accesses by id — or, for a literal source, as "path:<path>".
|
||||
func containerMounts(r map[string]any) (dirs, accesses []string) {
|
||||
volumes, _ := r["volumes"].([]any)
|
||||
for _, v := range volumes {
|
||||
s, _ := v.(string)
|
||||
source := volumeSource(s)
|
||||
if strings.HasPrefix(source, "/") {
|
||||
accesses = append(accesses, "path:"+filepath.Clean(source))
|
||||
continue
|
||||
}
|
||||
for _, m := range dirRef.FindAllStringSubmatch(source, -1) {
|
||||
dirs = append(dirs, m[1])
|
||||
}
|
||||
for _, m := range accessRef.FindAllStringSubmatch(source, -1) {
|
||||
accesses = append(accesses, m[1])
|
||||
}
|
||||
}
|
||||
return dirs, accesses
|
||||
}
|
||||
|
||||
// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so
|
||||
// "${dir:config}:/config" is "${dir:config}".
|
||||
func volumeSource(v string) string {
|
||||
depth := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r == '{' && i > 0 && v[i-1] == '$':
|
||||
depth++
|
||||
case r == '}' && depth > 0:
|
||||
depth--
|
||||
case r == ':' && depth == 0:
|
||||
return v[:i]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// AccessUsersShape is who among a module's containers uses which access as the module's identity, in a form that
|
||||
// compares: each container declaring identity with how it is told, and the accesses it mounts, sorted. Two manifests
|
||||
// with the same shape give their accesses the same container users.
|
||||
func AccessUsersShape(m Manifest) string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
v, declares := r[IdentityField]
|
||||
if fmt.Sprint(r["type"]) != "container" || !declares {
|
||||
continue
|
||||
}
|
||||
_, accesses := containerMounts(r)
|
||||
sort.Strings(accesses)
|
||||
out = append(out, fmt.Sprint(r["id"], v, accesses))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "\x01")
|
||||
}
|
||||
|
||||
// accessMode is the mode an access a container mounts was declared at, by id or by "path:<path>"; "" when the
|
||||
// manifest declares no such access.
|
||||
func accessMode(m Manifest, ref string) string {
|
||||
for _, a := range m.Accesses {
|
||||
if (a.ID != "" && a.ID == ref) || (a.Path != "" && ref == "path:"+filepath.Clean(a.Path)) {
|
||||
return a.At()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ADR0325 is how the module check names the decision its identity rules come from.
|
||||
const ADR0325 = "novox/hq ADR 0325"
|
||||
|
||||
// identityProblems is what the module check refuses of a manifest's identities (novox/hq ADR 0325 §1, §2, §3):
|
||||
//
|
||||
// - an `identity` on anything but a container, or one that is neither env:<NAME> for both nor run;
|
||||
// - **in a module that declares `identity` on a container** — it says it runs as its own identity, so it is held
|
||||
// to the decision whole: a directory of it naming an owner (§1, §3), and a container of it that declares none
|
||||
// and mounts one of the module's directories or a read-write access (§2).
|
||||
//
|
||||
// A module that declares no `identity` is warned of the last two (IdentityWarnings), never refused for them yet:
|
||||
// every registered container module mounts its own directories without one, and refusing them would stop the
|
||||
// catalogues composing before they can migrate. **This is the narrowest rule that breaks nothing registered**: no
|
||||
// registered manifest declares `identity`, so no registered module is refused anything new.
|
||||
func identityProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
v, has := r[IdentityField]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
problems = append(problems, fmt.Sprintf("%s: %v is a %v and says identity; only a container says how its "+
|
||||
"image is told who to run as (%s §2)", m.Module, r["id"], r["type"], ADR0325))
|
||||
continue
|
||||
}
|
||||
if _, err := ParseIdentity(v); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: the container %v: %v (%s §2)", m.Module, r["id"], err, ADR0325))
|
||||
}
|
||||
}
|
||||
if !DeclaresIdentity(m) {
|
||||
return problems
|
||||
}
|
||||
return append(problems, identityRules(m)...)
|
||||
}
|
||||
|
||||
// IdentityWarnings is what the module check warns of a module that declares no `identity` (novox/hq ADR 0325): each
|
||||
// thing the decision refuses once the module migrates, said now so the catalogues can. Refusing comes when no
|
||||
// registered module still does any of it. Empty for a module that declares `identity`: it is refused these instead.
|
||||
func IdentityWarnings(m Manifest) []string {
|
||||
if DeclaresIdentity(m) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, p := range identityRules(m) {
|
||||
out = append(out, p+"; accepted while registered modules still do it, refused once they have moved")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityRules is every place a manifest breaks what ADR 0325 §1–§3 refuse: a directory naming an owner, and a
|
||||
// container declaring no identity that mounts the module's own directory or a read-write access.
|
||||
func identityRules(m Manifest) []string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
switch fmt.Sprint(r["type"]) {
|
||||
case "directory":
|
||||
if owner, said := r["owner"]; said {
|
||||
out = append(out, fmt.Sprintf("%s: the directory %v names its owner %v; a directory a module owns is "+
|
||||
"owned by the module's identity, and no manifest names an owner (%s §1, §3)", m.Module, r["id"],
|
||||
owner, ADR0325))
|
||||
}
|
||||
case "container":
|
||||
if _, has := r[IdentityField]; has {
|
||||
continue
|
||||
}
|
||||
dirs, accesses := containerMounts(r)
|
||||
seen := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the module's "+
|
||||
"own directory %s; only a container that declares identity mounts its module's directories, "+
|
||||
"so what writes them is the module's identity (%s §2)", m.Module, r["id"], d, ADR0325))
|
||||
}
|
||||
}
|
||||
for _, a := range accesses {
|
||||
if accessMode(m, a) == AccessReadWrite && !seen["access:"+a] {
|
||||
seen["access:"+a] = true
|
||||
out = append(out, fmt.Sprintf("%s: the container %v declares no identity and mounts the read-write "+
|
||||
"access %s; a container that runs as its image says is given no entry on a shared folder, so "+
|
||||
"it is refused write (%s §2)", m.Module, r["id"], strings.TrimPrefix(a, "path:"), ADR0325))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PlacesOwnerProblem refuses an owner a `places` setting names for a module that declares `identity` (novox/hq ADR
|
||||
// 0325 §1, §3): its directories are owned by its identity. Nil for a module that declares none, whose placed owner
|
||||
// is still applied, and warned of by PlacesOwnerWarnings.
|
||||
func PlacesOwnerProblem(m Manifest, layers []Layer) error {
|
||||
if !DeclaresIdentity(m) {
|
||||
return nil
|
||||
}
|
||||
if owned := placedOwners(m, layers); len(owned) > 0 {
|
||||
return fmt.Errorf("%s places %s with an owner, and declares identity: its directories are owned by its "+
|
||||
"identity, and no setting names an owner (%s §1, §3)", m.Module, strings.Join(owned, ", "), ADR0325)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlacesOwnerWarnings is a warning for each directory a `places` setting gives an owner, for a module that declares
|
||||
// no `identity` (novox/hq ADR 0325 §1): accepted while registered modules still do it, refused once they have moved.
|
||||
func PlacesOwnerWarnings(m Manifest, layers []Layer) []string {
|
||||
if DeclaresIdentity(m) {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, id := range placedOwners(m, layers) {
|
||||
out = append(out, fmt.Sprintf("warning: %s's setting places.%s.owner names an owner; a directory a module owns "+
|
||||
"is owned by the module's identity, and no setting names an owner (%s §1, §3). Accepted while "+
|
||||
"registered modules still do it, refused once they have moved", m.Module, id, ADR0325))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func placedOwners(m Manifest, layers []Layer) []string {
|
||||
placed, err := Places(m, layers)
|
||||
if err != nil {
|
||||
// empty-on-error: a placement Places refuses is refused by name where it is judged and composed; it names no owner here
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for id, p := range placed {
|
||||
if p.Owner != "" {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// resolvedIdentity is a container's `identity` as the node-engine is told it: the mode, the variables an env image
|
||||
// reads, and the module's numbers.
|
||||
func resolvedIdentity(spec IdentitySpec, id ModuleIdentity) map[string]any {
|
||||
out := map[string]any{"mode": spec.Mode, "uid": id.UID, "gid": id.GID}
|
||||
if spec.Mode == IdentityEnv {
|
||||
out["user_env"], out["group_env"] = spec.UserEnv, spec.GroupEnv
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityFor is the identity module m runs as here, and whether one is composed: a module that declares `identity`,
|
||||
// to an engine that keeps folders, with an allocation the composition was given. A number outside the range is
|
||||
// refused (§11), never sent.
|
||||
func identityFor(m Manifest, with Rendering) (ModuleIdentity, bool, error) {
|
||||
if !with.KeepsFolders || !DeclaresIdentity(m) {
|
||||
return ModuleIdentity{}, false, nil
|
||||
}
|
||||
id, allocated := with.Identities[m.Module]
|
||||
if !allocated {
|
||||
return ModuleIdentity{}, false, nil
|
||||
}
|
||||
if !AllocatableID(id.UID) || !AllocatableID(id.GID) {
|
||||
return ModuleIdentity{}, false, fmt.Errorf("%s's identity is %d:%d, outside %d–%d: no module identity is root "+
|
||||
"or a root group's, and none is sent (%s §11)", m.Module, id.UID, id.GID, IdentityLow, IdentityHigh, ADR0325)
|
||||
}
|
||||
id.Module = m.Module
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// identityInto composes one resource of module m for its identity: a container's `identity` resolved, or taken out
|
||||
// where none is composed; and the owner of a directory one of its identity's containers mounts.
|
||||
func identityInto(resource map[string]any, m Manifest, id ModuleIdentity, has bool, mountedByIdentity map[string]bool) error {
|
||||
switch fmt.Sprint(resource["type"]) {
|
||||
case "container":
|
||||
v, declared := resource[IdentityField]
|
||||
if !declared {
|
||||
return nil
|
||||
}
|
||||
if !has {
|
||||
delete(resource, IdentityField)
|
||||
return nil
|
||||
}
|
||||
spec, err := ParseIdentity(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: the container %v: %w", m.Module, resource["id"], err)
|
||||
}
|
||||
resource[IdentityField] = resolvedIdentity(spec, id)
|
||||
case "directory":
|
||||
if has && mountedByIdentity[fmt.Sprint(resource["id"])] {
|
||||
resource["owner"] = fmt.Sprintf("%d:%d", id.UID, id.GID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// identityHeld says module m's containers are not switched to its identity yet (the operator's question on ADR 0325):
|
||||
// a container declaring `identity` mounts a read-write access whose folder is neither taken (KeptFolders) nor kept by
|
||||
// the engine (ReadyFolders). Switched before the take adds the identity's entry, the app would run as the new uid and
|
||||
// lose write on the operator's existing files; held, it runs as it did (its environment's PUID and PGID), the take's
|
||||
// users already name the identity, and the switch comes in the apply after every such folder is taken.
|
||||
func identityHeld(m Manifest, with Rendering) bool {
|
||||
_, byID, err := accessesFor(m, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return true // refused where the module itself is composed; never switched on a guess
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, declares := r[IdentityField]; !declares || fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
_, refs := containerMounts(r)
|
||||
for _, ref := range refs {
|
||||
if accessMode(m, ref) != AccessReadWrite {
|
||||
continue
|
||||
}
|
||||
path := byID[ref]
|
||||
if strings.HasPrefix(ref, "path:") {
|
||||
path = strings.TrimPrefix(ref, "path:")
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path != "." && !with.KeptFolders[path] && !with.ReadyFolders[path] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// identityContainers is the composed ids of module m's containers that declare `identity`, whether or not it is sent.
|
||||
func identityContainers(m Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if _, declares := r[IdentityField]; declares && fmt.Sprint(r["type"]) == "container" {
|
||||
out[m.Module+"."+fmt.Sprint(r["id"])] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dirsMountedByIdentity is the ids of module m's directories one of its containers declaring `identity` mounts.
|
||||
func dirsMountedByIdentity(m Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
if _, has := r[IdentityField]; !has {
|
||||
continue
|
||||
}
|
||||
dirs, _ := containerMounts(r)
|
||||
for _, d := range dirs {
|
||||
out[d] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identitiesOf is the declaration's `identities`, by module.
|
||||
func identitiesOf(ids []ModuleIdentity) []map[string]any {
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i].Module < ids[j].Module })
|
||||
out := make([]map[string]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"module": id.Module, "name": IdentityName(id.Module), "uid": id.UID,
|
||||
"gid": id.GID})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no
|
||||
// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// usersInto writes `users` and `taken` on every access of module m among the resources composed so far: its identity
|
||||
// where a container of it that declares `identity` mounts the access's path, and the tool runner's account where the
|
||||
// module's tools run as it, each at the access's use (§4: the engine keeps the widest).
|
||||
func usersInto(out []map[string]any, owner map[string]string, m Manifest, id ModuleIdentity, has bool, account string,
|
||||
with Rendering) {
|
||||
// The containers that declare identity, whether or not it is sent yet: the take adds the entries of the identity
|
||||
// to come, so they exist before the switch.
|
||||
declaring := identityContainers(m)
|
||||
var containers []map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == m.Module && declaring[fmt.Sprint(res["id"])] && has {
|
||||
containers = append(containers, res)
|
||||
}
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != m.Module {
|
||||
continue
|
||||
}
|
||||
path, _ := res["path"].(string)
|
||||
mode, _ := res["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = AccessRead
|
||||
}
|
||||
users := []AccessUser{}
|
||||
for _, c := range containers {
|
||||
if mounts(c, path) {
|
||||
users = append(users, AccessUser{Identity: IdentityName(m.Module), UID: id.UID, Use: mode, From: FromContainer})
|
||||
break // one identity per module, whichever of its containers mounts it
|
||||
}
|
||||
}
|
||||
if account != "" {
|
||||
users = append(users, AccessUser{Identity: account, Use: mode, From: FromToolRunner})
|
||||
}
|
||||
if len(users) > 0 {
|
||||
res["users"] = users
|
||||
}
|
||||
res["taken"] = with.KeptFolders[filepath.Clean(path)]
|
||||
}
|
||||
}
|
||||
|
||||
// mounts says whether a composed container mounts path, or something below it, from the machine. A path that merely
|
||||
// begins with the same letters — /storage/media2 beside /storage/media — is another path.
|
||||
func mounts(container map[string]any, path string) bool {
|
||||
volumes, _ := container["volumes"].([]any)
|
||||
path = filepath.Clean(path)
|
||||
for _, v := range volumes {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source, _, _ := strings.Cut(s, ":")
|
||||
if !strings.HasPrefix(source, "/") {
|
||||
continue // a named volume: the runtime's, not the operator's path
|
||||
}
|
||||
source = filepath.Clean(source)
|
||||
if source == path || strings.HasPrefix(source, path+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the
|
||||
// definition's default; false where neither says, or the module declares no such access.
|
||||
func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) {
|
||||
_, byID, err := accessesFor(m, layers)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
path, has := byID[id]
|
||||
if !has || path == "" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(path), true
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0325 on the controller's side, row by row of its "How it is checked" table: the module check
|
||||
// (rows 1, 2), the identity composed (row 3), the tool runner's entry (row 4), no identity root (row 11) — and none
|
||||
// of it sent to a node-engine older than the contract, which would refuse the whole declaration.
|
||||
|
||||
const identityImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
// aLibrarian files films: a container told its identity by PUID and PGID that mounts its config and the movies it
|
||||
// writes; a sidecar that declares none and reads the music; a directory only its tools write; and tools served by the
|
||||
// machine's runtime.
|
||||
func aLibrarian(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "librarian", "tools/index.js")
|
||||
m.Accesses = []Access{
|
||||
{ID: "movies", Path: "/storage/media/movies", Mode: AccessReadWrite},
|
||||
{ID: "music", Path: "/storage/media/music", Mode: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "notes", "type": "directory", "mode": "0700"},
|
||||
{"id": "app", "type": "container", "name": "librarian", "image": identityImage,
|
||||
"identity": map[string]any{"user": "env:PUID", "group": "env:PGID"},
|
||||
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}},
|
||||
{"id": "sidecar", "type": "container", "name": "librarian-sidecar", "image": identityImage,
|
||||
"volumes": []any{"${access:music}:/music:ro"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// aPlayer reads the movies the librarian writes, as its own identity through the runtime's user option, and has
|
||||
// tools of its own that read them too.
|
||||
func aPlayer(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "player", "tools/index.js")
|
||||
m.Accesses = []Access{{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead}}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "app", "type": "container", "name": "player", "image": identityImage,
|
||||
"identity": map[string]any{"user": "run"}, "volumes": []any{"${access:movies}:/movies:ro"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func keepingFolders(ids map[string]ModuleIdentity, kept ...string) Rendering {
|
||||
with := Rendering{KeepsFolders: true, Identities: ids, KeptFolders: map[string]bool{}}
|
||||
for _, k := range kept {
|
||||
with.KeptFolders[k] = true
|
||||
}
|
||||
return with
|
||||
}
|
||||
|
||||
func composeFolders(t *testing.T, r Resolution, with Rendering) Composed {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return composed
|
||||
}
|
||||
|
||||
var identities = map[string]ModuleIdentity{"librarian": {UID: 50001, GID: 50001}, "player": {UID: 50002, GID: 50002}}
|
||||
|
||||
// Row 3: the identity composed — the same on every machine, the container told it, the module's directories its
|
||||
// containers mount owned by it, and the declaration's identities naming the account.
|
||||
func TestAModuleRunsAsItsIdentityTheSameOnEveryMachine(t *testing.T) {
|
||||
var seen []string
|
||||
for _, node := range []string{"ace", "novox"} {
|
||||
r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
composed := composeFolders(t, r, keepingFolders(identities, "/storage/media/movies")) // its read-write folder taken: switched
|
||||
got := byID(composed.Resources)
|
||||
|
||||
app := got["librarian.app"]
|
||||
ident, _ := app[IdentityField].(map[string]any)
|
||||
want := map[string]any{"mode": "env", "user_env": "PUID", "group_env": "PGID", "uid": 50001, "gid": 50001}
|
||||
if fmt.Sprint(ident) != fmt.Sprint(want) {
|
||||
t.Errorf("%s: the container is told %v; want %v", node, ident, want)
|
||||
}
|
||||
player, _ := got["player.app"][IdentityField].(map[string]any)
|
||||
if fmt.Sprint(player) != fmt.Sprint(map[string]any{"mode": "run", "uid": 50002, "gid": 50002}) {
|
||||
t.Errorf("%s: the player is told %v", node, player)
|
||||
}
|
||||
if _, has := got["librarian.sidecar"][IdentityField]; has {
|
||||
t.Errorf("%s: a container declaring no identity was given one", node)
|
||||
}
|
||||
if owner := got["librarian.config"]["owner"]; owner != "50001:50001" {
|
||||
t.Errorf("%s: the directory its identity's container mounts is owned by %v", node, owner)
|
||||
}
|
||||
if owner, has := got["librarian.notes"]["owner"]; has {
|
||||
t.Errorf("%s: a directory no identity container mounts was given the owner %v: what writes it is not "+
|
||||
"the identity", node, owner)
|
||||
}
|
||||
raw, _ := json.Marshal(composed.Identities)
|
||||
seen = append(seen, string(raw))
|
||||
if !strings.Contains(string(raw), `"name":"mesh-librarian"`) || !strings.Contains(string(raw), `"uid":50001`) {
|
||||
t.Errorf("%s: the declaration's identities are %s", node, raw)
|
||||
}
|
||||
}
|
||||
// What the node-engine this controller pins (the ADR 0325 half) makes of it: nothing refused.
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
with := keepingFolders(identities, "/storage/media/movies")
|
||||
composed := composeFolders(t, r, with)
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources,
|
||||
"identities": composed.Identities})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("the pinned node-engine refuses it: %v", problems)
|
||||
}
|
||||
if seen[0] != seen[1] {
|
||||
t.Fatalf("the identities differ between machines: %s and %s", seen[0], seen[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 2, 4 and 8's composed part: each access names its users — the module's identity where a container declaring
|
||||
// identity mounts it, never a container declaring none — and the tool runner's account by name, with no number,
|
||||
// each at the access's own use (the node-engine keeps one entry per user at the widest use any access gives it, so
|
||||
// the runner's is the widest among the modules whose tools use the folder); and whether the folder is taken.
|
||||
func TestAnAccessNamesItsUsers(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
with := keepingFolders(identities, "/storage/media/movies")
|
||||
got := byID(composeFolders(t, r, with).Resources)
|
||||
|
||||
users := func(id string) string {
|
||||
raw, _ := json.Marshal(got[id]["users"])
|
||||
return string(raw)
|
||||
}
|
||||
movies := "librarian." + AccessID("/storage/media/movies")
|
||||
if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"},` +
|
||||
`{"identity":"jochen","use":"read-write","from":"tool-runner"}]`; users(movies) != want {
|
||||
t.Errorf("the librarian's movies name %s; want %s", users(movies), want)
|
||||
}
|
||||
played := "player." + AccessID("/storage/media/movies")
|
||||
if want := `[{"identity":"mesh-player","uid":50002,"use":"read","from":"container"},` +
|
||||
`{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(played) != want {
|
||||
t.Errorf("the player's movies name %s; want %s", users(played), want)
|
||||
}
|
||||
// The sidecar declares no identity: no entry for it (§2); the runner reads the music for the librarian's tools.
|
||||
music := "librarian." + AccessID("/storage/media/music")
|
||||
if want := `[{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(music) != want {
|
||||
t.Errorf("the music names %s; want only the tool runner's read", users(music))
|
||||
}
|
||||
if got[movies]["taken"] != true || got[music]["taken"] != false {
|
||||
t.Errorf("taken is %v for the movies (taken) and %v for the music (not)", got[movies]["taken"], got[music]["taken"])
|
||||
}
|
||||
|
||||
// The runner is named with no number: the engine resolves it by name, never as uid 0 sent from here.
|
||||
if strings.Contains(users(music), `"uid"`) {
|
||||
t.Errorf("the tool runner's entry carries a number: %s", users(music))
|
||||
}
|
||||
}
|
||||
|
||||
// The contract: to an engine that does not state link.IdentityContract nothing of ADR 0325 is sent — no identities, no
|
||||
// container identity, no users, no taken, no owner from an identity — and what is sent is what the pinned node-engine
|
||||
// accepts, as before.
|
||||
func TestNothingOfTheIdentityIsSentToAnOlderEngine(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}}
|
||||
composed := composeFolders(t, r, Rendering{Identities: identities})
|
||||
if len(composed.Identities) > 0 {
|
||||
t.Fatalf("identities were composed for an older engine: %v", composed.Identities)
|
||||
}
|
||||
for _, res := range composed.Resources {
|
||||
for _, field := range []string{IdentityField, "users", "taken"} {
|
||||
if _, has := res[field]; has {
|
||||
t.Errorf("%v carries %s for an older engine", res["id"], field)
|
||||
}
|
||||
}
|
||||
if res["id"] == "librarian.config" && res["owner"] != nil {
|
||||
t.Errorf("the directory was given the owner %v for an older engine", res["owner"])
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("the pinned node-engine refuses it: %v", problems)
|
||||
}
|
||||
// An engine that keeps folders, with no allocation yet (a question asked before the first send): composed
|
||||
// without an identity, never with a made-up one.
|
||||
none := composeFolders(t, r, keepingFolders(nil))
|
||||
if len(none.Identities) > 0 || byID(none.Resources)["librarian.app"][IdentityField] != nil {
|
||||
t.Fatal("an identity was composed with no allocation")
|
||||
}
|
||||
}
|
||||
|
||||
// Row 11: no identity outside the range is ever sent — 0 above all — and the composition says why.
|
||||
func TestNoModuleIdentityIsRoot(t *testing.T) {
|
||||
for _, n := range []int{0, 4, 10, 1000, IdentityLow - 1, IdentityHigh + 1} {
|
||||
if AllocatableID(n) {
|
||||
t.Errorf("%d is allocatable", n)
|
||||
}
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}}
|
||||
with := keepingFolders(map[string]ModuleIdentity{"librarian": {UID: n, GID: n}})
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§11") {
|
||||
t.Errorf("an identity of %d composed: %v", n, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []int{IdentityLow, IdentityHigh} {
|
||||
if !AllocatableID(n) {
|
||||
t.Errorf("%d is not allocatable", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func manifestJSON(t *testing.T, m map[string]any) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func aContainerModule(name string, resources ...map[string]any) map[string]any {
|
||||
return map[string]any{"module": name, "version": "1", "capabilities": []any{"container-runtime"},
|
||||
"accesses": []any{map[string]any{"id": "movies", "path": "/storage/media/movies", "mode": "read-write"}},
|
||||
"resources": resources}
|
||||
}
|
||||
|
||||
// Rows 1 and 2: the module check accepts identity as env:<NAME> or run and refuses every other shape and an owner in
|
||||
// it; a module that declares identity is refused a directory naming its owner and a container declaring none that
|
||||
// mounts its own directory or a read-write access; a directory's mode is accepted.
|
||||
func TestTheModuleCheckHoldsAnIdentityModuleToTheDecision(t *testing.T) {
|
||||
ok := func(identity any) map[string]any {
|
||||
return map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage,
|
||||
"identity": identity, "volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
|
||||
}
|
||||
config := map[string]any{"id": "config", "type": "directory", "mode": "0750"}
|
||||
for _, identity := range []any{
|
||||
map[string]any{"user": "env:PUID", "group": "env:PGID"},
|
||||
map[string]any{"user": "run"},
|
||||
} {
|
||||
if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(identity)))); err != nil {
|
||||
t.Errorf("identity %v refused: %v", identity, err)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
identity any
|
||||
want string
|
||||
}{
|
||||
{map[string]any{"user": "1000", "group": "1000"}, `"env:<NAME>" or "run"`},
|
||||
{map[string]any{"user": "env:PUID"}, "told its group the same way"},
|
||||
{map[string]any{"user": "run", "group": "env:PGID"}, "takes no group"},
|
||||
{map[string]any{"user": "env:puid", "group": "env:PGID"}, "environment variable's name"},
|
||||
{map[string]any{"user": "env:PUID", "group": "env:PGID", "owner": "1000:1000"}, "never an owner or a number"},
|
||||
{"run", "identity is"},
|
||||
} {
|
||||
_, err := ParseManifest(manifestJSON(t, aContainerModule("app", config, ok(c.identity))))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "ADR 0325") {
|
||||
t.Errorf("identity %v: %v, want it refused saying %q", c.identity, err, c.want)
|
||||
}
|
||||
}
|
||||
// An identity on anything but a container.
|
||||
onAFile := map[string]any{"id": "f", "type": "directory", "mode": "0750", "identity": map[string]any{"user": "run"}}
|
||||
if _, err := ParseManifest(manifestJSON(t, aContainerModule("app", onAFile))); err == nil {
|
||||
t.Error("an identity on a directory was accepted")
|
||||
}
|
||||
|
||||
// Held to the decision whole once it declares identity (§1, §2, §3).
|
||||
owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"}
|
||||
bare := map[string]any{"id": "helper", "type": "container", "name": "helper", "image": identityImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
|
||||
_, err := ParseManifest(manifestJSON(t, aContainerModule("app", owned, ok(map[string]any{"user": "run"}), bare)))
|
||||
for _, want := range []string{"names its owner 1000:1000", "declares no identity and mounts the module's own directory config",
|
||||
"declares no identity and mounts the read-write access movies"} {
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("an identity module: %v, want it refused saying %q", err, want)
|
||||
}
|
||||
}
|
||||
// A setting's owner too.
|
||||
settingOwned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "${setting:puid}:${setting:pgid}"}
|
||||
identityModule := aContainerModule("app", settingOwned, ok(map[string]any{"user": "run"}))
|
||||
identityModule["settings"] = map[string]any{"puid": map[string]any{"default": "1000"}, "pgid": map[string]any{"default": "1000"}}
|
||||
if _, err := ParseManifest(manifestJSON(t, identityModule)); err == nil || !strings.Contains(err.Error(), "names its owner") {
|
||||
t.Errorf("a setting's owner in an identity module: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 1 and 2 for what is registered today: a module that declares no identity is warned, never refused — a
|
||||
// directory naming its owner, a container mounting its own directory or a read-write access — and each warning
|
||||
// names ADR 0325. A placement's owner is refused for an identity module and warned of for any other.
|
||||
func TestAModuleWithoutIdentityIsWarnedNotRefused(t *testing.T) {
|
||||
owned := map[string]any{"id": "config", "type": "directory", "mode": "0750", "owner": "1000:1000"}
|
||||
bare := map[string]any{"id": "app", "type": "container", "name": "app", "image": identityImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${access:movies}:/movies"}}
|
||||
m, err := ParseManifest(manifestJSON(t, aContainerModule("legacy", owned, bare)))
|
||||
if err != nil {
|
||||
t.Fatalf("a registered shape was refused: %v", err)
|
||||
}
|
||||
warnings := IdentityWarnings(m)
|
||||
sort.Strings(warnings)
|
||||
if len(warnings) != 3 {
|
||||
t.Fatalf("warned %d times: %q", len(warnings), warnings)
|
||||
}
|
||||
for _, w := range warnings {
|
||||
if !strings.Contains(w, "ADR 0325") || !strings.Contains(w, "refused once they have moved") {
|
||||
t.Errorf("a warning that does not name ADR 0325 or say refusal comes later: %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/srv/legacy", "owner": "1000:1000"}}}}}
|
||||
if err := PlacesOwnerProblem(m, placed); err != nil {
|
||||
t.Errorf("a placement's owner refused for a module without identity: %v", err)
|
||||
}
|
||||
if w := PlacesOwnerWarnings(m, placed); len(w) != 1 || !strings.Contains(w[0], "places.config.owner") {
|
||||
t.Errorf("a placement's owner warned %q", w)
|
||||
}
|
||||
withIdentity := aLibrarian(t)
|
||||
placedLibrarian := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/srv/librarian", "owner": "1000:1000"}}}}}
|
||||
if err := PlacesOwnerProblem(withIdentity, placedLibrarian); err == nil || !strings.Contains(err.Error(), "ADR 0325") {
|
||||
t.Errorf("a placement's owner for an identity module: %v", err)
|
||||
}
|
||||
if len(IdentityWarnings(withIdentity)) != 0 {
|
||||
t.Error("an identity module was warned rather than refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A container is not switched to its identity while a read-write folder it mounts holds data the mesh has not taken
|
||||
// (the operator's question on ADR 0325): the app would run as the new uid before the take adds its entry, and lose
|
||||
// write on the existing files. Its identity is held — no `identity` sent, its directories keep their owner — while
|
||||
// the take's users already name the identity, so the entries exist before the switch; once every such folder is
|
||||
// taken (or kept by the engine), the identity is sent in one apply.
|
||||
func TestAContainerKeepsItsOldIdentityUntilItsDataIsTaken(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), theRuntime(t)}}
|
||||
held := byID(composeFolders(t, r, keepingFolders(identities)).Resources)
|
||||
if _, has := held["librarian.app"][IdentityField]; has {
|
||||
t.Fatalf("the container was told its identity while its movies are not taken: %v", held["librarian.app"][IdentityField])
|
||||
}
|
||||
if owner, has := held["librarian.config"]["owner"]; has {
|
||||
t.Fatalf("its directory was given the owner %v while its identity is held", owner)
|
||||
}
|
||||
users, _ := json.Marshal(held["librarian."+AccessID("/storage/media/movies")]["users"])
|
||||
if !strings.Contains(string(users), `"identity":"mesh-librarian","uid":50001`) {
|
||||
t.Fatalf("the access does not name the identity to come, so the take would not add its entry: %s", users)
|
||||
}
|
||||
|
||||
for name, with := range map[string]Rendering{
|
||||
"taken": keepingFolders(identities, "/storage/media/movies"),
|
||||
"kept by the engine": func() Rendering {
|
||||
w := keepingFolders(identities)
|
||||
w.ReadyFolders = map[string]bool{"/storage/media/movies": true}
|
||||
return w
|
||||
}(),
|
||||
} {
|
||||
got := byID(composeFolders(t, r, with).Resources)
|
||||
if _, has := got["librarian.app"][IdentityField]; !has {
|
||||
t.Errorf("%s: the container is not told its identity", name)
|
||||
}
|
||||
if got["librarian.config"]["owner"] != "50001:50001" {
|
||||
t.Errorf("%s: its directory is owned by %v", name, got["librarian.config"]["owner"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,10 @@ func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
|
||||
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A joining module's identity and its libraries' host paths: its tools run on the machine (ADR 0328 §5).
|
||||
if value, err = bundleLibraryFill(value, m, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]string{}
|
||||
}
|
||||
|
||||
@@ -337,6 +337,10 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
}
|
||||
|
||||
lands := settingKeysUsedBy(m)
|
||||
// The libraries are the controller's own mesh-wide setting (novox/hq ADR 0328 §1).
|
||||
if m.Module == LibrariesModule {
|
||||
lands[LibrariesSetting] = true
|
||||
}
|
||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
||||
for key := range m.Settings {
|
||||
@@ -521,6 +525,22 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
||||
// declared (LeftOutBecause), and where a setting judged against a newer build of the module is held to still compose
|
||||
// with the one registered.
|
||||
func ComposesWith(m Manifest, layers []Layer, adopted bool) error {
|
||||
// The libraries, on the controller's own layer (novox/hq ADR 0328 §1): each well formed, and only mesh-wide.
|
||||
if m.Module == LibrariesModule {
|
||||
for _, l := range layers {
|
||||
v, has := l.Values[LibrariesSetting]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if l.From != MeshWideLayer && !l.Default {
|
||||
return fmt.Errorf("%s is the mesh's, set on the whole mesh and never on one machine (%s §1)",
|
||||
LibrariesSetting, ADR0328)
|
||||
}
|
||||
if _, err := ParseLibraries(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := settingsHoldOneLine(m.Module, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ func TerminalKeys(m Manifest) []string {
|
||||
}
|
||||
delete(keys, PlacesSetting)
|
||||
delete(keys, AccessesSetting)
|
||||
// The libraries say where shared data is and who may write it (novox/hq ADR 0328 §1): the operator's alone.
|
||||
if m.Module == LibrariesModule {
|
||||
keys[LibrariesSetting] = true
|
||||
}
|
||||
rest := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
rest = append(rest, k)
|
||||
|
||||
@@ -59,6 +59,12 @@ var fillers = []filler{
|
||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
|
||||
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||||
{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: "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)"},
|
||||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
||||
why: "a secret is never filled into an environment variable or any other field: put it in a file the " +
|
||||
"module declares and mount that (novox/hq ADR 0086)"},
|
||||
|
||||
@@ -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 ''
|
||||
);
|
||||
+16
-21
@@ -445,6 +445,16 @@ const RootContract = 3
|
||||
// superseded), is what engines stated before; the controller sends no grant to any engine (#270).
|
||||
const IdentityContract = 5
|
||||
|
||||
// LibraryContract is the statement of an engine that makes the mesh's users and library groups and keeps libraries
|
||||
// (novox/hq ADR 0328, which supersedes ADR 0325): it reads a declaration's `groups`, `identities` and `libraries`,
|
||||
// states a library it may not keep yet as waiting, and hears the take of a library. Only to such an engine is any of
|
||||
// it sent; five, ADR 0325's, is sent nothing new.
|
||||
const LibraryContract = 6
|
||||
|
||||
// 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"
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
@@ -605,19 +615,15 @@ type AccessWait struct {
|
||||
// word: a warrant given for another filesystem (the folder moved) does not answer it, and the controller asks
|
||||
// again (mesh-host internal/link AccessWait.Placed).
|
||||
Placed string `json:"placed,omitempty"`
|
||||
// Reason is what the wait is for: WaitKeep (the operator's word to keep the folder) or WaitDisk (its disk
|
||||
// mounted). ACL is said on a keep wait whose filesystem refuses access entries now: ACLNeedsEnabling when the take
|
||||
// act will let it, ACLUnsupported when no act can. Absent when it allows them.
|
||||
// Reason is what the wait is for: WaitKeep (the operator's word to take the library) or WaitDisk (its disk
|
||||
// mounted).
|
||||
Reason string `json:"reason"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
}
|
||||
|
||||
// The reasons of an access wait, and what its filesystem says of ACLs (mesh-host internal/link).
|
||||
// The reasons of an access wait (mesh-host internal/link).
|
||||
const (
|
||||
WaitKeep = "keep"
|
||||
WaitDisk = "disk"
|
||||
ACLNeedsEnabling = "needs-enabling"
|
||||
ACLUnsupported = "unsupported"
|
||||
WaitKeep = "keep"
|
||||
WaitDisk = "disk"
|
||||
)
|
||||
|
||||
// KindAccess is an access's verdict (novox/hq ADR 0325; mesh-host internal/link KindAccess): healthy while each user
|
||||
@@ -630,16 +636,6 @@ const KindAccess = "access"
|
||||
// last named it (`<access id>~entries`), which may no longer be declared. The node-engine's own.
|
||||
const KindKeptEntries = "kept-entries"
|
||||
|
||||
// KeptEntry is one user of a kept folder as the engine's listing says it (ADR 0325 §4), with a note beside the tool
|
||||
// runner's entry.
|
||||
type KeptEntry struct {
|
||||
Identity string `json:"identity"`
|
||||
UID int `json:"uid"`
|
||||
Use string `json:"use"`
|
||||
From string `json:"from"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceHealth is one long-running resource's state.
|
||||
type ResourceHealth struct {
|
||||
Module string `json:"module"`
|
||||
@@ -667,10 +663,9 @@ type ResourceHealth struct {
|
||||
// engine older than that.
|
||||
Waits []Wait `json:"waits,omitempty"`
|
||||
// AccessWaits is what an access stated waiting waits for (novox/hq ADR 0325 §7); Act the last walk under its
|
||||
// folder, kept as the engine said it; Entries the kept folder's listing. From an engine at IdentityContract only.
|
||||
// folder, kept as the engine said it. From an engine at IdentityContract or later.
|
||||
AccessWaits []AccessWait `json:"access-waits,omitempty"`
|
||||
Act json.RawMessage `json:"act,omitempty"`
|
||||
Entries []KeptEntry `json:"entries,omitempty"`
|
||||
}
|
||||
|
||||
// HealthSaid is the health event's body: the machine and its statement. The machine is read from the
|
||||
|
||||
Reference in New Issue
Block a user