Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344af664b2 | ||
|
|
e1c2b74442 | ||
|
|
6500139a2c | ||
|
|
1c117d1189 | ||
|
|
09c6f92b4a | ||
|
|
2b15cb57f0 | ||
|
|
63602264ef |
@@ -16,7 +16,8 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' -o /mesh-builder ./cmd/mes
|
||||
FROM ${ALPINE_BASE}
|
||||
# git to clone what it is asked to build, and the docker client to build and push it. The daemon
|
||||
# is the machine's, reached through its socket — a build machine shares the runtime it was given
|
||||
# rather than running one inside itself.
|
||||
RUN apk add --no-cache git docker-cli
|
||||
# rather than running one inside itself. xz to read an upstream release an archive is pinned to
|
||||
# (novox/hq ADR 0316): Go's standard library reads gzip and bzip2 and not xz.
|
||||
RUN apk add --no-cache git docker-cli xz
|
||||
COPY --from=build /mesh-builder /usr/local/bin/mesh-builder
|
||||
ENTRYPOINT ["/usr/local/bin/mesh-builder"]
|
||||
|
||||
@@ -117,9 +117,9 @@ type asked struct {
|
||||
// Proposal is a settings layer proposed through a verb (proposals.go, novox/hq ADR 0277): about no
|
||||
// condition, set on Approve by the serving controller itself, and left alone by the reconciling of conditions.
|
||||
Proposal *settingsProposal `json:"proposal,omitempty"`
|
||||
// Take is a library the operator is asked to let the mesh take (kept_libraries.go, novox/hq ADR 0328 §7): about no
|
||||
// condition, taken on Approve by the serving controller itself, asked once per library.
|
||||
Take *libraryTake `json:"take,omitempty"`
|
||||
// Take is a shared folder the operator is asked to let the mesh keep (kept_folders.go, novox/hq ADR 0325 §8):
|
||||
// about no condition, taken on Approve by the serving controller itself, asked once per folder and machine.
|
||||
Take *folderTake `json:"take,omitempty"`
|
||||
}
|
||||
|
||||
// ofACondition says an ask is one of a condition's: not a rehearsal, not a proposal.
|
||||
@@ -195,9 +195,9 @@ type asker struct {
|
||||
call func(ctx context.Context, a conditions.Action, args map[string]string) error
|
||||
// setLayer sets a proposed settings layer on the operator's warrant (novox/hq ADR 0277); nil cannot.
|
||||
setLayer setOnWarrant
|
||||
// folders is every library waiting to be taken (novox/hq ADR 0328 §7), and take what takes one on the operator's
|
||||
// warrant; nil asks about none. machines are the mesh's machine names, allowed in an ask's words.
|
||||
folders func(ctx context.Context) ([]libraryTake, error)
|
||||
// folders is every shared folder waiting to be kept (novox/hq ADR 0325 §8), and take what takes one on the
|
||||
// operator's warrant; nil asks about none. machines are the mesh's machine names, allowed in an ask's words.
|
||||
folders func(ctx context.Context) ([]folderTake, error)
|
||||
take takeOnWarrant
|
||||
machines func(ctx context.Context) ([]string, error)
|
||||
// record writes the hand-act log.
|
||||
@@ -562,9 +562,9 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// And every library waiting to be taken, once per library (novox/hq ADR 0328 §7).
|
||||
// And every shared folder waiting to be kept, once per folder and machine (novox/hq ADR 0325 §8).
|
||||
if err := a.askFolders(ctx); err != nil {
|
||||
a.logf("the operator could not be asked about the libraries waiting to be taken: %v", err)
|
||||
a.logf("the operator could not be asked about the folders waiting to be kept: %v", err)
|
||||
}
|
||||
why := "the router refused the ask"
|
||||
if len(refusedWords) > 0 {
|
||||
@@ -889,7 +889,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
outcome = "done: " + outcome
|
||||
}
|
||||
case r.Take != nil && act.Verb == takeVerb:
|
||||
// A library taken on the operator's warrant (novox/hq ADR 0328 §7): the act is composed again from the
|
||||
// A shared folder taken on the operator's warrant (novox/hq ADR 0325 §8): the act is composed again from the
|
||||
// record and held to what the option bound before the engine is asked.
|
||||
outcome, acted = a.decideTake(ctx, *r, act, w)
|
||||
if acted == nil && !strings.HasPrefix(outcome, "nothing") {
|
||||
|
||||
@@ -1109,16 +1109,6 @@ const resolverSeat = "mesh-dns-resolver"
|
||||
// classOf is a walk's class (novox/hq ADR 0282 decision 1): core when it walks a module on the controller's own
|
||||
// path or one holding the mesh's resolver, leaf otherwise.
|
||||
func classOf(walk inventory.Plan, entries []inventory.Entry) string {
|
||||
modules := make([]string, 0, len(walk.Modules))
|
||||
for m := range walk.Modules {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
return classOfModules(modules, entries)
|
||||
}
|
||||
|
||||
// classOfModules is the class of modules moved together: core when one is on the controller's own path or holds
|
||||
// the mesh's resolver, leaf otherwise (ADR 0282 decision 1).
|
||||
func classOfModules(modules []string, entries []inventory.Entry) string {
|
||||
resolvers := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
for _, c := range e.Manifest.Claims {
|
||||
@@ -1127,7 +1117,7 @@ func classOfModules(modules []string, entries []inventory.Entry) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range modules {
|
||||
for m := range walk.Modules {
|
||||
if _, own := onTheControllersPath[m]; own || resolvers[m] {
|
||||
return inventory.ClassCore
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -106,32 +105,9 @@ func changePlanOf(repository, base, head string, r mergeReach, entries []invento
|
||||
p.Machines = append(p.Machines, *machines[name])
|
||||
}
|
||||
p.Summary = summaryOf(p)
|
||||
p.Class = changeClassOf(p, entries)
|
||||
return p
|
||||
}
|
||||
|
||||
// classNone is the class of a change that moves no module: nothing for a walk to move (novox/hq ADR 0313 §2).
|
||||
const classNone = "none"
|
||||
|
||||
// changeClassOf is a change plan's class (novox/hq ADR 0313 §2): that of the slowest-budgeted module it moves,
|
||||
// itself or as a dependent — core when one is on the controller's own path or holds the mesh's resolver, leaf
|
||||
// otherwise — and none when it moves nothing. A module it adds a directory for counts by its own class: the
|
||||
// catalogue names a module's directory by the module, so the directory's last element is its name. The graph holds
|
||||
// no manifest of it yet, so a new module is core by its name alone (on the controller's own path), never by a
|
||||
// resolver claim its manifest makes.
|
||||
func changeClassOf(p link.ChangePlan, entries []inventory.Entry) string {
|
||||
if len(p.Moved) == 0 && len(p.Dependents) == 0 && len(p.New) == 0 {
|
||||
return classNone
|
||||
}
|
||||
modules := append(append([]string{}, p.Moved...), p.Dependents...)
|
||||
for _, dir := range p.New {
|
||||
if name := path.Base(strings.Trim(dir, "/")); name != "." && name != "/" && name != "" {
|
||||
modules = append(modules, name)
|
||||
}
|
||||
}
|
||||
return classOfModules(modules, entries)
|
||||
}
|
||||
|
||||
// grantsStepWord names the grants step (novox/hq issue 490): the bus's user list sent alone to the machine
|
||||
// holding the bus ahead of a walk's gate. Not the bus step, which replaces the bus itself.
|
||||
const grantsStepWord = "grants step"
|
||||
|
||||
@@ -152,10 +152,12 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// **A module that uses several libraries is warned** (novox/hq ADR 0328 §4): an image that drops added groups when
|
||||
// it changes user reaches only the first. Printed, never counted.
|
||||
// **What ADR 0325 refuses once a module runs as its own identity is warned of until then** (novox/hq ADR 0325 §1–§3):
|
||||
// a directory naming its owner, a container declaring no identity that mounts the module's own directory or a
|
||||
// read-write access. Printed, never counted: every registered container module does one of them today, and the
|
||||
// catalogues migrate against these lines. A module that declares identity is refused them instead (ParseManifest).
|
||||
for _, name := range names {
|
||||
for _, w := range catalogue.LibraryWarnings(shelf[name]) {
|
||||
for _, w := range catalogue.IdentityWarnings(shelf[name]) {
|
||||
fmt.Fprintf(out, "warning: %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// **No send but a person's `push <node>` and a gate's own put-back moves the mesh's own core to a build older
|
||||
// than the one its machine was last sent** (novox/hq issue 201). Every send made through sendToEach keeps it — a
|
||||
// plan's, a release's, a healer's, a rotation's, and the bus step too, which is a person's act but composes under
|
||||
// sendKeeps like the rest. On 2026-10-02 two merges a minute apart made two plans;
|
||||
// the controller's plan rolled the new controller, which widened its seat's row, and the node-engine's plan then
|
||||
// sent the control node the controller build before it. The older binary read the row it could not serve and the
|
||||
// mesh lost its voice for ten minutes — the thing that would have corrected it was the thing that was down. ADR
|
||||
// 0185 took the outage out of it (a controller behind its row serves what it can); the race is closed here,
|
||||
// without needing its trigger: whatever made the mesh's record older, the send keeps the build.
|
||||
//
|
||||
// **Kept, not refused.** The send composes the controller or the node-engine at the build the machine was last
|
||||
// sent, as a recorded module is kept (ADR 0245), and carries everything else: no other module, secret, healer or
|
||||
// walk waits on it, and nothing is retried. The kept move is no move for a gate either: ungatedIn and gatedSend
|
||||
// leave it out (withoutKept), so a gate judges the other moves of its send. A plan whose own build is the older one finds its gate superseded by
|
||||
// the build kept there (issue 352) and ends. The mesh's record stays older, and a warning condition says so until
|
||||
// a send carries the record — a person's `push <node>` accepts the older build — or the record moves forward: a
|
||||
// newer merge, or `rebuild mesh-controller`.
|
||||
//
|
||||
// Older is by the builds' registration order — when each was asked (novox/hq issue 219) — never by the commits'
|
||||
// dates. So a retry or a rebuild of an old commit, asked anew, counts as newer: it is registered as the module's
|
||||
// current build by the same order, and the guard does not stop that move. A build that failed its gate is never
|
||||
// ahead: the machine was sent it and the record was put back before it (gateFailed), and if the put-back's own
|
||||
// send was refused, the next send completes it rather than keeping the failed build there. A gate's own put-back
|
||||
// carries its modules back whatever the order (sendScope.puttingBack); the controller's is held to the store's
|
||||
// schema already (issue 352).
|
||||
|
||||
// kindCoreDowngrade is a core module kept at the build its machine was last sent, over an older record.
|
||||
const kindCoreDowngrade = "core-downgrade-kept"
|
||||
|
||||
// coreDowngradeSource is the condition's source: the sends, which raise and clear it.
|
||||
const coreDowngradeSource = "sends"
|
||||
|
||||
// downgradeGuarded is the core components a send may not move backwards: the controller, which reads the
|
||||
// store and its seat's row, and the node-engine, which reads what the controller declares.
|
||||
var downgradeGuarded = []string{lease.ComponentController, lease.ComponentEngine}
|
||||
|
||||
// askedBuild is what the order needs of one build of a module: when it was asked, and whether it failed its gate.
|
||||
type askedBuild struct {
|
||||
asked time.Time
|
||||
failedGate bool
|
||||
}
|
||||
|
||||
// buildAsked is the newest build of a module from a commit that worked — when it was asked (or recorded, when
|
||||
// the request time is not known), and whether it failed its gate — and false when the records hold none. A
|
||||
// variable, so a test reads its own order.
|
||||
var buildAsked = func(ctx context.Context, inv *inventory.Inventory, module, commit string) (askedBuild, bool, error) {
|
||||
builds, err := inv.Builds(ctx, module, askedLookBack)
|
||||
if err != nil {
|
||||
return askedBuild{}, false, err
|
||||
}
|
||||
for _, b := range builds {
|
||||
if b.Worked() && sameCommit(b.Commit, commit) {
|
||||
failed, err := inv.GateFailed(ctx, b.ID)
|
||||
if err != nil {
|
||||
return askedBuild{}, false, err
|
||||
}
|
||||
return askedBuild{asked: b.AskedOrAt(), failedGate: failed}, true, nil
|
||||
}
|
||||
}
|
||||
return askedBuild{}, false, nil
|
||||
}
|
||||
|
||||
// askedLookBack is how many of a module's newest builds are read for when a build was asked.
|
||||
const askedLookBack = 50
|
||||
|
||||
// coreDowngrade is one core module a machine was last sent a build of that is newer than the mesh's record.
|
||||
type coreDowngrade struct {
|
||||
module, node, component string
|
||||
// sent is the build the machine was last sent, record the one the mesh holds.
|
||||
sent, record string
|
||||
sentAsked, recordAsked time.Time
|
||||
}
|
||||
|
||||
func (d coreDowngrade) String() string {
|
||||
return fmt.Sprintf("%s was last sent %s %s, and the mesh holds the older %s (asked %s, before %s): a send there "+
|
||||
"keeps %s; `push %s` sends the older build, and a newer merge or `rebuild %s` moves the record forward "+
|
||||
"(novox/hq issue 201)", d.node, d.module, short(d.sent), short(d.record),
|
||||
d.recordAsked.UTC().Format(time.RFC3339), d.sentAsked.UTC().Format(time.RFC3339), short(d.sent), d.node,
|
||||
d.module)
|
||||
}
|
||||
|
||||
// coreDowngrades is, for one machine, each guarded core module whose record is older than the build the machine
|
||||
// was last sent, leaving out what a gate's own put-back puts back and a sent build that failed its gate. A move
|
||||
// whose either build the records do not hold is not proved older and is left to the gate that judges it. Pure
|
||||
// but for asked.
|
||||
func coreDowngrades(node string, sent map[string]string, record map[string]string, scope sendScope,
|
||||
asked func(module, commit string) (askedBuild, bool, error)) ([]coreDowngrade, error) {
|
||||
var out []coreDowngrade
|
||||
for module, was := range sent {
|
||||
component := coreComponent(module)
|
||||
now := record[module]
|
||||
if !guarded(component) || was == "" || now == "" || sameCommit(was, now) {
|
||||
continue
|
||||
}
|
||||
if scope.puttingBack && scope.modules[module] {
|
||||
continue
|
||||
}
|
||||
from, known, err := asked(module, was)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("when %s's build %s was asked cannot be read: %w", module, short(was), err)
|
||||
}
|
||||
if !known || from.failedGate {
|
||||
continue
|
||||
}
|
||||
to, known, err := asked(module, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("when %s's build %s was asked cannot be read: %w", module, short(now), err)
|
||||
}
|
||||
if known && to.asked.Before(from.asked) {
|
||||
out = append(out, coreDowngrade{module: module, node: node, component: component, sent: was, record: now,
|
||||
sentAsked: from.asked, recordAsked: to.asked})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].module < out[j].module })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func guarded(component string) bool {
|
||||
for _, c := range downgradeGuarded {
|
||||
if c == component {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// coreKept is, for a send that keeps (sendKeeps), each guarded core module of a machine to keep at the build it
|
||||
// was last sent, module → commit, each said and raised as a condition.
|
||||
func coreKept(ctx context.Context, inv *inventory.Inventory, node string, sent map[string]string,
|
||||
current map[string]inventory.CurrentBuild) (map[string]string, error) {
|
||||
record := map[string]string{}
|
||||
for m, c := range current {
|
||||
record[m] = c.Commit
|
||||
}
|
||||
found, err := coreDowngrades(node, sent, record, scopeOf(ctx), func(module, commit string) (askedBuild, bool, error) {
|
||||
return buildAsked(ctx, inv, module, commit)
|
||||
})
|
||||
if err != nil || len(found) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, d := range found {
|
||||
out[d.module] = d.sent
|
||||
sayCoreDowngrade(ctx, d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// coreDowngradeObservation is a kept core build as a condition: `core.<component>.<machine>.<kind>`.
|
||||
func coreDowngradeObservation(component, node, summary string) conditions.Observation {
|
||||
return conditions.Observation{Scope: conditions.ScopeCore, ID: component + "." + node, Kind: kindCoreDowngrade,
|
||||
Token: kindCoreDowngrade, Machine: node, Severity: conditions.Warning, Source: coreDowngradeSource,
|
||||
Summary: summary}
|
||||
}
|
||||
|
||||
func sayCoreDowngrade(ctx context.Context, d coreDowngrade) {
|
||||
fmt.Printf("KEPT: %s\n", d)
|
||||
keeper := conditionsFrom
|
||||
if keeper == nil {
|
||||
return
|
||||
}
|
||||
if _, err := keeper.Observe(ctx, coreDowngradeObservation(d.component, d.node, d.String())); err != nil {
|
||||
fmt.Printf("the condition for %s kept on %s could not be raised: %v\n", d.module, d.node, err)
|
||||
}
|
||||
}
|
||||
|
||||
// settleCoreDowngrades clears, after a send to a machine is away, the condition of each guarded core module the
|
||||
// send carried at the mesh's record — a person's push that accepted the older build, or a send after the record
|
||||
// moved forward — or did not carry at all. A send that kept the newer build leaves it open.
|
||||
func settleCoreDowngrades(ctx context.Context, inv *inventory.Inventory, node string, carried map[string]string) {
|
||||
keeper := conditionsFrom
|
||||
if keeper == nil || carried == nil {
|
||||
return
|
||||
}
|
||||
var open []string
|
||||
for _, c := range downgradeGuarded {
|
||||
if _, found, err := keeper.Get(ctx, coreDowngradeObservation(c, node, "").Key()); err == nil && found {
|
||||
open = append(open, c)
|
||||
}
|
||||
}
|
||||
if len(open) == 0 {
|
||||
return
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("whether %s still keeps a core build over the mesh's record cannot be read: %v\n", node, err)
|
||||
return
|
||||
}
|
||||
for module, now := range current {
|
||||
component := coreComponent(module)
|
||||
if !guarded(component) {
|
||||
continue
|
||||
}
|
||||
if was, has := carried[module]; has && !sameCommit(was, now.Commit) {
|
||||
continue
|
||||
}
|
||||
key := coreDowngradeObservation(component, node, "").Key()
|
||||
if _, err := keeper.Clear(ctx, key, node+" was sent the "+component+" the mesh holds"); err != nil {
|
||||
fmt.Printf("the condition %s could not be cleared: %v\n", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// **A change plan says its class** (novox/hq ADR 0313 §2): the issue tracker holds a fix to the budget of the
|
||||
// slowest-budgeted module the fix moves, and a fix that moves no module has no fix budget. The plan says core,
|
||||
// leaf or none, so mesh-delivery can pass it on with the fix's delivery.
|
||||
func TestAChangePlanSaysTheClassOfWhatItMoves(t *testing.T) {
|
||||
const repo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||
entry := func(name, path string) inventory.Entry {
|
||||
e := fromRepo(name, repo, path)
|
||||
e.Source.BuiltFrom = "old"
|
||||
e.On = []string{"anchor"}
|
||||
return e
|
||||
}
|
||||
gitea := entry("gitea", "modules/gitea")
|
||||
resolver := entry("dnsmasq", "modules/dnsmasq")
|
||||
resolver.Manifest.Claims = []catalogue.Claim{{Name: resolverSeat}}
|
||||
delivery := entry(catalogue.DeliverySeat, "modules/mesh-delivery")
|
||||
webApp := entry("photos", "modules/photos")
|
||||
entries := []inventory.Entry{gitea, resolver, delivery, webApp}
|
||||
// photos stands on gitea: a change to gitea moves photos as its dependent.
|
||||
edges := []inventory.Edge{{From: "photos", To: "gitea", Kind: inventory.EdgeStandsOn}}
|
||||
policy := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true }
|
||||
plan := func(paths ...string) link.ChangePlan {
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "head", Paths: paths}
|
||||
return changePlanOf("novox/mesh-catalog", "main", "head", reachOfMerge(m, entries, nil, edges), entries, policy)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
why string
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
{"a leaf module and its dependent", []string{"modules/gitea/index.ts"}, inventory.ClassLeaf},
|
||||
{"the delivery's owner, on the controller's own path", []string{"modules/mesh-delivery/cmd/x.go"}, inventory.ClassCore},
|
||||
{"the mesh's resolver", []string{"modules/dnsmasq/module.json"}, inventory.ClassCore},
|
||||
{"a leaf and a core module together: the slowest-budgeted", []string{"modules/gitea/index.ts", "modules/dnsmasq/module.json"}, inventory.ClassCore},
|
||||
{"nothing any module's build reads", []string{"README.md"}, "none"},
|
||||
{"a new leaf module alone", []string{"modules/weather/module.json"}, inventory.ClassLeaf},
|
||||
{"a new module on the controller's own path, by its own class", []string{"next/mesh-delivery/module.json"}, inventory.ClassCore},
|
||||
{"a new leaf module beside a core one moved", []string{"modules/weather/module.json", "modules/dnsmasq/module.json"}, inventory.ClassCore},
|
||||
} {
|
||||
if got := plan(c.paths...).Class; got != c.want {
|
||||
t.Errorf("%s: class %q, want %q", c.why, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,11 +819,11 @@ func movesChangingAccesses(ctx context.Context, inv *inventory.Inventory, g *inv
|
||||
return out
|
||||
}
|
||||
|
||||
// sameAccessUse says a move leaves a module's accesses and libraries as they were (the review of e1c2b744, novox/hq
|
||||
// ADR 0328): the same accesses at the same use, the same library entries, and whether it joins — a module that starts
|
||||
// joining runs as another user, and changes who uses its libraries.
|
||||
// sameAccessUse says a move leaves a module's accesses as they were, users included (the review of e1c2b744): the
|
||||
// same accesses at the same use, and the same containers declaring identity and mounting each — a container that
|
||||
// starts or stops running as the module's identity changes who uses the folder.
|
||||
func sameAccessUse(a, b catalogue.Manifest) bool {
|
||||
return sameAccesses(a.Accesses, b.Accesses) && catalogue.LibraryUseShape(a) == catalogue.LibraryUseShape(b)
|
||||
return sameAccesses(a.Accesses, b.Accesses) && catalogue.AccessUsersShape(a) == catalogue.AccessUsersShape(b)
|
||||
}
|
||||
|
||||
// sameAccesses says two manifests declare the same accesses, at the same use, in any order.
|
||||
@@ -1095,7 +1095,7 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
||||
}
|
||||
return
|
||||
}
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: map[string]bool{module: true}, puttingBack: true}), open, g.Machines)
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: map[string]bool{module: true}}), open, g.Machines)
|
||||
if err != nil {
|
||||
notBack(fmt.Sprintf("its registered build is back at %s, and sending it to %s was refused: %v — `push %s` "+
|
||||
"sends it", short(previous.Commit), strings.Join(g.Machines, ", "), err, g.Machines[0]))
|
||||
@@ -1188,7 +1188,7 @@ func sendRollbacks(ctx context.Context, open *stores, p *inventory.Plan, b *roll
|
||||
}
|
||||
inv := open.inventory
|
||||
sort.Strings(b.machines)
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: b.modules, puttingBack: true}), open, b.machines)
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: b.modules}), open, b.machines)
|
||||
var back []string
|
||||
for _, r := range b.pending {
|
||||
if err != nil {
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A module that joins keeps the user it ran as until it may join (novox/hq ADR 0328 §8, ADR 0334).
|
||||
//
|
||||
// **Held at the build it ran before, composed exactly as then.** A module whose registered build joins — declares
|
||||
// libraries or uses its identity — on a machine that was last sent another build of it, is held there while
|
||||
//
|
||||
// - the machine's engine is older than link.OwnGroupContract: it cannot put the tool runner in the module's own group
|
||||
// nor keep its own directories' group, so the module's tools would lose their read (the re-review of 96e15f86, 2);
|
||||
// - or a library it uses is not yet taken (§8).
|
||||
//
|
||||
// Held, it is composed at that build — the manifest the build records hold, in place of the registered one, as a
|
||||
// recorded module is kept (recorded_kept.go) — and with the settings it last ran with (SettingsAsOf the machine's last
|
||||
// send): its resources, its ports in the filter, its contributions and bindings are what they were, and nothing of the
|
||||
// new contract is sent for it. The send records that the machine still carries that build. A delivery that brings the
|
||||
// joining build does not send it while it is held: the module waits for a person, the plan says so and judges its
|
||||
// other modules, and sends it once nothing holds it (release_plan.go, heldIn). A module the machine never ran has
|
||||
// nothing to keep, and is composed as registered; a build the records no longer hold cannot be kept.
|
||||
|
||||
// heldModule is one module held on a machine: the build it keeps and why, in words with no path.
|
||||
type heldModule struct {
|
||||
commit string
|
||||
// library is the first library it uses that is not taken, empty when none.
|
||||
library string
|
||||
why string
|
||||
}
|
||||
|
||||
// holdJoining is every module held on a machine, by module; shelf, when given, has each one's manifest replaced by
|
||||
// the build it keeps.
|
||||
func holdJoining(ctx context.Context, inv *inventory.Inventory, node string,
|
||||
shelf map[string]catalogue.Manifest) (map[string]heldModule, error) {
|
||||
registered := shelf
|
||||
if registered == nil {
|
||||
var err error
|
||||
if registered, err = inv.Catalogue(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sent, known, err := inv.SentBuilds(ctx, node)
|
||||
if err != nil || !known {
|
||||
return nil, err
|
||||
}
|
||||
var joining []string
|
||||
for m := range sent {
|
||||
if man, has := registered[m]; has && catalogue.Joins(man) {
|
||||
joining = append(joining, m)
|
||||
}
|
||||
}
|
||||
if len(joining) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sort.Strings(joining)
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engineJoins := had && stated.Contract >= link.OwnGroupContract
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taken, err := librariesTaken(ctx, inv, libs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]heldModule{}
|
||||
for _, m := range joining {
|
||||
was := sent[m]
|
||||
if was == "" || sameCommit(was, current[m].Commit) {
|
||||
continue // the machine already runs the registered build: nothing before it to keep
|
||||
}
|
||||
h := heldModule{commit: was}
|
||||
for _, u := range registered[m].Libraries {
|
||||
if !taken[u.Library] {
|
||||
h.library = u.Library
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !engineJoins:
|
||||
h.why = fmt.Sprintf("%s keeps the build it runs on %s until that machine's node-engine keeps a module's own "+
|
||||
"group (novox/hq ADR 0334)", m, node)
|
||||
case h.library != "":
|
||||
h.why = fmt.Sprintf("%s waits for the operator to let the mesh keep the library %s (novox/hq ADR 0328 §8)",
|
||||
m, h.library)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
before, found, err := inv.ManifestAt(ctx, m, was)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || (engineJoins && usesLibrary(before, h.library)) {
|
||||
continue
|
||||
}
|
||||
if shelf != nil {
|
||||
shelf[m] = before
|
||||
}
|
||||
out[m] = h
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// heldIn is why a module is held on any of the machines given, or "": what a delivery's first send waits on.
|
||||
func heldIn(ctx context.Context, inv *inventory.Inventory, module string, machines []string) (string, error) {
|
||||
for _, node := range machines {
|
||||
held, err := holdJoining(ctx, inv, node, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if h, is := held[module]; is {
|
||||
return h.why, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// heldSettings puts, for each module held on a machine, the settings it last ran with in place of today's: the layers
|
||||
// as they stood at the machine's last send. Today's stand where no send is recorded.
|
||||
func heldSettings(ctx context.Context, inv *inventory.Inventory, node string, held map[string]heldModule,
|
||||
settings catalogue.SettingsBy) error {
|
||||
if len(held) == 0 {
|
||||
return nil
|
||||
}
|
||||
last, found, err := inv.LastSend(ctx, node)
|
||||
if err != nil || !found {
|
||||
return err
|
||||
}
|
||||
for m := range held {
|
||||
layers, err := inv.SettingsAsOf(ctx, node, m, last.SentAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(layers) == 0 {
|
||||
delete(settings, m)
|
||||
continue
|
||||
}
|
||||
settings[m] = layers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func usesLibrary(m catalogue.Manifest, library string) bool {
|
||||
for _, u := range m.Libraries {
|
||||
if library != "" && u.Library == library {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
const heldImage = "x@sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||
|
||||
// radarrBefore is radarr as it ran before it joined: its own port, its own data, PUID from a setting.
|
||||
func radarrBefore() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "radarr", Version: "1", Capabilities: []string{"container-runtime"},
|
||||
Listens: []catalogue.Listening{{Port: 7878, From: catalogue.FromMesh, Name: "web", Why: "its web UI"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"},
|
||||
"env": map[string]any{"PUID": "1000"}, "volumes": []any{"${dir:config}:/config"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// radarrJoined is the same module's next build, joining the media library.
|
||||
func radarrJoined() catalogue.Manifest {
|
||||
m := radarrBefore()
|
||||
m.Libraries = []catalogue.LibraryUse{{ID: "movies", Library: "media", Path: "movies", Use: catalogue.AccessReadWrite}}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"},
|
||||
"env": map[string]any{"PUID": "${identity:uid}"},
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// novox/hq ADR 0328 §8 (the review of 7ea6d36e, item 1): a joining module that ran on a machine before is held until
|
||||
// its libraries are taken — composed EXACTLY as before it joined, at the build the machine runs: its resources, its
|
||||
// port in the filter, its contributions; only the new build waits. The record says the machine still carries that build.
|
||||
func TestAHeldModuleIsComposedAsBeforeItJoined(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
controller := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"}
|
||||
register(t, open, controller)
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, joined := radarrBefore(), radarrJoined()
|
||||
raw, _ := json.Marshal(before)
|
||||
b1 := inventory.Build{ID: "b1", Repository: "https://forge.invalid/media.git", Ref: "main", Module: "radarr",
|
||||
Commit: "c1c1c1c1", On: "builder", Manifest: raw}
|
||||
if err := inv.RecordBuild(ctx, b1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, before, inventory.Source{Repository: b1.Repository, Ref: "main", BuiltFrom: b1.Commit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What anchor composes for radarr before it joined.
|
||||
plan, settings, err := planFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wasRules, err := plan.Rules(catalogue.Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": b1.Commit}, 1, 1,
|
||||
inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Anchor's engine keeps libraries; the next build joins media, which is not taken.
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.LibraryContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ = json.Marshal(joined)
|
||||
b2 := b1
|
||||
b2.ID, b2.Commit, b2.Manifest = "b2", "c2c2c2c2", raw
|
||||
if err := inv.RecordBuild(ctx, b2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, joined, inventory.Source{Repository: b2.Repository, Ref: "main", BuiltFrom: b2.Commit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan, settings, err = planFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := held.leftOutWhy["radarr"]; left {
|
||||
t.Fatalf("radarr was left out while held: %s", why)
|
||||
}
|
||||
if !reflect.DeepEqual(radarrResources(was.Resources), radarrResources(held.Resources)) {
|
||||
t.Fatalf("radarr held is composed otherwise than before it joined:\n %v\n %v", radarrResources(was.Resources),
|
||||
radarrResources(held.Resources))
|
||||
}
|
||||
rules, err := plan.Rules(catalogue.Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(rules, wasRules) || !strings.Contains(asJSON(rules), "7878") {
|
||||
t.Fatalf("radarr's port is not in the filter as before: %s", asJSON(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func radarrResources(rs []map[string]any) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, r := range rs {
|
||||
if strings.HasPrefix(r["id"].(string), "radarr.") {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
return string(raw)
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The re-review of 96e15f86, item 1 (novox/hq ADR 0328 §8): a delivery that brings a joining build of a module whose
|
||||
// library is not taken does not send it — the module waits for a person, the plan stays open saying so, and the other
|
||||
// modules of the plan are sent and judged as usual. Once the library is taken, the waiting build is sent and judged,
|
||||
// with no new merge.
|
||||
func TestADeliveryWaitsForALibraryAndJudgesTheRest(t *testing.T) {
|
||||
judgeLeavesAsTheCore(t)
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
was := doctorFrom
|
||||
doctorFrom = &doctor{open: open, keeper: keeper, teller: &conditions.Told{}}
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app1 := catalogue.Manifest{Module: "app", Version: "c1"}
|
||||
app2 := catalogue.Manifest{Module: "app", Version: "c2"}
|
||||
builds := []struct {
|
||||
id, module, commit string
|
||||
m catalogue.Manifest
|
||||
at time.Time
|
||||
}{
|
||||
{"build-app-1", "app", "c1", app1, time.Now().Add(-2 * time.Hour)},
|
||||
{"build-radarr-1", "radarr", "r1", radarrBefore(), time.Now().Add(-2 * time.Hour)},
|
||||
{"build-app-2", "app", "c2", app2, time.Now().Add(-time.Minute)},
|
||||
{"build-radarr-2", "radarr", "r2", radarrJoined(), time.Now().Add(-time.Minute)},
|
||||
}
|
||||
for _, b := range builds {
|
||||
raw, _ := json.Marshal(b.m)
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: b.id, Module: b.module, Commit: b.commit,
|
||||
Repository: "novox/mesh-media-catalog", Path: "modules/" + b.module, Ref: "main", Manifest: raw,
|
||||
Asked: b.at, At: b.at}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
registerAt := func(m catalogue.Manifest, commit string, asked time.Time) {
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-media-catalog", Seat: "git",
|
||||
Path: "modules/" + m.Module, Ref: "main", BuiltFrom: commit, Head: commit, Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
registerAt(app1, "c1", time.Now().Add(-2*time.Hour))
|
||||
registerAt(radarrBefore(), "r1", time.Now().Add(-2*time.Hour))
|
||||
if _, err := inv.Assign(ctx, "anchor", "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-0", map[string]string{"app": "c1", "radarr": "r1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.OwnGroupContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registerAt(app2, "c2", time.Now().Add(-time.Minute))
|
||||
registerAt(radarrJoined(), "r2", time.Now().Add(-time.Minute))
|
||||
|
||||
// Every send records what it carries: the registered builds, a held module at the build it runs.
|
||||
var sent []string
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
current, err := open.inventory.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range names {
|
||||
held, err := holdJoining(ctx, open.inventory, node, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
carried := map[string]string{"app": current["app"].Commit, "radarr": current["radarr"].Commit}
|
||||
for m, h := range held {
|
||||
carried[m] = h.commit
|
||||
}
|
||||
sent = append(sent, node+":app="+carried["app"]+",radarr="+carried["radarr"])
|
||||
digest := fmt.Sprintf("d-%s-%d", node, len(sent))
|
||||
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
|
||||
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = wasSend })
|
||||
wasGather := gatherGateFacts
|
||||
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
||||
f := gateFacts{now: time.Now(), reports: map[string]inventory.Reported{}, engines: map[string]string{},
|
||||
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
|
||||
reports, err := open.inventory.LastReports(ctx)
|
||||
for _, r := range reports {
|
||||
f.reports[r.Node] = r
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
t.Cleanup(func() { gatherGateFacts = wasGather })
|
||||
wasSettle, wasEvery := gateSettle, gateEvery
|
||||
gateSettle, gateEvery = 0, 0
|
||||
t.Cleanup(func() { gateSettle, gateEvery = wasSettle, wasEvery })
|
||||
|
||||
built := time.Now().UTC()
|
||||
plan := inventory.Plan{ID: "plan-held", Repository: "novox/mesh-media-catalog", Branch: "main", Commit: "c2",
|
||||
Created: built, State: inventory.PlanBuilding, Tiers: [][]string{{"app", "radarr"}},
|
||||
Modules: map[string]*inventory.PlanModule{
|
||||
"app": {State: "built", BuiltAt: &built, Commit: "c2", Build: "build-app-2"},
|
||||
"radarr": {State: "built", BuiltAt: &built, Commit: "r2", Build: "build-radarr-2"}}}
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, open)
|
||||
}
|
||||
p, err := inv.PlanByID(ctx, "plan-held")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.State == inventory.PlanSuperseded || p.State == inventory.PlanDone || p.State == inventory.PlanFailed {
|
||||
t.Fatalf("the plan is %s (%s); want it open, waiting for the library", p.State, p.Note)
|
||||
}
|
||||
if g := p.Modules["app"].Gate; g == nil || g.Verdict != inventory.GatePassed {
|
||||
t.Fatalf("app was not judged and passed beside the held module: %+v (%s)", p.Modules["app"], p.Note)
|
||||
}
|
||||
if p.Modules["radarr"].FirstAt != nil || !strings.Contains(p.Note, "library media") {
|
||||
t.Fatalf("radarr was sent, or the plan does not say it waits for the library: %+v, %q", p.Modules["radarr"], p.Note)
|
||||
}
|
||||
if slices.ContainsFunc(sent, func(s string) bool { return strings.Contains(s, "radarr=r2") }) {
|
||||
t.Fatalf("radarr's joining build was sent while held: %v", sent)
|
||||
}
|
||||
|
||||
// The library is taken: the waiting build is sent, with no new merge.
|
||||
if err := inv.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: "media", Home: "anchor", Root: "/storage/media",
|
||||
By: "the operator", Ask: "c01", Warrant: "c01"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.TakeDone(ctx, "media", "c01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, open)
|
||||
}
|
||||
if !slices.ContainsFunc(sent, func(s string) bool { return strings.Contains(s, "radarr=r2") }) {
|
||||
t.Fatalf("radarr's joining build was not sent once its library was taken: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// The re-review of 96e15f86, item 2: a module joins only on an engine stating link.OwnGroupContract (7). On an engine
|
||||
// at 6 a module that ran there before is held at the build it ran, whatever its library — so its tools never lose
|
||||
// their read of its directories.
|
||||
func TestAJoiningModuleIsHeldOnAnEngineOlderThanOwnGroups(t *testing.T) {
|
||||
open, record := heldMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
if err := inv.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: "media", Home: "anchor", Root: "/storage/media",
|
||||
By: "the operator", Ask: "c01", Warrant: "c01"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.TakeDone(ctx, "media", "c01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = record
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.LibraryContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := holdJoining(ctx, inv, "anchor", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if held["radarr"].commit != "c1c1c1c1" {
|
||||
t.Fatalf("on an engine at contract 6 radarr is not held at the build it ran: %+v", held)
|
||||
}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.OwnGroupContract,
|
||||
SaidAt: time.Now().Add(time.Second), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if held, err = holdJoining(ctx, inv, "anchor", nil); err != nil || len(held) > 0 {
|
||||
t.Fatalf("on an engine at contract 7 with its library taken radarr is still held: %+v (%v)", held, err)
|
||||
}
|
||||
}
|
||||
|
||||
// heldMesh is anchor running radarr's build from before it joined, the joining build registered, media defined.
|
||||
func heldMesh(t *testing.T) (*stores, inventory.Node) {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range []struct {
|
||||
id, commit string
|
||||
m catalogue.Manifest
|
||||
}{{"b1", "c1c1c1c1", radarrBefore()}, {"b2", "c2c2c2c2", radarrJoined()}} {
|
||||
raw, _ := json.Marshal(b.m)
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: b.id, Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
Module: "radarr", Commit: b.commit, On: "builder", Manifest: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, radarrBefore(), inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c1c1c1c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1c1c1c1"}, 1, 1,
|
||||
inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, radarrJoined(), inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c2c2c2c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return open, record
|
||||
}
|
||||
|
||||
// The re-review of 96e15f86, item 3: a held module is composed with the settings it last ran with — a layer changed
|
||||
// for the joining build (its puid removed) does not reach the build it still runs.
|
||||
func TestAHeldModuleKeepsTheSettingsItRanWith(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := radarrBefore()
|
||||
before.Resources = append(before.Resources, map[string]any{"id": "identity", "type": "file",
|
||||
"path": "${dir:config}/identity.env", "mode": "0644", "content": "PUID=${setting:puid}\n"})
|
||||
joined := radarrJoined()
|
||||
for _, b := range []struct {
|
||||
id, commit string
|
||||
m catalogue.Manifest
|
||||
}{{"b1", "c1c1c1c1", before}, {"b2", "c2c2c2c2", joined}} {
|
||||
raw, _ := json.Marshal(b.m)
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: b.id, Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
Module: "radarr", Commit: b.commit, On: "builder", Manifest: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, before, inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c1c1c1c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "anchor", "radarr", map[string]any{"puid": "1000"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1c1c1c1"}, 1, 1,
|
||||
inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := inv.RegisterModule(ctx, joined, inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c2c2c2c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, "anchor", "radarr"); err != nil { // the joining build reads no puid
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.OwnGroupContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, settings, err := planFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := held.leftOutWhy["radarr"]; left {
|
||||
t.Fatalf("radarr held was left out: %s", why)
|
||||
}
|
||||
for _, r := range held.Resources {
|
||||
if r["id"] == "radarr.identity" {
|
||||
if r["content"] != "PUID=1000\n" {
|
||||
t.Fatalf("radarr held reads %q; want the puid it ran with", r["content"])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("radarr held has no identity file")
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// novox/hq issue 201: on 2026-10-02 a plan sent the control node a controller older than the one running there,
|
||||
// whose seat row the newer one had already widened; the older binary refused the row and the mesh went quiet for
|
||||
// ten minutes. No send but a person's push and a gate's own put-back moves the controller or the node-engine back
|
||||
// behind the build its machine was last sent: the send keeps it there, and carries the rest.
|
||||
|
||||
// askedIn answers a build from a fixed table: module@commit → when it was asked; failed names those that failed
|
||||
// their gate.
|
||||
func askedIn(table map[string]time.Time, failed ...string) func(module, commit string) (askedBuild, bool, error) {
|
||||
return func(module, commit string) (askedBuild, bool, error) {
|
||||
at, known := table[module+"@"+commit]
|
||||
b := askedBuild{asked: at}
|
||||
for _, f := range failed {
|
||||
if f == module+"@"+commit {
|
||||
b.failedGate = true
|
||||
}
|
||||
}
|
||||
return b, known, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestACoreModuleIsKeptOverAnOlderRecord(t *testing.T) {
|
||||
old, now := t0, t0.Add(time.Minute)
|
||||
table := map[string]time.Time{
|
||||
"mesh-controller@c1": old, "mesh-controller@c2": now,
|
||||
"mesh-host@h1": old, "mesh-host@h2": now,
|
||||
"app@a1": old, "app@a2": now,
|
||||
}
|
||||
sent := map[string]string{"mesh-controller": "c2", "mesh-host": "h2", "app": "a2"}
|
||||
record := map[string]string{"mesh-controller": "c1", "mesh-host": "h1", "app": "a1"}
|
||||
found, err := coreDowngrades("anchor", sent, record, sendScope{judged: map[string]bool{"anchor": true}}, askedIn(table))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 2 || found[0].module != "mesh-controller" || found[0].component != lease.ComponentController ||
|
||||
found[0].sent != "c2" || found[1].module != "mesh-host" || found[1].component != lease.ComponentEngine {
|
||||
t.Fatalf("the controller and the node-engine over an older record are kept, and nothing else: %+v", found)
|
||||
}
|
||||
if said := found[0].String(); !strings.Contains(said, "anchor was last sent mesh-controller c2") ||
|
||||
!strings.Contains(said, "`push anchor`") || !strings.Contains(said, "`rebuild mesh-controller`") {
|
||||
t.Fatalf("it says the machine, the build kept and the two ways out: %s", said)
|
||||
}
|
||||
|
||||
// The record forward, the same build, or a build the records do not hold: nothing kept.
|
||||
for _, c := range [][2]string{{"c1", "c2"}, {"c2", "c2"}, {"c2", "c9"}, {"c9", "c1"}} {
|
||||
found, err := coreDowngrades("anchor", map[string]string{"mesh-controller": c[0]},
|
||||
map[string]string{"mesh-controller": c[1]}, sendScope{}, askedIn(table))
|
||||
if err != nil || len(found) != 0 {
|
||||
t.Fatalf("sent %s, record %s: no downgrade proved, nothing kept: %+v %v", c[0], c[1], found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A sent build that failed its gate is never ahead: the record put back before it is sent.
|
||||
if found, _ := coreDowngrades("anchor", sent, record, sendScope{}, askedIn(table, "mesh-controller@c2")); len(found) != 1 ||
|
||||
found[0].module != "mesh-host" {
|
||||
t.Fatalf("a controller that failed its gate was kept over the build put back: %+v", found)
|
||||
}
|
||||
|
||||
// A gate's own put-back carries its own modules back, and nothing else.
|
||||
putBack := sendScope{modules: map[string]bool{"mesh-controller": true}, puttingBack: true}
|
||||
if found, _ := coreDowngrades("anchor", sent, record, putBack, askedIn(table)); len(found) != 1 || found[0].module != "mesh-host" {
|
||||
t.Fatalf("a put-back of the controller carries it back, and not the node-engine: %+v", found)
|
||||
}
|
||||
if found, _ := coreDowngrades("anchor", sent, record, sendScope{modules: map[string]bool{"mesh-controller": true}},
|
||||
askedIn(table)); len(found) != 2 {
|
||||
t.Fatalf("a send for the controller that is no put-back carries it back: %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// core201 is a mesh whose control node, anchor, was sent the newer controller c2 while the mesh's record of the
|
||||
// controller is the build before it, c1, which passed its gate when it rolled out.
|
||||
func core201(t *testing.T) *stores {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
old, now := time.Now().Add(-2*time.Hour), time.Now().Add(-time.Minute)
|
||||
record := func(commit string, asked time.Time) inventory.Build {
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "mesh-controller", Version: "1"})
|
||||
b := inventory.Build{ID: "build-mesh-controller-" + commit, Module: "mesh-controller", Commit: commit,
|
||||
Repository: "novox/mesh-controller", Manifest: manifest, Asked: asked, At: asked,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:" + commit + "0000000000000000"}}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
before := record("c1aaaaaaaaaa", old)
|
||||
if err := inv.RecordGate(ctx, inventory.GateVerdict{Build: before.ID, Module: "mesh-controller", Commit: before.Commit,
|
||||
Machines: []string{"anchor"}, Verdict: inventory.GatePassed}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record("c2bbbbbbbbbb", now)
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-controller", BuiltFrom: "c2bbbbbbbbbb", Head: "c2bbbbbbbbbb", Asked: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "mesh-controller"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor", map[string]string{"mesh-controller": "c2bbbbbbbbbb"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RestoreModule(ctx, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wasMoves := machineMoves
|
||||
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
|
||||
modules, err := open.inventory.Assigned(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sent, known, err := open.inventory.SentBuilds(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.moves(node, modules, sent, known, all), nil
|
||||
}
|
||||
t.Cleanup(func() { machineMoves = wasMoves })
|
||||
return open
|
||||
}
|
||||
|
||||
// TestReplay201 replays the race on a store: the control node runs the newer controller and the mesh's record is
|
||||
// the build before it. A plan's send there — the node-engine's plan of 2026-10-02 — goes, keeps the newer
|
||||
// controller, and says so as a condition; the gate's own put-back carries it back; a send that carries the record,
|
||||
// as a person's push does, clears the condition.
|
||||
func TestReplay201(t *testing.T) {
|
||||
open := core201(t)
|
||||
ctx := t.Context()
|
||||
k, _ := withConditionsInMemory(t)
|
||||
|
||||
judged := withScope(ctx, sendScope{judged: map[string]bool{"anchor": true}})
|
||||
if kept, err := ungatedIn(judged, open, []string{"anchor"}, ""); err != nil || len(kept) != 1 {
|
||||
t.Fatalf("a plan's send to the machine is made, the rest of it with it: %v %v", kept, err)
|
||||
}
|
||||
keeps, err := recordedKept(keepingRecorded(judged), open, "anchor")
|
||||
if err != nil || keeps["mesh-controller"] != "c2bbbbbbbbbb" {
|
||||
t.Fatalf("the send keeps the newer controller there: %v %v", keeps, err)
|
||||
}
|
||||
shelf := map[string]catalogue.Manifest{"mesh-controller": {Module: "mesh-controller", Version: "old"}}
|
||||
if _, err := keepRecorded(keepingRecorded(judged), open, "anchor", shelf); err != nil || shelf["mesh-controller"].Version != "1" {
|
||||
t.Fatalf("the send composes the kept build's manifest: %+v %v", shelf, err)
|
||||
}
|
||||
key := coreDowngradeObservation(lease.ComponentController, "anchor", "").Key()
|
||||
if c, found, err := k.Get(ctx, key); err != nil || !found || !strings.Contains(c.Summary, "c2bbbbbb") {
|
||||
t.Fatalf("the condition %s is raised, saying the build kept: %+v %v %v", key, c, found, err)
|
||||
}
|
||||
|
||||
putBack := keepingRecorded(withScope(ctx, sendScope{modules: map[string]bool{"mesh-controller": true}, puttingBack: true}))
|
||||
if keeps, err := recordedKept(putBack, open, "anchor"); err != nil || len(keeps) != 0 {
|
||||
t.Fatalf("the gate's own put-back of the controller carries it back: %v %v", keeps, err)
|
||||
}
|
||||
|
||||
settleCoreDowngrades(ctx, open.inventory, "anchor", map[string]string{"mesh-controller": "c2bbbbbbbbbb"})
|
||||
if _, found, _ := k.Get(ctx, key); !found {
|
||||
t.Fatal("a send that kept the newer controller cleared the condition")
|
||||
}
|
||||
settleCoreDowngrades(ctx, open.inventory, "anchor", map[string]string{"mesh-controller": "c1aaaaaaaaaa"})
|
||||
if _, found, _ := k.Get(ctx, key); found {
|
||||
t.Fatal("a send carrying the mesh's record — a person's push — leaves the condition open")
|
||||
}
|
||||
}
|
||||
|
||||
// The review of #273: a gate failed the newer controller, put the record back, and the put-back's own send was
|
||||
// refused. The next automatic send — a healer's, which judges nothing — completes the put-back: a build that
|
||||
// failed its gate is never kept.
|
||||
func TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend(t *testing.T) {
|
||||
open := core201(t)
|
||||
ctx := t.Context()
|
||||
k, _ := withConditionsInMemory(t)
|
||||
if err := open.inventory.RecordGate(ctx, inventory.GateVerdict{Build: "build-mesh-controller-c2bbbbbbbbbb",
|
||||
Module: "mesh-controller", Commit: "c2bbbbbbbbbb", Previous: "c1aaaaaaaaaa", Machines: []string{"anchor"},
|
||||
Verdict: inventory.GateFailed, Rollback: inventory.NotRolledBack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
healer := withScope(ctx, sendScope{})
|
||||
if kept, err := ungatedIn(healer, open, []string{"anchor"}, ""); err != nil || len(kept) != 1 {
|
||||
t.Fatalf("the healer's send is made: %v %v", kept, err)
|
||||
}
|
||||
if keeps, err := recordedKept(keepingRecorded(healer), open, "anchor"); err != nil || len(keeps) != 0 {
|
||||
t.Fatalf("the failed controller was kept over the build put back: %v %v", keeps, err)
|
||||
}
|
||||
if open, _ := k.Open(ctx); len(open) != 0 {
|
||||
t.Fatalf("a completed put-back raised a condition: %+v", open)
|
||||
}
|
||||
}
|
||||
|
||||
// Review 91 of #273: a gated send to a machine whose controller is kept over an older record leaves the kept
|
||||
// controller out of what its gate judges. Judged as moving back to the record, the gate would read the send's own
|
||||
// kept controller as another build sent there after it (issue 352) and be superseded, judging nothing.
|
||||
func TestAGateJudgesTheOtherMovesBesideAKeptCore(t *testing.T) {
|
||||
open := core201(t)
|
||||
ctx := t.Context()
|
||||
withConditionsInMemory(t)
|
||||
inv := open.inventory
|
||||
old, now := time.Now().Add(-2*time.Hour), time.Now().Add(-time.Minute)
|
||||
for _, b := range []struct {
|
||||
commit string
|
||||
at time.Time
|
||||
}{{"a1aaaaaaaaaa", old}, {"a2bbbbbbbbbb", now}} {
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-" + b.commit, Module: "app", Commit: b.commit,
|
||||
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: b.at, At: b.at,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-" + b.commit}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: b.commit, Head: b.commit,
|
||||
Asked: b.at}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-2",
|
||||
map[string]string{"mesh-controller": "c2bbbbbbbbbb", "app": "a1aaaaaaaaaa"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The send as it is made: the controller kept at c2, the app moved to the record.
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
return names, open.inventory.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-3",
|
||||
map[string]string{"mesh-controller": "c2bbbbbbbbbb", "app": "a2bbbbbbbbbb"})
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = wasSend })
|
||||
|
||||
moves, sent, _, err := gatedSend(ctx, open, "anchor", nil)
|
||||
if err != nil || len(sent) != 1 {
|
||||
t.Fatalf("the gated send is made: %v %v", sent, err)
|
||||
}
|
||||
if len(moves) != 1 || moves[0].Module != "app" || moves[0].To != "a2bbbbbbbbbb" {
|
||||
t.Fatalf("the gate judges the app's move and not the kept controller: %+v", moves)
|
||||
}
|
||||
builds, _, err := inv.SentBuilds(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := &inventory.PlanGate{Carried: moves}
|
||||
f := gateFacts{commits: judgedCommits(g, nil), sentBuilds: map[string]map[string]string{"anchor": builds}}
|
||||
for _, m := range []string{"app", "mesh-controller"} {
|
||||
if why, superseded := f.supersededOn(m, "anchor"); superseded {
|
||||
t.Fatalf("the gate is superseded by its own send: %s", why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package main
|
||||
|
||||
// The mesh takes a shared folder that already holds data once, on the operator's word (novox/hq ADR 0325 §7, §8).
|
||||
//
|
||||
// **What waits.** A node-engine that keeps folders states an access waiting, naming it (inventory.Wait.Access), while
|
||||
// its folder is there and has not been taken; the controller excuses that wait only for a declared, placed folder not
|
||||
// yet taken (checkAccessWait), and the module is said as its needs-operator condition, in words with no path.
|
||||
//
|
||||
// **One ask per folder and machine.** The serving controller's asker asks the operator once for each such folder on
|
||||
// each machine — however many modules wait on it — at the level approve (ADR 0259), in the clause's words: "Let the
|
||||
// mesh keep <path> on <machine>, now for <modules>: …". It is kept in the controller's asks beside a proposal
|
||||
// (asked.Take), never a condition's: the condition is the module's, and two modules waiting on one folder are one
|
||||
// question. While it is open no second is asked; one that expired unanswered is asked again while the folder waits;
|
||||
// one about a folder that no longer waits is taken back. None is asked for a folder already taken, so a module that
|
||||
// joins a taken folder is asked nothing (its access wait is not excused, and its entries are added at apply).
|
||||
//
|
||||
// **On the warrant, and only on it**, the controller asks the machine's engine to take the folder (link.AskTake),
|
||||
// naming the access by its resource id and the warrant; the engine adds the entries of the users it was declared for
|
||||
// that folder. On its word that it started, the folder is recorded taken (kept_folder), and from the next send every
|
||||
// access to it says `taken`. Where the engine's wait says the filesystem must first allow ACLs (acl
|
||||
// "needs-enabling"), the ask says the take sets that; where it cannot carry them (acl "unsupported") the wait is not
|
||||
// excused and nothing is asked.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// folderTake is one folder the operator is asked to let the mesh keep, as the controller's ask keeps it
|
||||
// (asked.Take). Every field the ask is composed from is here, so the act on the warrant is composed from the record.
|
||||
type folderTake struct {
|
||||
Node string `json:"node"`
|
||||
Path string `json:"path"`
|
||||
// Access is the resource id of one access to it in the machine's declaration: what the take act names.
|
||||
Access string `json:"access"`
|
||||
// Accesses are the access ids (each module's own) that place it, Modules the modules waiting on it, both sorted.
|
||||
Accesses []string `json:"accesses"`
|
||||
Modules []string `json:"modules"`
|
||||
// EnablesACL says the folder's filesystem must first allow access entries (a ZFS dataset's acltype), which the
|
||||
// take act sets: the ask says so.
|
||||
EnablesACL bool `json:"enables-acl,omitempty"`
|
||||
// Placed is the filesystem the folder is on, as the engine names it without a path: the warrant is for that one.
|
||||
Placed string `json:"placed,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant.
|
||||
const takeVerb = askerName + ".take"
|
||||
|
||||
// key is the folder on its machine: what one ask is kept for.
|
||||
// The filesystem is part of it: a folder that moved to another disk is asked about again.
|
||||
func (t folderTake) key() string { return t.Node + "\x00" + filepath.Clean(t.Path) + "\x00" + t.Placed }
|
||||
|
||||
// digest binds everything the take does and was said for: the machine, the folder, the access it names and the
|
||||
// modules the operator was told.
|
||||
func (t folderTake) digest() string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
Node, Path, Access, Placed string
|
||||
Modules []string
|
||||
EnablesACL bool
|
||||
}{t.Node, filepath.Clean(t.Path), t.Access, t.Placed, t.Modules, t.EnablesACL})
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// actions is the take's one answer, at the level approve, binding the exact act through boundAct.
|
||||
func (t folderTake) actions(id string) []conditions.Action {
|
||||
return []conditions.Action{{Label: "Let the mesh keep it", Verb: takeVerb, Machine: t.Node,
|
||||
Level: conditions.LevelApprove, Arguments: map[string]string{"take": id, "node": t.Node,
|
||||
"path": filepath.Clean(t.Path), "access": t.Access, "digest": t.digest()}}}
|
||||
}
|
||||
|
||||
// takeWords is the clause's ask (ADR 0325 §8), with the folder as shown: "Let the mesh keep <path> on <machine>, now
|
||||
// for <modules>: …".
|
||||
func takeWords(shown, machine string, modules []string, enablesACL bool) string {
|
||||
words := fmt.Sprintf("Let the mesh keep %s on %s, now for %s: it adds their access entries, and those of any "+
|
||||
"module given access later, to every folder and file there; the group bits `ls -l` shows become the "+
|
||||
"entries' mask, and owners, groups and every other right stay as they are", shown, machine,
|
||||
strings.Join(modules, ", "))
|
||||
if enablesACL {
|
||||
words += ". Its filesystem does not allow access entries yet (a ZFS dataset's acltype): approving sets that first"
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// ask is the take's ask, composed from it alone. The folder's path is said whole where the router's content rule
|
||||
// lets it leave the mesh, and otherwise named by the shared folder it is ("the shared folder movies"), which is the
|
||||
// access the module's wait names.
|
||||
func (t folderTake) ask(id string, machines []string) (asks.Ask, map[string]int) {
|
||||
shown := filepath.Clean(t.Path)
|
||||
if _, ok := outward.Check(shown, machines...); !ok {
|
||||
shown = "the shared folder " + strings.Join(t.Accesses, ", ")
|
||||
}
|
||||
headline := fmt.Sprintf("Let the mesh keep a shared folder on %s?", t.Node)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = "Let the mesh keep a shared folder?"
|
||||
}
|
||||
explanation := takeWords(shown, t.Node, t.Modules, t.EnablesACL) + "."
|
||||
if _, ok := outward.Check(explanation, machines...); !ok {
|
||||
explanation = sayable(explanation, machines)
|
||||
}
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: explanation,
|
||||
Who: asks.Operator, Expires: t.At.Add(askApproveFor),
|
||||
OnExpiry: "nothing is changed, and you are asked again while the folder waits", About: "take." + t.Node}
|
||||
options := map[string]int{}
|
||||
for i, act := range t.actions(id) {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Level: asks.Level(act.Level), Binds: binds,
|
||||
Does: "the mesh adds the access entries there, and keeps them from then on without asking"})
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
// takeOnWarrant performs a take the operator approved: the engine is asked, and the folder recorded taken on its
|
||||
// word. It answers what the engine said.
|
||||
type takeOnWarrant func(ctx context.Context, t folderTake, askID, by string) (string, error)
|
||||
|
||||
// askFolders asks the operator about every folder waiting to be kept that is not asked about yet, and takes back an
|
||||
// open ask about one that no longer waits. Called at the end of each reconcile, after the conditions' asks, within
|
||||
// the asks the router holds open at once.
|
||||
func (a *asker) askFolders(ctx context.Context) error {
|
||||
if a.folders == nil {
|
||||
return nil
|
||||
}
|
||||
wanted, err := a.folders(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now()
|
||||
all, err := a.store.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open, openNow := map[string]asked{}, 0
|
||||
for _, r := range all {
|
||||
if r.State != askOpen || !now.Before(r.Ask.Expires) {
|
||||
continue
|
||||
}
|
||||
openNow++
|
||||
if r.Take != nil {
|
||||
open[r.Take.key()] = r
|
||||
}
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, t := range wanted {
|
||||
want[t.key()] = true
|
||||
}
|
||||
for key, r := range open {
|
||||
if !want[key] {
|
||||
if err := a.cancel(ctx, r, "the folder no longer waits to be kept"); err != nil {
|
||||
return err
|
||||
}
|
||||
openNow--
|
||||
}
|
||||
}
|
||||
var machines []string
|
||||
if a.machines != nil {
|
||||
if machines, err = a.machines(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, t := range wanted {
|
||||
if _, asking := open[t.key()]; asking {
|
||||
continue // one ask per folder and machine (§8)
|
||||
}
|
||||
if openNow >= askMostOpen {
|
||||
return nil // asked when one of the open ones ends
|
||||
}
|
||||
t.At = now
|
||||
id := newAskID()
|
||||
q, options := t.ask(id, machines)
|
||||
if err := q.Check(now); err != nil {
|
||||
a.logf("the operator could not be asked to let the mesh keep a folder on %s: %v", t.Node, err)
|
||||
continue
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
take := t
|
||||
if err := a.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: t.actions(id),
|
||||
Options: options, State: askOpen, Opened: now, Take: &take}); err != nil {
|
||||
return fmt.Errorf("the take could not be kept in the controller's asks, so it was not asked: %w", err)
|
||||
}
|
||||
if err := a.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil {
|
||||
_, _ = a.store.Change(ctx, id, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = askUnsent, a.now(), "nothing: it could not be published: "+err.Error()
|
||||
return true
|
||||
})
|
||||
a.logf("the operator could not be asked to let the mesh keep a folder on %s: %v", t.Node, err)
|
||||
continue
|
||||
}
|
||||
openNow++
|
||||
a.logf("asked the operator to let the mesh keep a folder on %s for %s (%s)", t.Node,
|
||||
strings.Join(t.Modules, ", "), id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decideTake is what the asker does with a warrant for a take (asker.Decided): the act only when it is the one the
|
||||
// option bound — composed again from the record, the users' digest among it — and then the engine is asked.
|
||||
func (a *asker) decideTake(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) {
|
||||
t := *r.Take
|
||||
recomposed := t.actions(r.ID)
|
||||
option, offered := r.Ask.Option(w.Option)
|
||||
if !offered || len(recomposed) != 1 {
|
||||
return "", fmt.Errorf("the take %s offers no answer %q: nothing is taken", r.ID, w.Option)
|
||||
}
|
||||
if err := option.Performs(boundAct(recomposed[0])); err != nil {
|
||||
return "", fmt.Errorf("the take kept for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
if err := option.Performs(boundAct(act)); err != nil {
|
||||
return "", fmt.Errorf("the act chosen for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
if a.take == nil {
|
||||
return "", errors.New("this controller cannot take a folder on a warrant")
|
||||
}
|
||||
// **The folder must still wait on the filesystem the operator was asked about** (the review of e1c2b744): a take
|
||||
// is about no condition, so nothing else rechecks it. A folder that moved to another filesystem, or no longer
|
||||
// waits for the operator's word, is not taken: the ask is spent, and one for the filesystem it is on now is asked.
|
||||
if a.folders != nil {
|
||||
waiting, err := a.folders(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("whether the folder still waits on %s could not be read, so nothing is taken: %w",
|
||||
t.Node, err)
|
||||
}
|
||||
still := false
|
||||
for _, now := range waiting {
|
||||
still = still || now.key() == t.key()
|
||||
}
|
||||
if !still {
|
||||
return "nothing: the folder no longer waits to be kept on the filesystem the operator was asked about " +
|
||||
"(it moved, or was taken or released meanwhile); asked again where it waits now", nil
|
||||
}
|
||||
}
|
||||
by := fmt.Sprintf("approved by %s via %s at %s (ask %s)", byWords(w), viaWords(w),
|
||||
w.At.Local().Format("2006-01-02 15:04"), r.ID)
|
||||
return a.take(ctx, t, r.ID, by)
|
||||
}
|
||||
|
||||
// takeFolderIn is takeOnWarrant on this controller's stores and bus: the engine is asked with the mesh's key, naming the
|
||||
// ask the warrant answered; on its word that it started, the folder is recorded taken. A refusal records nothing.
|
||||
func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error)) takeOnWarrant {
|
||||
return func(ctx context.Context, t folderTake, askID, by string) (string, error) {
|
||||
// Who asked, as one short line the engine takes (at most link.MaxHandOverBy): the ask names the rest.
|
||||
short := by
|
||||
if len(short) > 180 {
|
||||
short = "the operator, on the warrant for ask " + askID
|
||||
}
|
||||
answer, err := ask(ctx, link.AccessActAsk{Node: t.Node, Path: filepath.Clean(t.Path), By: short,
|
||||
Access: t.Access, Warrant: askID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if answer.Refused != "" {
|
||||
return "", fmt.Errorf("%s refused to take the folder: %s", t.Node, answer.Refused)
|
||||
}
|
||||
if err := open.inventory.RecordTaken(ctx, t.Node, inventory.TakenFolder{Path: t.Path, By: by, Ask: askID,
|
||||
Warrant: askID, Said: answer.Said, Placed: t.Placed}); err != nil {
|
||||
return "", fmt.Errorf("%s started the take (%s), and it could NOT be recorded: %w", t.Node, answer.Said, err)
|
||||
}
|
||||
return fmt.Sprintf("%s takes the folder for %s: %s", t.Node, strings.Join(t.Modules, ", "), answer.Said), nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitingFolder is one access wait excused for a folder not yet taken, as foldersWaiting gathers them.
|
||||
type waitingFolder struct {
|
||||
module, access string
|
||||
// resource is the access's resource id on the machine, as the engine stated it; placed the filesystem its folder
|
||||
// is on, without its path.
|
||||
resource, placed string
|
||||
enablesACL bool
|
||||
}
|
||||
|
||||
// foldersWaiting is every folder waiting to be kept across the mesh (ADR 0325 §7, §8): from each machine's newest
|
||||
// statement, each access wait of a module that is excused (checkWaiting) and waits for the operator's word rather
|
||||
// than for a disk, grouped by machine and folder.
|
||||
func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[string]inventory.NodeHealth) (
|
||||
map[string]map[string][]waitingFolder, operatorWaitFacts) {
|
||||
var f operatorWaitFacts
|
||||
out := map[string]map[string][]waitingFolder{}
|
||||
machines := make([]string, 0, len(healths))
|
||||
for m := range healths {
|
||||
machines = append(machines, m)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
for _, machine := range machines {
|
||||
rs := healths[machine].Resources
|
||||
var modules []string
|
||||
for _, r := range rs {
|
||||
for _, w := range r.Waits {
|
||||
if r.State == link.StateWaiting && w.Access != "" && !w.Disk {
|
||||
modules = append(modules, r.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(modules) == 0 {
|
||||
continue
|
||||
}
|
||||
readWaitFacts(ctx, inv, machine, modules, nil, &f)
|
||||
for _, r := range checkWaiting(machine, rs, f) {
|
||||
if r.State != link.StateWaiting {
|
||||
continue
|
||||
}
|
||||
for _, w := range r.Waits {
|
||||
if w.Access == "" || w.Disk {
|
||||
continue
|
||||
}
|
||||
path, placed := f.placed[r.Module+"@"+machine][w.Access]
|
||||
if !placed {
|
||||
continue
|
||||
}
|
||||
if out[machine] == nil {
|
||||
out[machine] = map[string][]waitingFolder{}
|
||||
}
|
||||
out[machine][path] = append(out[machine][path], waitingFolder{module: r.Module, access: w.Access,
|
||||
resource: r.Resource, placed: w.Placed, enablesACL: w.ACL == link.ACLNeedsEnabling})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, f
|
||||
}
|
||||
|
||||
// foldersIn is the asker's folders on this controller's stores: every folder waiting to be kept, each with the
|
||||
// modules waiting on it and the access the take names (the first waiting module's, by its resource id). Who the
|
||||
// take adds entries for is what the engine was declared for that folder: the ask names the modules.
|
||||
func foldersIn(open *stores) func(ctx context.Context) ([]folderTake, error) {
|
||||
return func(ctx context.Context) ([]folderTake, error) {
|
||||
healths, err := open.inventory.Healths(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
waiting, _ := foldersWaiting(ctx, open.inventory, healths)
|
||||
return takesOf(waiting), nil
|
||||
}
|
||||
}
|
||||
|
||||
// takesOf is one take per machine and folder from the waits gathered, sorted.
|
||||
func takesOf(waiting map[string]map[string][]waitingFolder) []folderTake {
|
||||
var out []folderTake
|
||||
for machine, folders := range waiting {
|
||||
for path, all := range folders {
|
||||
// One take per filesystem the waits name: they agree but for a moment while a disk is swapped.
|
||||
byPlaced := map[string][]waitingFolder{}
|
||||
for _, w := range all {
|
||||
byPlaced[w.placed] = append(byPlaced[w.placed], w)
|
||||
}
|
||||
for placed, ws := range byPlaced {
|
||||
t := folderTake{Node: machine, Path: path, Placed: placed}
|
||||
seenM, seenA := map[string]bool{}, map[string]bool{}
|
||||
var resources []string
|
||||
for _, w := range ws {
|
||||
if !seenM[w.module] {
|
||||
seenM[w.module] = true
|
||||
t.Modules = append(t.Modules, w.module)
|
||||
}
|
||||
if !seenA[w.access] {
|
||||
seenA[w.access] = true
|
||||
t.Accesses = append(t.Accesses, w.access)
|
||||
}
|
||||
resources = append(resources, w.resource)
|
||||
t.EnablesACL = t.EnablesACL || w.enablesACL
|
||||
}
|
||||
sort.Strings(t.Modules)
|
||||
sort.Strings(t.Accesses)
|
||||
sort.Strings(resources)
|
||||
t.Access = resources[0]
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0325 §7 and §8 on the controller's side: an access wait is excused only for a declared, placed folder
|
||||
// not yet taken, or one whose disk is not there; the gate passes it as a wait for a person and the operator reads it
|
||||
// as needs-operator in words with no path; the operator is asked once per folder and machine, never about a folder
|
||||
// taken; and the folder is taken only on the warrant.
|
||||
|
||||
var radarrManifest = catalogue.Manifest{Module: "radarr", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}}}
|
||||
|
||||
var plexManifest = catalogue.Manifest{Module: "plex", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "films", Path: "/services/media/movies", Mode: catalogue.AccessRead}}}
|
||||
|
||||
const moviesAt = "/storage/media/movies"
|
||||
|
||||
func keepWait(access string) inventory.Wait {
|
||||
return inventory.Wait{Part: "radarr", Access: access, Reason: link.WaitKeep,
|
||||
What: "let the mesh keep the shared folder " + access}
|
||||
}
|
||||
|
||||
func placedWait(access, placed string) inventory.Wait {
|
||||
w := keepWait(access)
|
||||
w.Placed = placed
|
||||
return w
|
||||
}
|
||||
|
||||
func aclWait(access, acl string) inventory.Wait {
|
||||
w := keepWait(access)
|
||||
w.ACL = acl
|
||||
return w
|
||||
}
|
||||
|
||||
func diskWait(access string) inventory.Wait {
|
||||
return inventory.Wait{Part: "radarr", Access: access, Disk: true, Reason: link.WaitDisk,
|
||||
What: "mount the disk that holds " + access}
|
||||
}
|
||||
|
||||
// accessFacts is the facts with each folder given taken on the filesystem fs-000000000001.
|
||||
func accessFacts(taken ...string) operatorWaitFacts {
|
||||
kept := map[string]string{}
|
||||
for _, p := range taken {
|
||||
kept[p] = "fs-000000000001"
|
||||
}
|
||||
return operatorWaitFacts{
|
||||
manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest},
|
||||
given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}},
|
||||
placed: map[string]map[string]string{"radarr@ace": {"movies": moviesAt}, "plex@ace": {"films": moviesAt},
|
||||
"radarr@novox": {}},
|
||||
taken: map[string]map[string]string{"ace": kept},
|
||||
}
|
||||
}
|
||||
|
||||
// Row 7: excused for a declared, placed folder not yet taken, and for an absent folder whose disk is not there (taken
|
||||
// or not); judged unhealthy when undeclared, unplaced, already taken, or naming a secret beside it. That a folder is
|
||||
// root's is the engine's to see: it states that unhealthy and never waiting (§12).
|
||||
func TestAnAccessWaitIsExcusedOnlyForAFolderTheMeshMayNotKeepYet(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
machine string
|
||||
w inventory.Wait
|
||||
f operatorWaitFacts
|
||||
says string
|
||||
}{
|
||||
{"declared, placed, not taken", "ace", keepWait("movies"), accessFacts(), ""},
|
||||
{"its disk not there", "ace", diskWait("movies"), accessFacts(), ""},
|
||||
{"its disk not there, the folder taken before", "ace", diskWait("movies"), accessFacts(moviesAt), ""},
|
||||
{"already taken", "ace", keepWait("movies"), accessFacts(moviesAt), "taken on ace already"},
|
||||
{"already taken on the filesystem it is on", "ace", placedWait("movies", "fs-000000000001"),
|
||||
accessFacts(moviesAt), "taken on ace already"},
|
||||
{"taken on another filesystem: it moved, and is asked about again", "ace",
|
||||
placedWait("movies", "fs-0000000000ff"), accessFacts(moviesAt), ""},
|
||||
{"undeclared", "ace", keepWait("books"), accessFacts(), "does not declare"},
|
||||
{"unplaced", "novox", keepWait("movies"), accessFacts(), "not placed on novox"},
|
||||
{"taken folders unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: accessFacts().manifests,
|
||||
placed: accessFacts().placed}, "could not be read"},
|
||||
{"its filesystem needs ACLs enabled: asked", "ace", aclWait("movies", link.ACLNeedsEnabling), accessFacts(), ""},
|
||||
{"its filesystem cannot carry ACLs: not asked", "ace", aclWait("movies", link.ACLUnsupported), accessFacts(),
|
||||
"cannot carry access entries"},
|
||||
{"a reason the controller does not know", "ace", inventory.Wait{Part: "radarr", Access: "movies",
|
||||
Reason: "later", What: "w"}, accessFacts(), "waits for the operator's word to keep its folder or for its disk"},
|
||||
{"an access and a secret", "ace", inventory.Wait{Part: "p", Access: "movies", Secret: "x", What: "w"},
|
||||
accessFacts(), "one secret or one setting, or one access"},
|
||||
} {
|
||||
err := checkWait("radarr", c.machine, c.w, c.f)
|
||||
switch {
|
||||
case c.says == "" && err != nil:
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)):
|
||||
t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func accessWaiting(module, access string, w inventory.Wait) inventory.ResourceHealth {
|
||||
return inventory.ResourceHealth{Module: module, Resource: module + "." + catalogue.AccessID("/services/media/movies"),
|
||||
Kind: "access", Target: moviesAt, State: link.StateWaiting, Waits: []inventory.Wait{w}}
|
||||
}
|
||||
|
||||
// Row 7, the gate and the condition: both excused waits pass the first-node gate as a wait for a person, and the
|
||||
// module is said as needs-operator — in words that carry no path.
|
||||
func TestAnAccessWaitPassesTheGateAndAsksForTheOperatorWithoutAPath(t *testing.T) {
|
||||
now := time.Now()
|
||||
for _, w := range []inventory.Wait{keepWait("movies"), diskWait("movies")} {
|
||||
w.What += " at " + moviesAt // an engine's words carrying a path never reach the operator's
|
||||
f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{accessWaiting("radarr", "movies", w)}}}}
|
||||
h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f)
|
||||
if h != healthPerson || !strings.Contains(why, "waits for the operator") {
|
||||
t.Fatalf("an excused access wait reads %v %q; want a wait for a person", h, why)
|
||||
}
|
||||
if strings.Contains(why, "/") {
|
||||
t.Errorf("the gate's verdict carries a path: %q", why)
|
||||
}
|
||||
o := needsOperatorObservation("radarr", "ace", []inventory.Wait{w}, nil)
|
||||
if o.Kind != kindNeedsOperator {
|
||||
t.Fatalf("raised as %s", o.Kind)
|
||||
}
|
||||
for _, words := range []string{o.Summary, o.Headline, o.Explanation, o.Needs} {
|
||||
if strings.Contains(words, "/") {
|
||||
t.Errorf("the operator reads a path: %q", words)
|
||||
}
|
||||
}
|
||||
want := "let the mesh keep the shared folder movies on ace when you are asked"
|
||||
if w.Disk {
|
||||
want = "mount the disk that holds movies on ace"
|
||||
}
|
||||
if !strings.Contains(o.Needs, want) {
|
||||
t.Errorf("needs-operator asks %q; want %q", o.Needs, want)
|
||||
}
|
||||
}
|
||||
// Taken already: not excused, and the gate does not pass it.
|
||||
f := gateFacts{now: now, waits: accessFacts(moviesAt), groupsAdded: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{accessWaiting("radarr", "movies", keepWait("movies"))}}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet ||
|
||||
!strings.Contains(why, "taken") {
|
||||
t.Fatalf("a wait on a taken folder reads %v %q", h, why)
|
||||
}
|
||||
}
|
||||
|
||||
// waitingOn is foldersWaiting's gathering for one folder: the given modules waiting on it by their access.
|
||||
func waitingOn(machine string, ws ...waitingFolder) map[string]map[string][]waitingFolder {
|
||||
return map[string]map[string][]waitingFolder{machine: {moviesAt: ws}}
|
||||
}
|
||||
|
||||
func takeRig(t *testing.T, waiting func() map[string]map[string][]waitingFolder) (*askerRig, *[]string) {
|
||||
r := newAskerRig(t)
|
||||
var taken []string
|
||||
r.a.folders = func(context.Context) ([]folderTake, error) { return takesOf(waiting()), nil }
|
||||
r.a.machines = func(context.Context) ([]string, error) { return []string{"ace", "novox"}, nil }
|
||||
r.a.take = func(_ context.Context, ft folderTake, askID, by string) (string, error) {
|
||||
taken = append(taken, ft.Node+" "+ft.Path+" "+ft.Access+" "+askID+" "+by)
|
||||
return "taking", nil
|
||||
}
|
||||
return r, &taken
|
||||
}
|
||||
|
||||
func takeAsks(r *askerRig) []asked {
|
||||
var out []asked
|
||||
for _, a := range r.store {
|
||||
if a.Take != nil {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Row 8, the controller's part: one ask per folder and machine however many modules wait on it, in the clause's
|
||||
// words; none again while it is open; taken back when the folder no longer waits; none for a folder taken (its waits
|
||||
// are not excused, so it is never gathered).
|
||||
func TestTheOperatorIsAskedOncePerFolderAndMachine(t *testing.T) {
|
||||
both := waitingOn("ace",
|
||||
waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-services/media/movies"},
|
||||
waitingFolder{module: "plex", access: "films", resource: "plex.access-services/media/movies"})
|
||||
current := both
|
||||
r, _ := takeRig(t, func() map[string]map[string][]waitingFolder { return current })
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
kept := takeAsks(r)
|
||||
if len(kept) != 1 {
|
||||
t.Fatalf("%d asks for one folder on one machine", len(kept))
|
||||
}
|
||||
q := kept[0].Ask
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Highest() != asks.Approve || len(q.Options) != 1 {
|
||||
t.Fatalf("the ask offers %+v; want one answer at approve", q.Options)
|
||||
}
|
||||
// The path may not leave the mesh (the router's content rule): the folder is named by the shared folder it is.
|
||||
want := "Let the mesh keep the shared folder films, movies on ace, now for plex, radarr: it adds their access " +
|
||||
"entries, and those of any module given access later, to every folder and file there; the group bits `ls -l` " +
|
||||
"shows become the entries' mask, and owners, groups and every other right stay as they are."
|
||||
if q.Explanation != want {
|
||||
t.Errorf("the ask says\n %q\nwant\n %q", q.Explanation, want)
|
||||
}
|
||||
if kept[0].Take.Access != "plex.access-services/media/movies" {
|
||||
t.Errorf("the take names %q", kept[0].Take.Access)
|
||||
}
|
||||
|
||||
// The folder no longer waits (taken, or no module uses it): the open ask is taken back, and nothing is asked.
|
||||
current = nil
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := takeAsks(r); len(got) != 1 || got[0].State != askCancelled {
|
||||
t.Fatalf("the ask about a folder that no longer waits: %+v", got)
|
||||
}
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(takeAsks(r)) != 1 {
|
||||
t.Fatal("asked about a folder nothing waits on")
|
||||
}
|
||||
// A module joining a folder already taken is never gathered: its wait is not excused.
|
||||
f := accessFacts(moviesAt)
|
||||
if err := checkWait("radarr", "ace", keepWait("movies"), f); err == nil {
|
||||
t.Fatal("a wait on a taken folder was excused, and would have been asked about")
|
||||
}
|
||||
}
|
||||
|
||||
// Row 8: nothing is taken without the operator's warrant — an ask that ends without a choice, a warrant for another
|
||||
// ask, or an act other than the one the option bound takes nothing; the warrant takes it once.
|
||||
func TestAFolderIsTakenOnlyOnTheWarrant(t *testing.T) {
|
||||
r, taken := takeRig(t, func() map[string]map[string][]waitingFolder {
|
||||
return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"})
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := takeAsks(r)[0]
|
||||
if len(*taken) != 0 {
|
||||
t.Fatal("taken before any warrant")
|
||||
}
|
||||
// A warrant for another asker's ask.
|
||||
other := warrantOn(a, "let-the-mesh-keep-it", r.now)
|
||||
other.Asker = "someone-else"
|
||||
answerWith(t, r, other)
|
||||
// The record changed after the ask: the act no longer the one bound.
|
||||
changed := r.store[a.ID]
|
||||
changed.Take = &folderTake{Node: "ace", Path: "/", Access: "radarr.access-movies", Modules: a.Take.Modules}
|
||||
r.store[a.ID] = changed
|
||||
answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now))
|
||||
if len(*taken) != 0 || !strings.HasPrefix(r.store[a.ID].Acted, "failed") {
|
||||
t.Fatalf("a take whose record changed after the ask: %v, %q", *taken, r.store[a.ID].Acted)
|
||||
}
|
||||
|
||||
// Asked afresh, and the ask expires unanswered: nothing.
|
||||
r2, taken2 := takeRig(t, func() map[string]map[string][]waitingFolder {
|
||||
return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"})
|
||||
})
|
||||
if err := r2.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := takeAsks(r2)[0]
|
||||
expired := asks.Warrant{Ask: b.ID, Asker: "mesh-controller", Outcome: asks.OutcomeExpired, At: r2.now}
|
||||
answerWith(t, r2, expired)
|
||||
if len(*taken2) != 0 {
|
||||
t.Fatal("taken on an ask that expired")
|
||||
}
|
||||
|
||||
// The warrant: taken once, naming the access, the ask as the warrant, and who approved.
|
||||
r3, taken3 := takeRig(t, func() map[string]map[string][]waitingFolder {
|
||||
return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies"})
|
||||
})
|
||||
if err := r3.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := takeAsks(r3)[0]
|
||||
w := warrantOn(c, "let-the-mesh-keep-it", r3.now)
|
||||
answerWith(t, r3, w)
|
||||
answerWith(t, r3, w)
|
||||
if len(*taken3) != 1 || !strings.Contains((*taken3)[0], "ace "+moviesAt+" radarr.access-movies "+c.ID) ||
|
||||
!strings.Contains((*taken3)[0], "telegram") {
|
||||
t.Fatalf("the warrant took %v", *taken3)
|
||||
}
|
||||
if !strings.HasPrefix(r3.store[c.ID].Acted, "done") {
|
||||
t.Fatalf("the take was recorded %q", r3.store[c.ID].Acted)
|
||||
}
|
||||
}
|
||||
|
||||
// The engine is asked only on a warrant, and the ask carries it: one with no warrant is never signed.
|
||||
func TestATakeAskCarriesItsWarrantAndIsSigned(t *testing.T) {
|
||||
_, key, _ := ed25519.GenerateKey(nil)
|
||||
signer := keySigner{key}
|
||||
ask := link.AccessActAsk{Node: "ace", Path: moviesAt, By: "the operator", Access: "radarr.access-services/media/movies"}
|
||||
if _, err := link.SignTake(context.Background(), signer, ask); err == nil {
|
||||
t.Fatal("a take with no warrant was signed")
|
||||
}
|
||||
ask.Warrant = "c0123456789abcdef"
|
||||
body, err := link.SignTake(context.Background(), signer, ask)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var signed link.SignedHandOver
|
||||
if err := json.Unmarshal(body, &signed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ed25519.Verify(key.Public().(ed25519.PublicKey), append([]byte(link.AccessActContext), signed.Ask...), signed.Signature) {
|
||||
t.Fatal("the take is not signed over its own context")
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(signed.Ask, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["act"] != "take" || got["warrant"] != "c0123456789abcdef" || got["access"] != ask.Access ||
|
||||
got["path"] != moviesAt || got["nonce"] == "" {
|
||||
t.Fatalf("the ask carries %v", got)
|
||||
}
|
||||
// And the controller records it taken only on the engine's word.
|
||||
refusing := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
return link.HandOverAnswer{Refused: "the disk that holds the folder is not there"}, nil
|
||||
})
|
||||
if _, err := refusing(context.Background(), folderTake{Node: "ace", Path: moviesAt}, "c01", "the operator"); err == nil {
|
||||
t.Fatal("a refused take was answered as done")
|
||||
}
|
||||
unreachable := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
return link.HandOverAnswer{}, errors.New("nothing on ace answers")
|
||||
})
|
||||
if _, err := unreachable(context.Background(), folderTake{Node: "ace", Path: moviesAt}, "c01", "the operator"); err == nil {
|
||||
t.Fatal("an unanswered take was answered as done")
|
||||
}
|
||||
}
|
||||
|
||||
type keySigner struct{ key ed25519.PrivateKey }
|
||||
|
||||
func (s keySigner) Sign(_ context.Context, b []byte) ([]byte, error) {
|
||||
return ed25519.Sign(s.key, b), nil
|
||||
}
|
||||
|
||||
// The envelope carries `identities` only when composed — for an engine at link.IdentityContract — and an older
|
||||
// engine's body is what it was.
|
||||
func TestTheDeclarationSaysItsIdentitiesOnlyWhenComposed(t *testing.T) {
|
||||
older, err := sendable{Resources: []map[string]any{{"id": "a"}}}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(older), "identities") {
|
||||
t.Fatalf("an older engine's body names identities: %s", older)
|
||||
}
|
||||
newer, err := sendable{Resources: []map[string]any{{"id": "a"}}, Identities: []map[string]any{
|
||||
{"module": "radarr", "name": "mesh-radarr", "uid": 50000, "gid": 50000}}}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(newer), `"identities":[{"gid":50000,"module":"radarr","name":"mesh-radarr","uid":50000}]`) {
|
||||
t.Fatalf("the body: %s", newer)
|
||||
}
|
||||
if link.IdentityContract != 5 {
|
||||
t.Fatalf("the contract the engine states for ADR 0325 is 5, and this controller reads %d", link.IdentityContract)
|
||||
}
|
||||
}
|
||||
|
||||
// A folder that moved to another filesystem is asked about again: one ask per folder, machine and filesystem, and the
|
||||
// take records the filesystem it was for.
|
||||
func TestAFolderThatMovedIsAskedAboutAgain(t *testing.T) {
|
||||
placed := "fs-000000000001"
|
||||
r, taken := takeRig(t, func() map[string]map[string][]waitingFolder {
|
||||
return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies",
|
||||
placed: placed})
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placed = "fs-0000000000ff"
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var open []asked
|
||||
for _, a := range takeAsks(r) {
|
||||
if a.State == askOpen {
|
||||
open = append(open, a)
|
||||
}
|
||||
}
|
||||
if len(open) != 1 || open[0].Take.Placed != "fs-0000000000ff" || len(takeAsks(r)) != 2 {
|
||||
t.Fatalf("after the folder moved: %d asks, open %+v", len(takeAsks(r)), open)
|
||||
}
|
||||
answerWith(t, r, warrantOn(open[0], "let-the-mesh-keep-it", r.now))
|
||||
if len(*taken) != 1 {
|
||||
t.Fatalf("taken %v", *taken)
|
||||
}
|
||||
}
|
||||
|
||||
// The mesh's entries left while a disk was away (kind kept-entries) do not hold a delivery whose move leaves the
|
||||
// module's accesses as they were — they may name an access no longer declared; one that changes them is held.
|
||||
func TestEntriesLeftWhileADiskWasAwayDoNotHoldADeliveryThatDidNotBringThem(t *testing.T) {
|
||||
now := time.Now()
|
||||
since := now.Add(-time.Minute)
|
||||
left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries,
|
||||
Target: moviesAt, State: link.StateUnhealthy, Reason: "the mesh's entries could not be taken out: its disk is away",
|
||||
Since: since.Add(-time.Hour)}
|
||||
f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: map[string]bool{"radarr": false}, health: map[string]inventory.NodeHealth{"ace": {Node: "ace",
|
||||
HeardAt: now, Resources: []inventory.ResourceHealth{left}}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", since, f); h != healthPerson || !strings.Contains(why, "access-gone~entries") {
|
||||
t.Fatalf("entries left before the send read %v %q; want carried in a passing verdict", h, why)
|
||||
}
|
||||
left.Since = now
|
||||
f.health["ace"] = inventory.NodeHealth{Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{left}}
|
||||
if h, _ := moduleHealthWord("radarr", "ace", since, f); h != healthPerson {
|
||||
t.Fatalf("entries stated since the send by a move that leaves accesses as they were read %v", h)
|
||||
}
|
||||
f.accessesChanged = nil // a move not known changes them
|
||||
if h, _ := moduleHealthWord("radarr", "ace", since, f); h != healthNotYet {
|
||||
t.Fatalf("entries of a move not known read %v; want judged unhealthy", h)
|
||||
}
|
||||
}
|
||||
|
||||
// An access the engine reports unknown (its filesystem could not be named this look) carries no wait: of a module
|
||||
// whose move leaves its accesses as they were it is pre-existing and not counted; of one whose move changes them, or
|
||||
// whose move is not known, it is not yet healthy — held to the gate's bound, never waited on for ever. Entries left
|
||||
// while a disk was away follow the same rule whenever they were stated.
|
||||
func TestAnUnknownAccessAndLeftEntriesAreJudgedByWhetherTheMoveChangesAccesses(t *testing.T) {
|
||||
now := time.Now()
|
||||
since := now.Add(-time.Minute)
|
||||
unknown := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-movies", Kind: link.KindAccess,
|
||||
Target: moviesAt, State: link.StateUnknown, Since: now}
|
||||
left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries,
|
||||
Target: moviesAt, State: link.StateUnhealthy, Since: now}
|
||||
facts := func(changes map[string]bool, rs ...inventory.ResourceHealth) gateFacts {
|
||||
return gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: changes, health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: rs}}}
|
||||
}
|
||||
unchanged := map[string]bool{"radarr": false}
|
||||
if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, unknown)); h != healthGood {
|
||||
t.Errorf("an unknown access of a move that leaves accesses as they were reads %v %q; want not counted", h, why)
|
||||
}
|
||||
for name, changes := range map[string]map[string]bool{"changes them": {"radarr": true}, "not known": nil} {
|
||||
if h, _ := moduleHealthWord("radarr", "ace", since, facts(changes, unknown)); h != healthNotYet {
|
||||
t.Errorf("an unknown access of a move that %s reads %v; want not yet, held to the bound", name, h)
|
||||
}
|
||||
}
|
||||
if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, left)); h != healthPerson ||
|
||||
!strings.Contains(why, "access-gone~entries") {
|
||||
t.Errorf("entries left, a move that leaves accesses as they were: %v %q; want carried", h, why)
|
||||
}
|
||||
if h, _ := moduleHealthWord("radarr", "ace", since, facts(map[string]bool{"radarr": true}, left)); h != healthNotYet {
|
||||
t.Errorf("entries left since the send by a move that changes accesses read %v", h)
|
||||
}
|
||||
if !sameAccesses(radarrManifest.Accesses, []catalogue.Access{{ID: "movies", Path: "/services/media/movies",
|
||||
Mode: catalogue.AccessReadWrite}}) || sameAccesses(radarrManifest.Accesses, plexManifest.Accesses) {
|
||||
t.Error("sameAccesses does not tell a changed access from an unchanged one")
|
||||
}
|
||||
}
|
||||
|
||||
// A take whose folder's filesystem must first allow access entries says so in the ask (ADR 0325 §8).
|
||||
func TestTheAskSaysWhenTheTakeEnablesACLs(t *testing.T) {
|
||||
r, _ := takeRig(t, func() map[string]map[string][]waitingFolder {
|
||||
return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies",
|
||||
enablesACL: true})
|
||||
})
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := takeAsks(r)[0].Ask
|
||||
if !strings.Contains(q.Explanation, "does not allow access entries yet (a ZFS dataset's acltype): approving sets that first") {
|
||||
t.Fatalf("the ask says %q", q.Explanation)
|
||||
}
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's access waits are kept by their reason, never by their words, and a kept folder's entries are shown.
|
||||
func TestTheEnginesAccessWaitsAndEntriesAreKept(t *testing.T) {
|
||||
lines := healthLines(inventory.NodeHealth{Node: "ace", HeardAt: time.Now(), Resources: []inventory.ResourceHealth{{
|
||||
Module: "radarr", Resource: "radarr.access-movies", Kind: link.KindAccess, Target: moviesAt,
|
||||
State: link.StateHealthy, Entries: []inventory.KeptEntry{{Identity: "mesh-radarr", UID: 50000, Use: "read-write",
|
||||
From: "container"}, {Identity: "jochen", UID: 1000, Use: "read-write", From: "tool-runner",
|
||||
Note: "the tool runner's account stands for every module whose tools use this folder"}}}}}, true, time.Now())
|
||||
all := strings.Join(lines, "\n")
|
||||
if !strings.Contains(all, "entry mesh-radarr (uid 50000, read-write, from the container)") ||
|
||||
!strings.Contains(all, "entry jochen (uid 1000, read-write, from the tool-runner) — the tool runner's account") {
|
||||
t.Fatalf("the listing reads:\n%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
// Review of e1c2b744: a warrant for a folder on one filesystem never takes it on another. When the warrant comes and
|
||||
// the folder no longer waits on the filesystem the ask named, nothing is sent, the ask is spent, and the operator is
|
||||
// asked again for the filesystem it is on now.
|
||||
func TestAWarrantForOneFilesystemIsNotCarriedOutOnAnother(t *testing.T) {
|
||||
placed := "fs-000000000001"
|
||||
r, taken := takeRig(t, func() map[string]map[string][]waitingFolder {
|
||||
return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies",
|
||||
placed: placed})
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := takeAsks(r)[0]
|
||||
placed = "fs-0000000000ff" // the folder moved before the operator answered
|
||||
answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now))
|
||||
if len(*taken) != 0 {
|
||||
t.Fatalf("a warrant for %s was carried out on %s: %v", a.Take.Placed, placed, *taken)
|
||||
}
|
||||
if spent := r.store[a.ID]; spent.State == askOpen || !strings.HasPrefix(spent.Acted, "nothing") {
|
||||
t.Fatalf("the ask after the folder moved: %s, %q", spent.State, spent.Acted)
|
||||
}
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var again []asked
|
||||
for _, x := range takeAsks(r) {
|
||||
if x.State == askOpen {
|
||||
again = append(again, x)
|
||||
}
|
||||
}
|
||||
if len(again) != 1 || again[0].Take.Placed != placed {
|
||||
t.Fatalf("not asked again for the filesystem the folder is on now: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// Review of e1c2b744: a move that changes who uses an access — a container of the module that starts declaring
|
||||
// identity — changes the access, so an access the engine cannot judge is not passed uncounted.
|
||||
func TestAMoveThatChangesAnAccesssUsersChangesTheAccess(t *testing.T) {
|
||||
before := catalogue.Manifest{Module: "radarr", Accesses: radarrManifest.Accesses, Resources: []map[string]any{
|
||||
{"id": "app", "type": "container", "volumes": []any{"${access:movies}:/movies"}}}}
|
||||
after := catalogue.Manifest{Module: "radarr", Accesses: radarrManifest.Accesses, Resources: []map[string]any{
|
||||
{"id": "app", "type": "container", "identity": map[string]any{"user": "run"},
|
||||
"volumes": []any{"${access:movies}:/movies"}}}}
|
||||
if sameAccessUse(before, after) {
|
||||
t.Fatal("a move that adds identity to the container mounting the access left it the same")
|
||||
}
|
||||
if !sameAccessUse(before, before) {
|
||||
t.Fatal("an unchanged manifest reads changed")
|
||||
}
|
||||
// Entries left while a disk was away, stated before the send, of a move that changes the access: counted.
|
||||
now := time.Now()
|
||||
since := now.Add(-time.Minute)
|
||||
left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-movies~entries", Kind: link.KindKeptEntries,
|
||||
Target: moviesAt, State: link.StateUnhealthy, Since: since.Add(-time.Hour)}
|
||||
f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: map[string]bool{"radarr": true}, health: map[string]inventory.NodeHealth{"ace": {Node: "ace",
|
||||
HeardAt: now, Resources: []inventory.ResourceHealth{left}}}}
|
||||
if h, _ := moduleHealthWord("radarr", "ace", since, f); h != healthNotYet {
|
||||
t.Fatalf("entries left before the send by a move that changes the access read %v; want counted", h)
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package main
|
||||
|
||||
// Existing data joins a library once, on the operator's word (novox/hq ADR 0328 §7, §8).
|
||||
//
|
||||
// **What waits.** A node-engine that keeps libraries states a library waiting, with an access wait of reason `keep`,
|
||||
// while its root on its home holds data not yet in its group; a module that uses it waits the same way. The
|
||||
// controller excuses such a wait only for a defined library that reaches the machine and is not yet taken on its home,
|
||||
// on the filesystem the wait names (checkLibraryWait), and the module is said as its needs-operator condition, in words
|
||||
// with no path.
|
||||
//
|
||||
// **One ask per library.** The serving controller's asker asks the operator once for each such library — however many
|
||||
// modules and machines wait on it — at the level approve (ADR 0259), in the clause's words: "Let the mesh keep library
|
||||
// <name> at <root> on <machine>: …". It is kept in the controller's asks beside a proposal (asked.Take), never a
|
||||
// condition's. While it is open no second is asked; one that expired unanswered is asked again while the library
|
||||
// waits; one about a library that no longer waits is taken back; one whose library moved to another filesystem is
|
||||
// asked again for the one it is on now. None is asked for a library already taken.
|
||||
//
|
||||
// **On the warrant, and only on it**, the controller asks the library's home engine to take it (link.AskTake), naming
|
||||
// "library:<name>" and the warrant — after checking the library still waits on the filesystem the operator was asked
|
||||
// about. On the engine's word that it started, the library is recorded taken (kept_library), and from the next send
|
||||
// the library says `taken`.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// libraryTake is one library the operator is asked to let the mesh take, as the controller's ask keeps it
|
||||
// (asked.Take). Every field the ask is composed from is here, so the act on the warrant is composed from the record.
|
||||
type libraryTake struct {
|
||||
Library string `json:"library"`
|
||||
// Node is the library's home, where it is taken; Root its root there.
|
||||
Node string `json:"node"`
|
||||
Root string `json:"root"`
|
||||
// Modules are the modules waiting on it, sorted.
|
||||
Modules []string `json:"modules"`
|
||||
// Placed is the filesystem the root is on, as the home's engine names it without a path: the warrant is for it.
|
||||
Placed string `json:"placed,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant.
|
||||
const takeVerb = askerName + ".take"
|
||||
|
||||
// key is the library on its home, at its root, on its filesystem: what one ask is kept for. A library that moved is
|
||||
// asked again.
|
||||
func (t libraryTake) key() string {
|
||||
return t.Library + "\x00" + t.Node + "\x00" + t.Root + "\x00" + t.Placed
|
||||
}
|
||||
|
||||
// digest binds everything the take does and was said for.
|
||||
func (t libraryTake) digest() string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
Library, Node, Root, Placed string
|
||||
Modules []string
|
||||
}{t.Library, t.Node, t.Root, t.Placed, t.Modules})
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// actions is the take's one answer, at the level approve, binding the exact act through boundAct.
|
||||
func (t libraryTake) actions(id string) []conditions.Action {
|
||||
return []conditions.Action{{Label: "Let the mesh keep it", Verb: takeVerb, Machine: t.Node,
|
||||
Level: conditions.LevelApprove, Arguments: map[string]string{"take": id, "library": t.Library,
|
||||
"node": t.Node, "access": libraryAccess + t.Library, "digest": t.digest()}}}
|
||||
}
|
||||
|
||||
// takeWords is the clause's ask (ADR 0328 §7), with the root as shown.
|
||||
func takeWords(library, shown, machine string) string {
|
||||
return fmt.Sprintf("Let the mesh keep library %s at %s on %s: its group becomes %s, the group may read and write, "+
|
||||
"folders keep the group; owners and everyone else's rights stay as they are", library, shown, machine,
|
||||
catalogue.MeshName(library))
|
||||
}
|
||||
|
||||
// ask is the take's ask, composed from it alone. The root is said whole where the router's content rule lets it leave
|
||||
// the mesh, and otherwise as "its root".
|
||||
func (t libraryTake) ask(id string, machines []string) (asks.Ask, map[string]int) {
|
||||
shown := t.Root
|
||||
if _, ok := outward.Check(shown, machines...); !ok {
|
||||
shown = "its root"
|
||||
}
|
||||
headline := fmt.Sprintf("Let the mesh keep library %s?", t.Library)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = "Let the mesh keep a library?"
|
||||
}
|
||||
explanation := takeWords(t.Library, shown, t.Node) + "."
|
||||
if len(t.Modules) > 0 {
|
||||
explanation += " Waiting on it: " + strings.Join(t.Modules, ", ") + "."
|
||||
}
|
||||
if _, ok := outward.Check(explanation, machines...); !ok {
|
||||
explanation = sayable(explanation, machines)
|
||||
}
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: explanation,
|
||||
Who: asks.Operator, Expires: t.At.Add(askApproveFor),
|
||||
OnExpiry: "nothing is changed, and you are asked again while the library waits", About: "take." + t.Library}
|
||||
options := map[string]int{}
|
||||
for i, act := range t.actions(id) {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Level: asks.Level(act.Level), Binds: binds,
|
||||
Does: "the library's data joins its group, and is kept from then on without asking"})
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
// takeOnWarrant performs a take the operator approved: the engine is asked, and the library recorded taken on its
|
||||
// word. It answers what the engine said.
|
||||
type takeOnWarrant func(ctx context.Context, t libraryTake, askID, by string) (string, error)
|
||||
|
||||
// askFolders asks the operator about every library waiting to be taken that is not asked about yet, and takes back an
|
||||
// open ask about one that no longer waits. Called at the end of each reconcile, after the conditions' asks, within the
|
||||
// asks the router holds open at once.
|
||||
func (a *asker) askFolders(ctx context.Context) error {
|
||||
if a.folders == nil {
|
||||
return nil
|
||||
}
|
||||
wanted, err := a.folders(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now()
|
||||
all, err := a.store.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open, openNow := map[string]asked{}, 0
|
||||
for _, r := range all {
|
||||
if r.State != askOpen || !now.Before(r.Ask.Expires) {
|
||||
continue
|
||||
}
|
||||
openNow++
|
||||
if r.Take != nil {
|
||||
open[r.Take.key()] = r
|
||||
}
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, t := range wanted {
|
||||
want[t.key()] = true
|
||||
}
|
||||
for key, r := range open {
|
||||
if !want[key] {
|
||||
if err := a.cancel(ctx, r, "the library no longer waits to be taken there"); err != nil {
|
||||
return err
|
||||
}
|
||||
openNow--
|
||||
}
|
||||
}
|
||||
var machines []string
|
||||
if a.machines != nil {
|
||||
if machines, err = a.machines(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, t := range wanted {
|
||||
if _, asking := open[t.key()]; asking {
|
||||
continue // one ask per library (§7)
|
||||
}
|
||||
if openNow >= askMostOpen {
|
||||
return nil // asked when one of the open ones ends
|
||||
}
|
||||
t.At = now
|
||||
id := newAskID()
|
||||
q, options := t.ask(id, machines)
|
||||
if err := q.Check(now); err != nil {
|
||||
a.logf("the operator could not be asked to let the mesh keep the library %s: %v", t.Library, err)
|
||||
continue
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
take := t
|
||||
if err := a.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: t.actions(id),
|
||||
Options: options, State: askOpen, Opened: now, Take: &take}); err != nil {
|
||||
return fmt.Errorf("the take could not be kept in the controller's asks, so it was not asked: %w", err)
|
||||
}
|
||||
if err := a.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil {
|
||||
_, _ = a.store.Change(ctx, id, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = askUnsent, a.now(), "nothing: it could not be published: "+err.Error()
|
||||
return true
|
||||
})
|
||||
a.logf("the operator could not be asked to let the mesh keep the library %s: %v", t.Library, err)
|
||||
continue
|
||||
}
|
||||
openNow++
|
||||
a.logf("asked the operator to let the mesh keep the library %s on %s (%s)", t.Library, t.Node, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decideTake is what the asker does with a warrant for a take (asker.Decided): the act only when it is the one the
|
||||
// option bound — composed again from the record — and the library still waits on the filesystem the operator was
|
||||
// asked about; then the engine is asked.
|
||||
func (a *asker) decideTake(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) {
|
||||
t := *r.Take
|
||||
recomposed := t.actions(r.ID)
|
||||
option, offered := r.Ask.Option(w.Option)
|
||||
if !offered || len(recomposed) != 1 {
|
||||
return "", fmt.Errorf("the take %s offers no answer %q: nothing is taken", r.ID, w.Option)
|
||||
}
|
||||
if err := option.Performs(boundAct(recomposed[0])); err != nil {
|
||||
return "", fmt.Errorf("the take kept for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
if err := option.Performs(boundAct(act)); err != nil {
|
||||
return "", fmt.Errorf("the act chosen for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
if a.take == nil {
|
||||
return "", errors.New("this controller cannot take a library on a warrant")
|
||||
}
|
||||
// **The library must still wait where the operator was asked about it** (the review of e1c2b744): a take is about
|
||||
// no condition, so nothing else rechecks it. A library that moved to another home or filesystem, or no longer
|
||||
// waits, is not taken: the ask is spent, and one for where it waits now is asked.
|
||||
if a.folders != nil {
|
||||
waiting, err := a.folders(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("whether the library %s still waits could not be read, so nothing is taken: %w",
|
||||
t.Library, err)
|
||||
}
|
||||
still := false
|
||||
for _, now := range waiting {
|
||||
still = still || now.key() == t.key()
|
||||
}
|
||||
if !still {
|
||||
return "nothing: the library no longer waits to be taken where the operator was asked about it (it " +
|
||||
"moved, or was taken meanwhile); asked again where it waits now", nil
|
||||
}
|
||||
}
|
||||
by := fmt.Sprintf("approved by %s via %s at %s (ask %s)", byWords(w), viaWords(w),
|
||||
w.At.Local().Format("2006-01-02 15:04"), r.ID)
|
||||
return a.take(ctx, t, r.ID, by)
|
||||
}
|
||||
|
||||
// takeFolderIn is takeOnWarrant on this controller's stores and bus: the home's engine is asked with the mesh's key,
|
||||
// naming the library and the ask the warrant answered; on its word that it started, the library is recorded taken. A
|
||||
// refusal records nothing.
|
||||
func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error)) takeOnWarrant {
|
||||
return func(ctx context.Context, t libraryTake, askID, by string) (string, error) {
|
||||
// Who asked, as one short line the engine takes (at most link.MaxHandOverBy): the ask names the rest.
|
||||
short := by
|
||||
if len(short) > 180 {
|
||||
short = "the operator, on the warrant for ask " + askID
|
||||
}
|
||||
answer, err := ask(ctx, link.AccessActAsk{Node: t.Node, Path: t.Root, By: short,
|
||||
Access: libraryAccess + t.Library, Warrant: askID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if answer.Refused != "" {
|
||||
return "", fmt.Errorf("%s refused to take the library %s: %s", t.Node, t.Library, answer.Refused)
|
||||
}
|
||||
// Started, not taken: the library is taken when the engine states the act done (stateHealth, TakeDone).
|
||||
if err := open.inventory.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: t.Library, Home: t.Node,
|
||||
Root: t.Root, Placed: t.Placed, By: by, Ask: askID, Warrant: askID, Said: answer.Said}); err != nil {
|
||||
return "", fmt.Errorf("%s started the take of %s (%s), and it could NOT be recorded: %w", t.Node, t.Library,
|
||||
answer.Said, err)
|
||||
}
|
||||
return fmt.Sprintf("%s started taking the library %s: %s; it is taken when the engine says the act is done",
|
||||
t.Node, t.Library, answer.Said), nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitingFolder is one library wait excused, as foldersWaiting gathers them.
|
||||
type waitingFolder struct {
|
||||
module, machine string
|
||||
// placed is the filesystem the library's root is on, as its home's engine names it without a path; empty from
|
||||
// another machine.
|
||||
placed string
|
||||
}
|
||||
|
||||
// foldersWaiting is every library waiting to be taken across the mesh (ADR 0328 §7, §8): from each machine's newest
|
||||
// statement, each wait of reason keep that is excused (checkWaiting), by library.
|
||||
func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[string]inventory.NodeHealth) (
|
||||
map[string][]waitingFolder, operatorWaitFacts) {
|
||||
var f operatorWaitFacts
|
||||
out := map[string][]waitingFolder{}
|
||||
machines := make([]string, 0, len(healths))
|
||||
for m := range healths {
|
||||
machines = append(machines, m)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
for _, machine := range machines {
|
||||
rs := healths[machine].Resources
|
||||
var modules []string
|
||||
for _, r := range rs {
|
||||
for _, w := range r.Waits {
|
||||
if r.State == link.StateWaiting && w.Access != "" && !w.Disk {
|
||||
modules = append(modules, r.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(modules) == 0 {
|
||||
continue
|
||||
}
|
||||
readWaitFacts(ctx, inv, machine, modules, nil, &f)
|
||||
for _, r := range checkWaiting(machine, rs, f) {
|
||||
if r.State != link.StateWaiting {
|
||||
continue
|
||||
}
|
||||
for _, w := range r.Waits {
|
||||
if w.Access == "" || w.Disk || w.Reason == link.WaitDisk {
|
||||
continue
|
||||
}
|
||||
lib, isLibrary := strings.CutPrefix(w.Access, libraryAccess)
|
||||
if !isLibrary {
|
||||
lib = catalogue.LibraryOfEntry(f.manifests[r.Module], w.Access)
|
||||
}
|
||||
if lib == "" {
|
||||
continue
|
||||
}
|
||||
placed := ""
|
||||
if l := f.libraries[lib]; l.Home == machine {
|
||||
placed = w.Placed
|
||||
}
|
||||
out[lib] = append(out[lib], waitingFolder{module: r.Module, machine: machine, placed: placed})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, f
|
||||
}
|
||||
|
||||
// foldersIn is the asker's folders on this controller's stores: every library waiting to be taken, with its home, its
|
||||
// root and the modules waiting on it.
|
||||
func foldersIn(open *stores) func(ctx context.Context) ([]libraryTake, error) {
|
||||
return func(ctx context.Context) ([]libraryTake, error) {
|
||||
healths, err := open.inventory.Healths(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
waiting, f := foldersWaiting(ctx, open.inventory, healths)
|
||||
return withoutTakesUnderWay(takesOf(waiting, f.libraries), f.taken), nil
|
||||
}
|
||||
}
|
||||
|
||||
// withoutTakesUnderWay is the takes not already under way: a take started on the same home, root and filesystem, which
|
||||
// the engine has not yet stated done or failed, is not asked about again (the review of 7ea6d36e, item 5).
|
||||
func withoutTakesUnderWay(takes []libraryTake, recorded map[string]inventory.TakenLibrary) []libraryTake {
|
||||
var out []libraryTake
|
||||
for _, t := range takes {
|
||||
r, was := recorded[t.Library]
|
||||
if was && !r.Done && r.Home == t.Node && r.Root == t.Root && (t.Placed == "" || r.Placed == "" || r.Placed == t.Placed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// takesOf is one take per library and filesystem from the waits gathered, sorted. A library no longer defined is
|
||||
// asked about nowhere.
|
||||
func takesOf(waiting map[string][]waitingFolder, libraries map[string]catalogue.Library) []libraryTake {
|
||||
var out []libraryTake
|
||||
for lib, ws := range waiting {
|
||||
l, defined := libraries[lib]
|
||||
if !defined {
|
||||
continue
|
||||
}
|
||||
// The home's word on the filesystem stands; waits from other machines name none.
|
||||
placed := map[string]bool{}
|
||||
for _, w := range ws {
|
||||
if w.placed != "" {
|
||||
placed[w.placed] = true
|
||||
}
|
||||
}
|
||||
if len(placed) == 0 {
|
||||
placed[""] = true
|
||||
}
|
||||
for p := range placed {
|
||||
t := libraryTake{Library: lib, Node: l.Home, Root: l.Root, Placed: p}
|
||||
seen := map[string]bool{}
|
||||
for _, w := range ws {
|
||||
if w.module != "" && !seen[w.module] {
|
||||
seen[w.module] = true
|
||||
t.Modules = append(t.Modules, w.module)
|
||||
}
|
||||
}
|
||||
sort.Strings(t.Modules)
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() })
|
||||
return out
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0328 §7, §8 and §9 on the controller's side: a library's wait is excused only for a defined library
|
||||
// that reaches the machine and is not yet taken, or whose disk is not there; the gate passes it as a wait for a person
|
||||
// and the operator reads it as needs-operator in words with no path; the operator is asked once per library, never
|
||||
// about one taken; and the library is taken only on the warrant, where it still waits.
|
||||
|
||||
var radarrManifest = catalogue.Manifest{Module: "radarr", Version: "1",
|
||||
Libraries: []catalogue.LibraryUse{{ID: "movies", Library: "media", Path: "movies", Use: catalogue.AccessReadWrite}}}
|
||||
|
||||
var plexManifest = catalogue.Manifest{Module: "plex", Version: "1",
|
||||
Libraries: []catalogue.LibraryUse{{ID: "films", Library: "media", Path: "movies", Use: catalogue.AccessRead}}}
|
||||
|
||||
var mediaLib = catalogue.Library{Name: "media", Home: "ace", Root: "/storage/media", Reach: []string{"novox"}}
|
||||
|
||||
func keepWait(access string) inventory.Wait {
|
||||
return inventory.Wait{Part: "radarr", Access: access, Reason: link.WaitKeep,
|
||||
What: "let the mesh keep the library media"}
|
||||
}
|
||||
|
||||
func placedWait(access, placed string) inventory.Wait {
|
||||
w := keepWait(access)
|
||||
w.Placed = placed
|
||||
return w
|
||||
}
|
||||
|
||||
func diskWait(access string) inventory.Wait {
|
||||
return inventory.Wait{Part: "radarr", Access: access, Disk: true, Reason: link.WaitDisk,
|
||||
What: "mount the disk that holds media"}
|
||||
}
|
||||
|
||||
// libraryFacts is the facts with media defined, and each library given taken on ace on fs-000000000001.
|
||||
func libraryFactsWith(taken ...string) operatorWaitFacts {
|
||||
t := map[string]inventory.TakenLibrary{}
|
||||
for _, l := range taken {
|
||||
t[l] = inventory.TakenLibrary{Library: l, Home: "ace", Root: "/storage/media", Placed: "fs-000000000001", Done: true}
|
||||
}
|
||||
return operatorWaitFacts{
|
||||
manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest},
|
||||
given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}},
|
||||
libraries: map[string]catalogue.Library{"media": mediaLib},
|
||||
taken: t,
|
||||
librariesRead: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 8 and 9: excused for a defined library reaching the machine and not yet taken — by the module's entry or the
|
||||
// library's own wait — and for one whose disk is not there, taken or not; judged unhealthy when undefined, not
|
||||
// reaching the machine, already taken on the filesystem it is on, for an access that is not a library, or for a
|
||||
// reason the controller does not know.
|
||||
func TestALibraryWaitIsExcusedOnlyForALibraryNotYetTaken(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
machine string
|
||||
w inventory.Wait
|
||||
f operatorWaitFacts
|
||||
says string
|
||||
}{
|
||||
{"the module's entry, not taken", "ace", keepWait("movies"), libraryFactsWith(), ""},
|
||||
{"the library's own wait, not taken", "ace", keepWait("library:media"), libraryFactsWith(), ""},
|
||||
{"on a machine it reaches", "novox", keepWait("movies"), libraryFactsWith(), ""},
|
||||
{"its disk not there", "ace", diskWait("movies"), libraryFactsWith(), ""},
|
||||
{"its disk not there, taken before", "ace", diskWait("movies"), libraryFactsWith("media"), ""},
|
||||
{"taken already", "ace", keepWait("movies"), libraryFactsWith("media"), "taken already"},
|
||||
{"taken on the filesystem it is on", "ace", placedWait("library:media", "fs-000000000001"),
|
||||
libraryFactsWith("media"), "taken already"},
|
||||
{"taken on another filesystem: it moved", "ace", placedWait("library:media", "fs-0000000000ff"),
|
||||
libraryFactsWith("media"), ""},
|
||||
{"taken at another root: its root changed", "ace", keepWait("movies"), func() operatorWaitFacts {
|
||||
f := libraryFactsWith("media")
|
||||
f.taken["media"] = inventory.TakenLibrary{Library: "media", Home: "ace", Root: "/storage/old-media", Done: true,
|
||||
Placed: "fs-000000000001"}
|
||||
return f
|
||||
}(), ""},
|
||||
{"a machine it does not reach", "far", keepWait("movies"), libraryFactsWith(), "does not reach far"},
|
||||
{"undefined", "ace", keepWait("library:books"), libraryFactsWith(), "not defined"},
|
||||
{"not a library", "ace", keepWait("downloads"), libraryFactsWith(), "only a library waits"},
|
||||
{"a reason not known", "ace", inventory.Wait{Part: "radarr", Access: "movies", Reason: "later", What: "w"},
|
||||
libraryFactsWith(), "take it or for its disk"},
|
||||
{"libraries unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: libraryFactsWith().manifests},
|
||||
"could not be read"},
|
||||
} {
|
||||
err := checkWait("radarr", c.machine, c.w, c.f)
|
||||
switch {
|
||||
case c.says == "" && err != nil:
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)):
|
||||
t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func libraryWaiting(module string, w inventory.Wait) inventory.ResourceHealth {
|
||||
return inventory.ResourceHealth{Module: module, Resource: module + ".app", Kind: "container", Target: module,
|
||||
State: link.StateWaiting, Waits: []inventory.Wait{w}}
|
||||
}
|
||||
|
||||
// Rows 8 and 9 at the gate: both excused waits pass the first-node gate as a wait for a person, and the module is said
|
||||
// as needs-operator in words with no path; a wait on a library taken already does not pass.
|
||||
func TestALibraryWaitPassesTheGateAndAsksTheOperatorWithoutAPath(t *testing.T) {
|
||||
now := time.Now()
|
||||
for _, w := range []inventory.Wait{keepWait("movies"), diskWait("movies")} {
|
||||
w.What += " at /storage/media" // an engine's words carrying a path never reach the operator's
|
||||
f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{libraryWaiting("radarr", w)}}}}
|
||||
h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f)
|
||||
if h != healthPerson || !strings.Contains(why, "waits for the operator") || strings.Contains(why, "/") {
|
||||
t.Fatalf("an excused library wait reads %v %q; want a wait for a person, with no path", h, why)
|
||||
}
|
||||
o := needsOperatorObservation("radarr", "ace", []inventory.Wait{w}, nil)
|
||||
for _, words := range []string{o.Summary, o.Headline, o.Explanation, o.Needs} {
|
||||
if strings.Contains(words, "/") {
|
||||
t.Errorf("the operator reads a path: %q", words)
|
||||
}
|
||||
}
|
||||
want := "let the mesh keep the library entry movies when you are asked"
|
||||
if w.Disk {
|
||||
want = "mount the disk that holds the library entry movies on ace"
|
||||
}
|
||||
if o.Kind != kindNeedsOperator || !strings.Contains(o.Needs, want) {
|
||||
t.Errorf("needs-operator %s asks %q; want %q", o.Kind, o.Needs, want)
|
||||
}
|
||||
}
|
||||
f := gateFacts{now: now, waits: libraryFactsWith("media"), groupsAdded: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{libraryWaiting("radarr", keepWait("movies"))}}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet || !strings.Contains(why, "taken") {
|
||||
t.Fatalf("a wait on a taken library reads %v %q", h, why)
|
||||
}
|
||||
}
|
||||
|
||||
func takeRig(t *testing.T, waiting func() map[string][]waitingFolder) (*askerRig, *[]string) {
|
||||
r := newAskerRig(t)
|
||||
var taken []string
|
||||
libs := map[string]catalogue.Library{"media": mediaLib}
|
||||
r.a.folders = func(context.Context) ([]libraryTake, error) { return takesOf(waiting(), libs), nil }
|
||||
r.a.machines = func(context.Context) ([]string, error) { return []string{"ace", "novox"}, nil }
|
||||
r.a.take = func(_ context.Context, lt libraryTake, askID, by string) (string, error) {
|
||||
taken = append(taken, lt.Library+" "+lt.Node+" "+lt.Root+" "+lt.Placed+" "+askID+" "+by)
|
||||
return "taking", nil
|
||||
}
|
||||
return r, &taken
|
||||
}
|
||||
|
||||
func takeAsks(r *askerRig) []asked {
|
||||
var out []asked
|
||||
for _, a := range r.store {
|
||||
if a.Take != nil {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func openTakes(r *askerRig) []asked {
|
||||
var out []asked
|
||||
for _, a := range takeAsks(r) {
|
||||
if a.State == askOpen {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Row 7: one ask per library however many modules and machines wait on it, in the clause's words, at approve; none
|
||||
// again while it is open; taken back when the library no longer waits.
|
||||
func TestTheOperatorIsAskedOncePerLibrary(t *testing.T) {
|
||||
current := map[string][]waitingFolder{"media": {
|
||||
{module: "radarr", machine: "ace", placed: "fs-000000000001"},
|
||||
{module: "plex", machine: "novox"},
|
||||
{module: "", machine: "ace", placed: "fs-000000000001"}}}
|
||||
r, _ := takeRig(t, func() map[string][]waitingFolder { return current })
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
kept := takeAsks(r)
|
||||
if len(kept) != 1 {
|
||||
t.Fatalf("%d asks for one library", len(kept))
|
||||
}
|
||||
q := kept[0].Ask
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Highest() != asks.Approve || len(q.Options) != 1 {
|
||||
t.Fatalf("the ask offers %+v; want one answer at approve", q.Options)
|
||||
}
|
||||
// The root may not leave the mesh (the router's content rule): it is said as "its root".
|
||||
want := "Let the mesh keep library media at its root on ace: its group becomes mesh-media, the group may read " +
|
||||
"and write, folders keep the group; owners and everyone else's rights stay as they are. Waiting on it: plex, radarr."
|
||||
if q.Explanation != want {
|
||||
t.Errorf("the ask says\n %q\nwant\n %q", q.Explanation, want)
|
||||
}
|
||||
if kept[0].Take.Node != "ace" || kept[0].Take.Placed != "fs-000000000001" {
|
||||
t.Errorf("the take is for %+v; want the home and the filesystem its home names", kept[0].Take)
|
||||
}
|
||||
current = nil
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := takeAsks(r); len(got) != 1 || got[0].State != askCancelled {
|
||||
t.Fatalf("the ask about a library that no longer waits: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 7: nothing is taken without the operator's warrant — a warrant for another asker, an ask that expired, or a
|
||||
// record changed after the ask takes nothing; the warrant takes it once, on its home, naming library:<name>.
|
||||
func TestALibraryIsTakenOnlyOnTheWarrant(t *testing.T) {
|
||||
waits := func() map[string][]waitingFolder {
|
||||
return map[string][]waitingFolder{"media": {{module: "radarr", machine: "ace", placed: "fs-000000000001"}}}
|
||||
}
|
||||
ctx := context.Background()
|
||||
r, taken := takeRig(t, waits)
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := takeAsks(r)[0]
|
||||
other := warrantOn(a, "let-the-mesh-keep-it", r.now)
|
||||
other.Asker = "someone-else"
|
||||
answerWith(t, r, other)
|
||||
changed := r.store[a.ID]
|
||||
changed.Take = &libraryTake{Library: "media", Node: "ace", Root: "/", Modules: a.Take.Modules, Placed: a.Take.Placed}
|
||||
r.store[a.ID] = changed
|
||||
answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now))
|
||||
if len(*taken) != 0 || !strings.HasPrefix(r.store[a.ID].Acted, "failed") {
|
||||
t.Fatalf("a take whose record changed after the ask: %v, %q", *taken, r.store[a.ID].Acted)
|
||||
}
|
||||
|
||||
r2, taken2 := takeRig(t, waits)
|
||||
if err := r2.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := takeAsks(r2)[0]
|
||||
answerWith(t, r2, asks.Warrant{Ask: b.ID, Asker: "mesh-controller", Outcome: asks.OutcomeExpired, At: r2.now})
|
||||
if len(*taken2) != 0 {
|
||||
t.Fatal("taken on an ask that expired")
|
||||
}
|
||||
|
||||
r3, taken3 := takeRig(t, waits)
|
||||
if err := r3.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := takeAsks(r3)[0]
|
||||
w := warrantOn(c, "let-the-mesh-keep-it", r3.now)
|
||||
answerWith(t, r3, w)
|
||||
answerWith(t, r3, w)
|
||||
if len(*taken3) != 1 || !strings.HasPrefix((*taken3)[0], "media ace /storage/media fs-000000000001 "+c.ID) ||
|
||||
!strings.Contains((*taken3)[0], "telegram") {
|
||||
t.Fatalf("the warrant took %v", *taken3)
|
||||
}
|
||||
if !strings.HasPrefix(r3.store[c.ID].Acted, "done") {
|
||||
t.Fatalf("the take was recorded %q", r3.store[c.ID].Acted)
|
||||
}
|
||||
}
|
||||
|
||||
// A warrant for a library on one filesystem is never carried out on another: when it comes and the library waits on
|
||||
// another, nothing is sent, the ask is spent, and the operator is asked again for the filesystem it is on now.
|
||||
func TestAWarrantForOneFilesystemIsNotCarriedOutOnAnother(t *testing.T) {
|
||||
placed := "fs-000000000001"
|
||||
r, taken := takeRig(t, func() map[string][]waitingFolder {
|
||||
return map[string][]waitingFolder{"media": {{module: "radarr", machine: "ace", placed: placed}}}
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := takeAsks(r)[0]
|
||||
placed = "fs-0000000000ff"
|
||||
answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now))
|
||||
if len(*taken) != 0 {
|
||||
t.Fatalf("a warrant for %s was carried out on %s: %v", a.Take.Placed, placed, *taken)
|
||||
}
|
||||
if spent := r.store[a.ID]; spent.State == askOpen || !strings.HasPrefix(spent.Acted, "nothing") {
|
||||
t.Fatalf("the ask after the library moved: %s, %q", spent.State, spent.Acted)
|
||||
}
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again := openTakes(r); len(again) != 1 || again[0].Take.Placed != placed {
|
||||
t.Fatalf("not asked again for the filesystem it is on now: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// The take act names library:<name> and the warrant, is signed over its own context, and is recorded only on the
|
||||
// engine's word.
|
||||
func TestATakeAskNamesTheLibraryAndItsWarrant(t *testing.T) {
|
||||
_, key, _ := ed25519.GenerateKey(nil)
|
||||
signer := keySigner{key}
|
||||
ask := link.AccessActAsk{Node: "ace", Path: "/storage/media", By: "the operator", Access: "library:media"}
|
||||
if _, err := link.SignTake(context.Background(), signer, ask); err == nil {
|
||||
t.Fatal("a take with no warrant was signed")
|
||||
}
|
||||
ask.Warrant = "c0123456789abcdef"
|
||||
body, err := link.SignTake(context.Background(), signer, ask)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var signed link.SignedHandOver
|
||||
if err := json.Unmarshal(body, &signed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ed25519.Verify(key.Public().(ed25519.PublicKey), append([]byte(link.AccessActContext), signed.Ask...), signed.Signature) {
|
||||
t.Fatal("the take is not signed over its own context")
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(signed.Ask, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["act"] != "take" || got["warrant"] != "c0123456789abcdef" || got["access"] != "library:media" ||
|
||||
got["path"] != "/storage/media" {
|
||||
t.Fatalf("the ask carries %v", got)
|
||||
}
|
||||
var asked link.AccessActAsk
|
||||
refusing := takeFolderIn(nil, func(_ context.Context, a link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
asked = a
|
||||
return link.HandOverAnswer{Refused: "the disk is not there"}, nil
|
||||
})
|
||||
if _, err := refusing(context.Background(), libraryTake{Library: "media", Node: "ace", Root: "/storage/media"},
|
||||
"c01", "the operator"); err == nil {
|
||||
t.Fatal("a refused take was answered as done")
|
||||
}
|
||||
if asked.Access != "library:media" || asked.Node != "ace" || asked.Path != "/storage/media" || asked.Warrant != "c01" {
|
||||
t.Fatalf("the engine was asked %+v", asked)
|
||||
}
|
||||
unreachable := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
return link.HandOverAnswer{}, errors.New("nothing on ace answers")
|
||||
})
|
||||
if _, err := unreachable(context.Background(), libraryTake{Library: "media", Node: "ace", Root: "/storage/media"},
|
||||
"c01", "the operator"); err == nil {
|
||||
t.Fatal("an unanswered take was answered as done")
|
||||
}
|
||||
}
|
||||
|
||||
type keySigner struct{ key ed25519.PrivateKey }
|
||||
|
||||
func (s keySigner) Sign(_ context.Context, b []byte) ([]byte, error) {
|
||||
return ed25519.Sign(s.key, b), nil
|
||||
}
|
||||
|
||||
// The envelope carries groups, identities and libraries only when composed — for an engine at link.LibraryContract —
|
||||
// and an older engine's body is what it was.
|
||||
func TestTheDeclarationSaysItsLibrariesOnlyWhenComposed(t *testing.T) {
|
||||
older, err := sendable{Resources: []map[string]any{{"id": "a"}}}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"groups", "identities", "libraries"} {
|
||||
if strings.Contains(string(older), key) {
|
||||
t.Fatalf("an older engine's body names %s: %s", key, older)
|
||||
}
|
||||
}
|
||||
newer, err := sendable{Resources: []map[string]any{{"id": "a"}},
|
||||
Groups: []catalogue.GroupEntry{{Name: "mesh-media", GID: 50010}},
|
||||
Identities: []catalogue.IdentityEntry{{Module: "radarr", Name: "mesh-radarr", UID: 50020, GID: 50010}},
|
||||
Libraries: []catalogue.LibraryEntry{{Library: "media", Root: "/storage/media", Group: "mesh-media", GID: 50010,
|
||||
Home: true, Paths: []string{"movies"}, Runner: "jochen"}}}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"groups":[{"name":"mesh-media","gid":50010}]`,
|
||||
`"identities":[{"module":"radarr","name":"mesh-radarr","uid":50020,"gid":50010}]`,
|
||||
`"libraries":[{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,` +
|
||||
`"taken":false,"paths":["movies"],"runner":"jochen"}]`} {
|
||||
if !strings.Contains(string(newer), want) {
|
||||
t.Errorf("the body lacks %s: %s", want, newer)
|
||||
}
|
||||
}
|
||||
if link.LibraryContract != 6 {
|
||||
t.Fatalf("the contract the engine states for ADR 0328 is 6, and this controller reads %d", link.LibraryContract)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate: an access or library the engine cannot judge this look, or entries left while a disk was away, of a module
|
||||
// whose move leaves its accesses and libraries as they were, are not counted; of one whose move changes them — a
|
||||
// library added, or joining — or is not known, they hold the gate to its bound.
|
||||
func TestAnUnknownLibraryIsJudgedByWhetherTheMoveChangesIt(t *testing.T) {
|
||||
now := time.Now()
|
||||
since := now.Add(-time.Minute)
|
||||
unknown := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.library-movies", Kind: link.KindAccess,
|
||||
Target: "/storage/media/movies", State: link.StateUnknown, Since: now}
|
||||
left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries,
|
||||
Target: "/storage/media", State: link.StateUnhealthy, Since: since.Add(-time.Hour)}
|
||||
facts := func(changes map[string]bool, rs ...inventory.ResourceHealth) gateFacts {
|
||||
return gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: changes, health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: rs}}}
|
||||
}
|
||||
unchanged := map[string]bool{"radarr": false}
|
||||
if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, unknown)); h != healthGood {
|
||||
t.Errorf("an unknown library of a move that changes nothing reads %v %q", h, why)
|
||||
}
|
||||
if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, left)); h != healthPerson ||
|
||||
!strings.Contains(why, "access-gone~entries") {
|
||||
t.Errorf("entries left, a move that changes nothing: %v %q", h, why)
|
||||
}
|
||||
for name, changes := range map[string]map[string]bool{"changes them": {"radarr": true}, "not known": nil} {
|
||||
for _, r := range []inventory.ResourceHealth{unknown, left} {
|
||||
if h, _ := moduleHealthWord("radarr", "ace", since, facts(changes, r)); h != healthNotYet {
|
||||
t.Errorf("%s %s of a move that %s reads %v; want held to the bound", r.Kind, r.State, name, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
plain := catalogue.Manifest{Module: "radarr"}
|
||||
if sameAccessUse(plain, radarrManifest) || !sameAccessUse(radarrManifest, radarrManifest) {
|
||||
t.Fatal("a move that adds a library, and joins, reads unchanged — or an unchanged one reads changed")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine states a library once, of no module; each module assigned to the machine that uses it is judged and said
|
||||
// by a copy of that verdict — its wait excused and passed by the gate as a wait for a person. A library unknown or
|
||||
// starting (a take walking under it) of a move that changes no library is not counted.
|
||||
func TestALibrarysVerdictIsSaidForTheModulesThatUseIt(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []catalogue.Manifest{radarrManifest, plexManifest, {Module: "plain", Version: "1"}} {
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Module == "plex" {
|
||||
continue // pulled in by a requirement, not assigned: it is in what the machine was sent (item 4)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "ace", m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1", "plex": "c2", "plain": "c3"},
|
||||
1, 1, inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := inventory.ResourceHealth{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media",
|
||||
State: link.StateWaiting, Waits: []inventory.Wait{{Part: "media", Access: "library:media", Reason: link.WaitKeep,
|
||||
Placed: "fs-000000000001", What: "let the mesh keep the library media"}}}
|
||||
copies := libraryVerdictsFor(ctx, inv, "ace", []inventory.ResourceHealth{lib})
|
||||
var modules []string
|
||||
for _, c := range copies {
|
||||
modules = append(modules, c.Module)
|
||||
}
|
||||
if strings.Join(modules, ",") != "plex,radarr" {
|
||||
t.Fatalf("the library's verdict is said for %v; want plex and radarr, the modules using it", modules)
|
||||
}
|
||||
paths, err := inv.LibraryPaths(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(paths["media"], ",") != "movies" {
|
||||
t.Fatalf("the paths kept of media are %v", paths["media"])
|
||||
}
|
||||
now := time.Now()
|
||||
f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, Resources: copies}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthPerson {
|
||||
t.Fatalf("radarr waiting on media reads %v %q; want a wait for a person", h, why)
|
||||
}
|
||||
for _, state := range []string{link.StateUnknown, link.StateStarting} {
|
||||
c := copies[1]
|
||||
c.State, c.Waits = state, nil
|
||||
f.health["ace"] = inventory.NodeHealth{Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{c}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthGood {
|
||||
t.Errorf("media %s, a move that changes no library: %v %q", state, h, why)
|
||||
}
|
||||
f.accessesChanged = map[string]bool{"radarr": true}
|
||||
if h, _ := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet {
|
||||
t.Errorf("media %s, a move that changes a library: %v; want held to the bound", state, h)
|
||||
}
|
||||
f.accessesChanged = map[string]bool{"radarr": false}
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 7ea6d36e, item 5: the engine saying it started a take does not make the library taken. While the act
|
||||
// runs the library still waits and the operator is not asked again; it is taken when the engine states the act done
|
||||
// under that warrant.
|
||||
func TestALibraryIsTakenOnlyWhenTheEngineSaysTheActIsDone(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
take := takeFolderIn(open, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
return link.HandOverAnswer{Said: "the take of library:media started"}, nil
|
||||
})
|
||||
lt := libraryTake{Library: "media", Node: "anchor", Root: "/storage/media", Placed: "fs-000000000001"}
|
||||
if _, err := take(ctx, lt, "c01", "the operator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taken, err := librariesTaken(ctx, inv, libs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken["media"] {
|
||||
t.Fatal("a take only started made the library taken")
|
||||
}
|
||||
// While it runs: the wait is still excused, and no new ask is made for it.
|
||||
var f operatorWaitFacts
|
||||
readWaitFacts(ctx, inv, "anchor", nil, nil, &f)
|
||||
if err := checkWait("radarr", "anchor", placedWait("library:media", "fs-000000000001"), f); err != nil {
|
||||
t.Fatalf("the wait while the take runs: %v", err)
|
||||
}
|
||||
if got := withoutTakesUnderWay([]libraryTake{lt}, f.taken); len(got) != 0 {
|
||||
t.Fatalf("a library whose take runs is asked about again: %+v", got)
|
||||
}
|
||||
// The engine states the act done under that warrant.
|
||||
act, _ := json.Marshal(map[string]any{"act": "take", "access": "library:media", "warrant": "c01", "state": "done"})
|
||||
if err := stateHealth(ctx, inv, nil, "anchor", link.Health{Contract: link.LibraryContract, At: time.Now(),
|
||||
Resources: []link.ResourceHealth{{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media",
|
||||
State: link.StateHealthy, Since: time.Now(), Act: act}}}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, err = librariesTaken(ctx, inv, libs); err != nil || !taken["media"] {
|
||||
t.Fatalf("the act done did not make the library taken (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0334: the engine states the tool runner's membership as resources of kind runner — `runner:<module>` of that
|
||||
// module, `runner:library:<name>` of no module. A library's is said for the modules that use it; neither fails the
|
||||
// first-node gate of a move that leaves the module's libraries as they were, and both hold one that changes them.
|
||||
func TestARunnersVerdictIsSaidAndJudgedByWhetherTheMoveChangesIt(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []catalogue.Manifest{radarrManifest, plexManifest} {
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "ace", m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
lib := inventory.ResourceHealth{Resource: "runner:library:media", Kind: link.KindRunner, Target: "jochen",
|
||||
State: link.StateUnhealthy, Reason: "the tool runner is not in mesh-media yet", Since: time.Now()}
|
||||
copies := libraryVerdictsFor(ctx, inv, "ace", []inventory.ResourceHealth{lib})
|
||||
if len(copies) != 2 || copies[0].Module != "plex" || copies[1].Module != "radarr" {
|
||||
t.Fatalf("the runner's library verdict is said for %+v; want plex and radarr", copies)
|
||||
}
|
||||
own := inventory.ResourceHealth{Module: "radarr", Resource: "runner:radarr", Kind: link.KindRunner, Target: "jochen",
|
||||
State: link.StateUnhealthy, Reason: "the tool runner is not in mesh-radarr yet", Since: time.Now()}
|
||||
now := time.Now()
|
||||
for _, r := range []inventory.ResourceHealth{copies[1], own} {
|
||||
f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{r}}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthGood {
|
||||
t.Errorf("%s, a move that changes no library: %v %q; want not counted", r.Resource, h, why)
|
||||
}
|
||||
f.accessesChanged = map[string]bool{"radarr": true}
|
||||
if h, _ := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet {
|
||||
t.Errorf("%s, a move that changes a library: %v; want held to the bound", r.Resource, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -42,96 +40,6 @@ const (
|
||||
moduleUnhealthyAfter = 2
|
||||
)
|
||||
|
||||
// runnerLibrary prefixes the engine's verdict on the tool runner's membership of a library's group (ADR 0334).
|
||||
const runnerLibrary = "runner:library:"
|
||||
|
||||
// libraryVerdictsFor is, for each library verdict in a statement (kind library, no module), one copy for each module
|
||||
// on the machine — assigned, or pulled in by a requirement — whose manifest uses that library: what the module is judged and said by. None when the
|
||||
// assignments or the catalogue cannot be read: the library's own verdict stands alone, and no module is excused by it.
|
||||
func libraryVerdictsFor(ctx context.Context, inv *inventory.Inventory, node string,
|
||||
rs []inventory.ResourceHealth) []inventory.ResourceHealth {
|
||||
var libraries []inventory.ResourceHealth
|
||||
for _, r := range rs {
|
||||
if r.Module == "" && ((r.Kind == link.KindLibrary && strings.HasPrefix(r.Resource, libraryAccess)) ||
|
||||
(r.Kind == link.KindRunner && strings.HasPrefix(r.Resource, runnerLibrary))) {
|
||||
libraries = append(libraries, r)
|
||||
}
|
||||
}
|
||||
if len(libraries) == 0 || inv == nil {
|
||||
return nil
|
||||
}
|
||||
assigned, err := inv.Assigned(ctx, node)
|
||||
if err != nil {
|
||||
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
|
||||
return nil
|
||||
}
|
||||
// And the modules a requirement pulled in: what the machine was last sent (the review of 7ea6d36e, item 4).
|
||||
sent, _, err := inv.SentBuilds(ctx, node)
|
||||
if err != nil {
|
||||
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
|
||||
return nil
|
||||
}
|
||||
for m := range sent {
|
||||
if !slices.Contains(assigned, m) {
|
||||
assigned = append(assigned, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(assigned)
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
|
||||
return nil
|
||||
}
|
||||
var out []inventory.ResourceHealth
|
||||
for _, lib := range libraries {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(lib.Resource, runnerLibrary), libraryAccess)
|
||||
for _, module := range assigned {
|
||||
for _, u := range shelf[module].Libraries {
|
||||
if u.Library == name {
|
||||
copied := lib
|
||||
copied.Module = module
|
||||
out = append(out, copied)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// takesStated keeps what a statement says of each library's take act: done under a warrant, the library is taken;
|
||||
// failed, the take is forgotten and the operator asked again. Running or stopped (it goes on) changes nothing.
|
||||
func takesStated(ctx context.Context, inv *inventory.Inventory, rs []link.ResourceHealth) error {
|
||||
if inv == nil {
|
||||
return nil
|
||||
}
|
||||
for _, r := range rs {
|
||||
lib, isLibrary := strings.CutPrefix(r.Resource, libraryAccess)
|
||||
if !isLibrary || r.Kind != link.KindLibrary || len(r.Act) == 0 {
|
||||
continue
|
||||
}
|
||||
var act struct {
|
||||
Act string `json:"act"`
|
||||
Warrant string `json:"warrant"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Act, &act); err != nil || act.Act != link.ActTake || act.Warrant == "" {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
switch act.State {
|
||||
case "done":
|
||||
err = inv.TakeDone(ctx, lib, act.Warrant)
|
||||
case "failed":
|
||||
err = inv.TakeFailed(ctx, lib, act.Warrant)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthRefused counts the statements refused as older than the one kept, for the log and a test.
|
||||
var healthRefused atomic.Int64
|
||||
|
||||
@@ -173,18 +81,14 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
|
||||
// access by its module's id, and whether it waits for a disk rather than for the operator's word.
|
||||
for _, w := range r.AccessWaits {
|
||||
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Access: w.Access, Disk: w.Reason == link.WaitDisk,
|
||||
Placed: w.Placed, Reason: w.Reason, What: w.What})
|
||||
Placed: w.Placed, Reason: w.Reason, ACL: w.ACL, What: w.What})
|
||||
}
|
||||
for _, e := range r.Entries {
|
||||
kept.Entries = append(kept.Entries, inventory.KeptEntry{Identity: e.Identity, UID: e.UID, Use: e.Use, From: e.From,
|
||||
Note: e.Note})
|
||||
}
|
||||
resources = append(resources, kept)
|
||||
}
|
||||
// **A library's take is done, or failed, when its engine says so** (novox/hq ADR 0328 §7): only then is it taken,
|
||||
// or asked about again.
|
||||
if err := takesStated(ctx, inv, h.Resources); err != nil {
|
||||
return err
|
||||
}
|
||||
// **A library's verdict is said for the modules that use it** (novox/hq ADR 0328 §8): the engine states each
|
||||
// library once, as `library:<name>`, of no module; a module that uses it waits with it, and is said so.
|
||||
resources = append(resources, libraryVerdictsFor(ctx, inv, node, resources)...)
|
||||
// **A wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose
|
||||
// wait does not check out is judged unhealthy, saying why; one that does is kept beside the unhealthy ones, so
|
||||
// judgeModuleHealth can say it as needs-operator. What is stored is what the machine said.
|
||||
@@ -788,15 +692,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
// no wait. Of a module whose move leaves its accesses as they were it is a condition the move found, not
|
||||
// counted; of one whose move changes them it is not yet healthy, held to the gate's bound like anything still
|
||||
// unsettled, so the gate fails safe at the bound and never waits for ever.
|
||||
// The same for a library (ADR 0328): unknown this look, or starting while a take walks under it, of a move that
|
||||
// leaves the module's libraries as they were, is not this move's.
|
||||
if (r.Kind == link.KindAccess || r.Kind == link.KindLibrary) && !changes &&
|
||||
(r.State == link.StateUnknown || (r.Kind == link.KindLibrary && r.State == link.StateStarting)) {
|
||||
continue
|
||||
}
|
||||
// The tool runner's membership (ADR 0334 §2), of a move that changes no library of the module, is not this
|
||||
// move's: it is said as the module's condition, and holds a move that changes them to the gate's bound.
|
||||
if r.Kind == link.KindRunner && !changes {
|
||||
if r.Kind == link.KindAccess && r.State == link.StateUnknown && !changes {
|
||||
continue
|
||||
}
|
||||
switch r.State {
|
||||
@@ -878,6 +774,14 @@ func healthLines(h inventory.NodeHealth, had bool, now time.Time) []string {
|
||||
line += " (unconfirmed: said once)"
|
||||
}
|
||||
out = append(out, line)
|
||||
// A kept folder's users, as the engine resolved them (novox/hq ADR 0325 §4), the runner's note beside it.
|
||||
for _, e := range r.Entries {
|
||||
entry := fmt.Sprintf(" entry %s (uid %d, %s, from the %s)", e.Identity, e.UID, e.Use, e.From)
|
||||
if e.Note != "" {
|
||||
entry += " — " + e.Note
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -487,6 +487,15 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if err := inv.SetSettingsBy(ctx, *node, positionals[0], values, setByWords()); err != nil {
|
||||
return err
|
||||
}
|
||||
// An owner a placement names is warned of (novox/hq ADR 0325 §1): accepted while registered modules still
|
||||
// name one, refused once they have moved.
|
||||
if shelf, err := inv.Catalogue(ctx); err == nil {
|
||||
if m, known := shelf[positionals[0]]; known {
|
||||
for _, w := range catalogue.PlacesOwnerWarnings(m, []catalogue.Layer{{From: where, Values: values}}) {
|
||||
fmt.Println(w)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s on %s:\n", positionals[0], where)
|
||||
if len(added)+len(changed)+len(removed) == 0 {
|
||||
fmt.Println(" nothing changed")
|
||||
|
||||
@@ -44,19 +44,16 @@ func needsOperatorKey(module, node string) string {
|
||||
type operatorWaitFacts struct {
|
||||
manifests map[string]catalogue.Manifest
|
||||
given map[string]map[string]time.Time
|
||||
// libraries is every library the operator defined, and taken every one taken (novox/hq ADR 0328 §7, §8);
|
||||
// librariesRead says both were read — when not, no access wait is excused.
|
||||
libraries map[string]catalogue.Library
|
||||
taken map[string]inventory.TakenLibrary
|
||||
librariesRead bool
|
||||
// placed is, per "<module>@<machine>", where each access of the module is on that machine, by id (novox/hq ADR
|
||||
// 0325 §7); taken is, per machine, every folder the operator let the mesh keep there, with the filesystem it was
|
||||
// taken on (empty when the engine named none). A machine absent from taken could not be read, and no access wait
|
||||
// of it is excused.
|
||||
placed map[string]map[string]string
|
||||
taken map[string]map[string]string
|
||||
}
|
||||
|
||||
// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure.
|
||||
func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) error {
|
||||
// A library's own wait names it, and no module's manifest (novox/hq ADR 0328 §7).
|
||||
if lib, ok := strings.CutPrefix(w.Access, libraryAccess); ok && w.Secret == "" && w.Setting == "" {
|
||||
return checkLibraryWait(machine, lib, w, f)
|
||||
}
|
||||
m, known := f.manifests[module]
|
||||
if !known {
|
||||
return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module)
|
||||
@@ -97,63 +94,49 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// libraryAccess prefixes the access a library's own wait and take name: "library:<name>".
|
||||
const libraryAccess = "library:"
|
||||
|
||||
// checkAccessWait is nil when a module's access wait is excused (novox/hq ADR 0328 §8, §9): the access is one of its
|
||||
// library entries, and its library's wait is excused. Any other access waits for nothing the operator gives: an
|
||||
// access that is not a library keeps ADR 0051's meaning.
|
||||
// checkAccessWait is nil when an access wait is excused (novox/hq ADR 0325 §7): the module's manifest declares the
|
||||
// access, its folder is placed on that machine, and either the folder is absent and its disk is not there (§9), or
|
||||
// it has not yet been taken under the operator's warrant (§8). That a folder that is there is not owned by root or a
|
||||
// root group is the node-engine's to see: it states such an access unhealthy and never waiting (§12). Any other
|
||||
// access wait is judged unhealthy, saying why: a module that gains an access to a folder already taken is given its
|
||||
// entries without a wait.
|
||||
func checkAccessWait(module, machine string, m catalogue.Manifest, w inventory.Wait, f operatorWaitFacts) error {
|
||||
lib := catalogue.LibraryOfEntry(m, w.Access)
|
||||
if lib == "" {
|
||||
return fmt.Errorf("says it waits for %s, which is no library entry %s declares: only a library waits for "+
|
||||
"the operator (novox/hq ADR 0328 §8)", w.Access, module)
|
||||
declared := false
|
||||
for _, a := range m.Accesses {
|
||||
declared = declared || a.ID == w.Access
|
||||
}
|
||||
return checkLibraryWait(machine, lib, w, f)
|
||||
}
|
||||
|
||||
// checkLibraryWait is nil when a wait on a library is excused (novox/hq ADR 0328 §8, §9): the library is defined and
|
||||
// reaches the machine, and either its disk is not there (reason disk) or it has not yet been taken on its home, on the
|
||||
// filesystem the wait names (reason keep). Any other is judged unhealthy, saying why: a module that uses a library
|
||||
// already taken is never asked about it.
|
||||
func checkLibraryWait(machine, lib string, w inventory.Wait, f operatorWaitFacts) error {
|
||||
if !f.librariesRead {
|
||||
return fmt.Errorf("says it waits for the library %s, and the libraries could not be read", lib)
|
||||
if !declared {
|
||||
return fmt.Errorf("says it waits for the access %s, which %s does not declare", w.Access, module)
|
||||
}
|
||||
path, placed := f.placed[module+"@"+machine][w.Access]
|
||||
if !placed {
|
||||
return fmt.Errorf("says it waits for the access %s, which is not placed on %s", w.Access, machine)
|
||||
}
|
||||
l, defined := f.libraries[lib]
|
||||
switch {
|
||||
case !defined:
|
||||
return fmt.Errorf("says it waits for the library %s, which is not defined", lib)
|
||||
case !l.Reaches(machine):
|
||||
return fmt.Errorf("says it waits for the library %s, which does not reach %s", lib, machine)
|
||||
case w.Reason == link.WaitDisk || (w.Reason == "" && w.Disk):
|
||||
return nil
|
||||
case w.Reason != link.WaitKeep:
|
||||
return fmt.Errorf("says it waits for the library %s for %q, and a library waits for the operator's word to "+
|
||||
"take it or for its disk", lib, w.Reason)
|
||||
case w.Reason != link.WaitKeep && w.Reason != "":
|
||||
return fmt.Errorf("says it waits for the access %s for %q, and an access waits for the operator's word to "+
|
||||
"keep its folder or for its disk", w.Access, w.Reason)
|
||||
case w.ACL == link.ACLUnsupported:
|
||||
// No act can make this filesystem carry access entries: nothing to ask; the operator places the data elsewhere.
|
||||
return fmt.Errorf("says it waits for the access %s, whose folder's filesystem cannot carry access entries: "+
|
||||
"the operator places the data on one that can, or mounts it with ACLs", w.Access)
|
||||
}
|
||||
// Taken on another home or filesystem (it moved) is not taken by that warrant: excused, and asked again (§7).
|
||||
// Only the home's engine names the root's filesystem; another machine's wait names its mount, and says nothing of it.
|
||||
placed := w.Placed
|
||||
if machine != l.Home {
|
||||
placed = ""
|
||||
taken, readable := f.taken[machine]
|
||||
if !readable {
|
||||
return fmt.Errorf("says it waits for the access %s, and which folders are taken on %s could not be read",
|
||||
w.Access, machine)
|
||||
}
|
||||
if t, was := f.taken[lib]; was && t.Done && t.Home == l.Home && t.Root == l.Root &&
|
||||
(placed == "" || t.Placed == "" || t.Placed == placed) {
|
||||
return fmt.Errorf("says it waits for the library %s, which is taken already: a module that uses a taken "+
|
||||
"library is never asked about it", lib)
|
||||
// A folder taken on another filesystem than the one it is on now (it moved to another disk) is not taken by that
|
||||
// warrant: the wait is excused, and the operator is asked again (ADR 0325 §8).
|
||||
if on, was := taken[path]; was && (w.Placed == "" || on == w.Placed) {
|
||||
return fmt.Errorf("says it waits for the access %s, whose folder is taken on %s already: a module that gains "+
|
||||
"an access to a taken folder is given its entries without a wait", w.Access, machine)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// accessWords is an access a wait names, in words with no path: "the library media", or "its library entry movies".
|
||||
func accessWords(access string) string {
|
||||
if lib, ok := strings.CutPrefix(access, libraryAccess); ok {
|
||||
return "the library " + lib
|
||||
}
|
||||
return "the library entry " + access
|
||||
}
|
||||
|
||||
// waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own.
|
||||
const waitRefusedPrefix = "says it waits"
|
||||
|
||||
@@ -228,9 +211,9 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string {
|
||||
// An access in the controller's own words, never the engine's: words with no path (novox/hq ADR 0325 §7).
|
||||
switch {
|
||||
case w.Access != "" && w.Disk:
|
||||
part = fmt.Sprintf("the disk that holds %s mounted (%s", accessWords(w.Access), waitNames(w))
|
||||
part = fmt.Sprintf("the disk that holds %s mounted (%s", w.Access, waitNames(w))
|
||||
case w.Access != "":
|
||||
part = fmt.Sprintf("leave to keep %s (%s, asked of you once for the library", accessWords(w.Access),
|
||||
part = fmt.Sprintf("leave to keep the shared folder %s (%s, asked of you once for the folder", w.Access,
|
||||
waitNames(w))
|
||||
}
|
||||
if w.Secret != "" {
|
||||
@@ -268,9 +251,9 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
|
||||
act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node)
|
||||
// An access, in words with no path (novox/hq ADR 0325 §7): the folder by the access's name.
|
||||
case w.Access != "" && w.Disk:
|
||||
act = fmt.Sprintf("mount the disk that holds %s on %s", accessWords(w.Access), node)
|
||||
act = fmt.Sprintf("mount the disk that holds %s on %s", w.Access, node)
|
||||
case w.Access != "":
|
||||
act = fmt.Sprintf("let the mesh keep %s when you are asked", accessWords(w.Access))
|
||||
act = fmt.Sprintf("let the mesh keep the shared folder %s on %s when you are asked", w.Access, node)
|
||||
}
|
||||
if act != "" && !seen[act] {
|
||||
seen[act] = true
|
||||
@@ -308,7 +291,12 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
||||
if f.given == nil {
|
||||
f.given = map[string]map[string]time.Time{}
|
||||
}
|
||||
|
||||
if f.placed == nil {
|
||||
f.placed = map[string]map[string]string{}
|
||||
}
|
||||
if f.taken == nil {
|
||||
f.taken = map[string]map[string]string{}
|
||||
}
|
||||
if inv == nil {
|
||||
return
|
||||
}
|
||||
@@ -330,6 +318,20 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
||||
}
|
||||
}
|
||||
}
|
||||
// Where each access of it is placed there (ADR 0325 §7): read once per module and machine.
|
||||
if m, known := f.manifests[module]; known && len(m.Accesses) > 0 {
|
||||
if _, read := f.placed[module+"@"+machine]; !read {
|
||||
if layers, err := inv.SettingsFor(ctx, machine, module); err == nil {
|
||||
placed := map[string]string{}
|
||||
for _, a := range m.Accesses {
|
||||
if path, has := catalogue.AccessPathOn(m, layers, a.ID); has && a.ID != "" {
|
||||
placed[a.ID] = path
|
||||
}
|
||||
}
|
||||
f.placed[module+"@"+machine] = placed
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, read := f.given[module+"@"+machine]; read {
|
||||
continue
|
||||
}
|
||||
@@ -337,12 +339,13 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
||||
f.given[module+"@"+machine] = given
|
||||
}
|
||||
}
|
||||
// The libraries and which are taken (ADR 0328 §7, §8): read once.
|
||||
if !f.librariesRead {
|
||||
libs, lerr := inv.Libraries(ctx)
|
||||
taken, terr := inv.TakenLibraries(ctx)
|
||||
if lerr == nil && terr == nil {
|
||||
f.libraries, f.taken, f.librariesRead = libs, taken, true
|
||||
if _, read := f.taken[machine]; !read {
|
||||
if taken, err := inv.KeptFolders(ctx, machine); err == nil {
|
||||
kept := map[string]string{}
|
||||
for path, t := range taken {
|
||||
kept[path] = t.Placed
|
||||
}
|
||||
f.taken[machine] = kept
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,20 +182,6 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
"register it, so it was not made.", module, m),
|
||||
Resolved: "Resolved: " + module + " on " + m + " is settled"}
|
||||
}),
|
||||
// The controller or the node-engine kept at the build a machine was last sent, over an older record (novox/hq
|
||||
// issue 201).
|
||||
kindCoreDowngrade: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
component := idPart(o, 0)
|
||||
if o.Scope != conditions.ScopeCore || !guarded(component) {
|
||||
component = "core component"
|
||||
}
|
||||
return words{Headline: m + " keeps a newer " + component + " than the mesh holds",
|
||||
Needs: fmt.Sprintf("push %s to take the older build, or a newer merge or rebuild moves the record forward.", m),
|
||||
Explanation: fmt.Sprintf("The mesh's record of its own %s is older than the build %s was last sent, so "+
|
||||
"the mesh's sends keep the newer one there rather than put it back.", component, m),
|
||||
Resolved: "Resolved: " + m + " runs the " + component + " the mesh holds"}
|
||||
}),
|
||||
kindBindingKept: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: "A module's data source is held on " + m,
|
||||
|
||||
+53
-126
@@ -134,11 +134,6 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
// And a module that may not join yet, at the build it ran before (novox/hq ADR 0328 §8, ADR 0334).
|
||||
held, err := holdJoining(ctx, inv, nodeName, shelf)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
|
||||
resolved, err := catalogue.Resolve(shelf, assigned,
|
||||
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
|
||||
@@ -224,10 +219,6 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
}
|
||||
settings[m.Module] = layers
|
||||
}
|
||||
// A held module with the settings it last ran with (ADR 0328 §8).
|
||||
if err := heldSettings(ctx, inv, nodeName, held, settings); err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
return resolved, settings, nil
|
||||
}
|
||||
|
||||
@@ -457,8 +448,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
}
|
||||
out := sendable{Resources: composed.Resources, Adoption: adoption, Groups: composed.Groups,
|
||||
Identities: composed.Identities, Libraries: composed.Libraries,
|
||||
out := sendable{Resources: composed.Resources, Adoption: adoption, Identities: composed.Identities,
|
||||
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
|
||||
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
|
||||
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced,
|
||||
@@ -482,22 +472,11 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
||||
names = append(names, m.Module)
|
||||
}
|
||||
out.Builds = carriedBuilds(names, composed.LeftOut, current, before)
|
||||
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245), and so
|
||||
// is a joining module held at the build it ran before (ADR 0328 §8).
|
||||
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245).
|
||||
kept, err := recordedKept(ctx, open, node)
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
}
|
||||
held, err := holdJoining(ctx, open.inventory, node, nil)
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
}
|
||||
if kept == nil && len(held) > 0 {
|
||||
kept = map[string]string{}
|
||||
}
|
||||
for m, h := range held {
|
||||
kept[m] = h.commit
|
||||
}
|
||||
for m, was := range kept {
|
||||
if _, carried := out.Builds[m]; carried {
|
||||
out.Builds[m] = was
|
||||
@@ -997,17 +976,18 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
// The libraries, the numbers of the modules that join and of the libraries' groups, and which libraries are taken
|
||||
// (novox/hq ADR 0328), to an engine that keeps libraries.
|
||||
lf, err := libraryFacts(ctx, inv, node, plan, choosing)
|
||||
// Each module's identity and the folders taken here (novox/hq ADR 0325 §3, §8), to an engine that keeps folders.
|
||||
keepsFolders, identities, keptFolders, readyFolders, err := folderFacts(ctx, inv, node, plan, choosing)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
KeepsLibraries: lf.keeps, Identities: lf.identities, Libraries: lf.libraries, LibraryGroups: lf.groups,
|
||||
TakenLibraries: lf.taken, LibraryPaths: lf.paths, RunnerJoinsOwnGroups: lf.ownGroups,
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
KeepsFolders: keepsFolders,
|
||||
Identities: identities,
|
||||
KeptFolders: keptFolders,
|
||||
ReadyFolders: readyFolders,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
@@ -1040,59 +1020,30 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
|
||||
return had && stated.Contract >= link.RootContract, nil
|
||||
}
|
||||
|
||||
// engineKeepsLibraries says whether a machine's node-engine makes the mesh's users and library groups, keeps
|
||||
// libraries, and keeps a joining module's own groups (novox/hq ADR 0328, ADR 0334: its statement's contract is
|
||||
// link.OwnGroupContract or later), by its own newest statement. On an older one no module joins: the libraries
|
||||
// alone are not enough, since its tools would lose their read of a joining module's directories.
|
||||
func engineKeepsLibraries(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
// engineKeepsFolders says whether a machine's node-engine gives modules their identities and keeps shared folders
|
||||
// (novox/hq ADR 0325), by its own newest statement, for the same reason as engineReadsHealth: an older engine parses
|
||||
// strictly, and is sent none of it.
|
||||
func engineKeepsFolders(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.OwnGroupContract, nil
|
||||
return had && stated.Contract >= link.IdentityContract, nil
|
||||
}
|
||||
|
||||
// libraryRendering is what a machine's composition needs of ADR 0328.
|
||||
type libraryRendering struct {
|
||||
keeps bool
|
||||
identities map[string]catalogue.ModuleIdentity
|
||||
libraries map[string]catalogue.Library
|
||||
groups map[string]int
|
||||
taken map[string]bool
|
||||
paths map[string][]string
|
||||
ownGroups bool
|
||||
}
|
||||
|
||||
// libraryFacts reads what a machine's composition needs of ADR 0328: the libraries and their group numbers, the
|
||||
// numbers of the modules here that join, which libraries are taken, and the sub-paths every module asks of each. **A number is allocated only on the send path** (Allocating),
|
||||
// as a port is: a question about what would be sent reads what was allocated. The libraries are read for every engine,
|
||||
// so a module that joins is left out, naming why, where its engine cannot be told it does.
|
||||
func libraryFacts(ctx context.Context, inv *inventory.Inventory, node string, plan catalogue.Resolution,
|
||||
choosing Choosing) (libraryRendering, error) {
|
||||
var lf libraryRendering
|
||||
joining := false
|
||||
// folderFacts is what a machine's composition needs of ADR 0325: whether its engine keeps folders, each identity of
|
||||
// a module of it that declares one, and the folders taken there. **An identity is allocated only on the send path**
|
||||
// (Allocating), as a port is: a question about what would be sent reads what was allocated, and a module that has
|
||||
// none yet is composed without one. Nothing is read for an engine that does not keep folders.
|
||||
func folderFacts(ctx context.Context, inv *inventory.Inventory, node string, plan catalogue.Resolution,
|
||||
choosing Choosing) (bool, map[string]catalogue.ModuleIdentity, map[string]bool, map[string]bool, error) {
|
||||
keeps, err := engineKeepsFolders(ctx, inv, node)
|
||||
if err != nil || !keeps {
|
||||
return false, nil, nil, nil, err
|
||||
}
|
||||
identities := map[string]catalogue.ModuleIdentity{}
|
||||
for _, m := range plan.Modules {
|
||||
joining = joining || catalogue.Joins(m)
|
||||
}
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
return lf, err
|
||||
}
|
||||
reached := false
|
||||
for _, l := range libs {
|
||||
reached = reached || l.Reaches(node)
|
||||
}
|
||||
if !joining && !reached {
|
||||
return lf, nil // nothing here joins, and no library reaches it: composed exactly as before (§14)
|
||||
}
|
||||
lf.libraries = libs
|
||||
if lf.keeps, err = engineKeepsLibraries(ctx, inv, node); err != nil || !lf.keeps {
|
||||
return lf, err
|
||||
}
|
||||
lf.ownGroups = true // a module joins only on an engine that also keeps its own groups (ADR 0334)
|
||||
lf.identities = map[string]catalogue.ModuleIdentity{}
|
||||
for _, m := range plan.Modules {
|
||||
if !catalogue.Joins(m) {
|
||||
if !catalogue.DeclaresIdentity(m) {
|
||||
continue
|
||||
}
|
||||
var id catalogue.ModuleIdentity
|
||||
@@ -1103,67 +1054,43 @@ func libraryFacts(ctx context.Context, inv *inventory.Inventory, node string, pl
|
||||
id, had, err = inv.IdentityOf(ctx, m.Module)
|
||||
}
|
||||
if err != nil {
|
||||
return lf, err
|
||||
return false, nil, nil, nil, err
|
||||
}
|
||||
if had {
|
||||
lf.identities[m.Module] = id
|
||||
identities[m.Module] = id
|
||||
}
|
||||
}
|
||||
if lf.groups, err = inv.LibraryGroups(ctx); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
if choosing == Allocating {
|
||||
for name, l := range libs {
|
||||
if _, has := lf.groups[name]; has || !l.Reaches(node) {
|
||||
continue
|
||||
}
|
||||
if lf.groups[name], err = inv.AllocateLibraryGroup(ctx, name); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if lf.paths, err = inv.LibraryPaths(ctx); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
if lf.taken, err = librariesTaken(ctx, inv, libs); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
return lf, nil
|
||||
}
|
||||
|
||||
// librariesTaken is which libraries are taken now (§7): recorded taken on the home they have, on the filesystem their
|
||||
// home's newest statement names — or stated kept by their home's engine, which keeps one it found empty without asking.
|
||||
func librariesTaken(ctx context.Context, inv *inventory.Inventory, libs map[string]catalogue.Library) (map[string]bool, error) {
|
||||
recorded, err := inv.TakenLibraries(ctx)
|
||||
taken, err := inv.KeptFolders(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, nil, nil, nil, err
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for name, l := range libs {
|
||||
t, was := recorded[name]
|
||||
taken := was && t.Done && t.Home == l.Home && t.Root == l.Root
|
||||
stated, had, err := inv.HealthOf(ctx, l.Home)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
kept := map[string]bool{}
|
||||
for path := range taken {
|
||||
kept[path] = true
|
||||
}
|
||||
// **A folder that moved to another filesystem is not taken there until its new warrant** (ADR 0325 §8): the
|
||||
// machine's newest statement names, without a path, the filesystem each folder waiting for the operator is on now.
|
||||
// And every folder the engine says it keeps (its access healthy): one it found empty, or took itself — what, with
|
||||
// a taken one, lets a container switch to its identity (identityHeld).
|
||||
ready := map[string]bool{}
|
||||
if stated, had, err := inv.HealthOf(ctx, node); err != nil {
|
||||
return false, nil, nil, nil, err
|
||||
} else if had {
|
||||
for _, r := range stated.Resources {
|
||||
if r.Kind == link.KindAccess && r.State == link.StateHealthy && r.Target != "" {
|
||||
ready[filepath.Clean(r.Target)] = true
|
||||
}
|
||||
}
|
||||
if had {
|
||||
for _, r := range stated.Resources {
|
||||
kept := (r.Resource == libraryAccess+name || filepath.Clean(r.Target) == l.Root) &&
|
||||
(r.Kind == link.KindAccess || r.Kind == link.KindLibrary)
|
||||
if kept && r.State == link.StateHealthy {
|
||||
taken = true
|
||||
}
|
||||
for _, w := range r.Waits {
|
||||
if w.Access == libraryAccess+name && w.Reason == link.WaitKeep && w.Placed != "" && was &&
|
||||
t.Placed != "" && w.Placed != t.Placed {
|
||||
taken = false // it moved to another filesystem: not taken there until its new warrant
|
||||
}
|
||||
for _, r := range stated.Resources {
|
||||
for _, w := range r.Waits {
|
||||
path := filepath.Clean(r.Target)
|
||||
if t, was := taken[path]; was && w.Access != "" && !w.Disk && w.Placed != "" && w.Placed != t.Placed {
|
||||
kept[path] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
out[name] = taken
|
||||
}
|
||||
return out, nil
|
||||
return true, identities, kept, ready, nil
|
||||
}
|
||||
|
||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// planID is every id the mesh gives a plan: a merge's walk or batch (plan-<nanoseconds>) or a release
|
||||
// (release-<nanoseconds>).
|
||||
var planID = regexp.MustCompile(`^(plan|release)-[0-9]+$`)
|
||||
|
||||
// planIDArguments are the arguments of plans that name a plan. wait is among them for when it is declared
|
||||
// (novox/hq issue 229); an argument not declared is refused before this is asked.
|
||||
var planIDArguments = []string{"id", "wait", "stop", "close", "retry", "go"}
|
||||
|
||||
// plainPlanIDs refuses a plans call naming a plan by anything but a plan's id (novox/hq issue 561): checked
|
||||
// once, where the call becomes a command line, so nothing else a caller sent reaches a command or the store.
|
||||
func plainPlanIDs(given map[string]string) error {
|
||||
for _, arg := range planIDArguments {
|
||||
if id, named := given[arg]; named && !planID.MatchString(id) {
|
||||
return fmt.Errorf("plans %s: %q is not a plan's id (plan-<digits> or release-<digits>, as `plans` "+
|
||||
"lists them); nothing was done", arg, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A plan's id is checked once, where a call becomes a command line** (novox/hq issue 561): every argument of
|
||||
// plans that names a plan is refused unless it is an id the mesh gives a plan, so a quote never reaches a
|
||||
// command, nor the store behind it.
|
||||
func TestAPlanIdThatIsNoPlansIdIsRefusedBeforeACommandStarts(t *testing.T) {
|
||||
for _, arg := range []string{"id", "stop", "close", "retry", "go"} {
|
||||
for _, bad := range []string{"x' or '1'='1", "plan-1'", "plan-", "plan-1 2", "batch-1", "PLAN-1"} {
|
||||
args := map[string]any{arg: bad}
|
||||
if arg == "stop" || arg == "close" || arg == "go" {
|
||||
args["why"] = "a test"
|
||||
}
|
||||
argv, err := argvFor("plans", args)
|
||||
if err == nil {
|
||||
t.Errorf("plans %s %q was composed: %v", arg, bad, argv)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not a plan's id") {
|
||||
t.Errorf("plans %s %q was refused for another reason: %v", arg, bad, err)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"plan-1791813626034291775", "release-1791811938267404408"} {
|
||||
args := map[string]any{arg: good}
|
||||
if arg == "stop" || arg == "close" || arg == "go" {
|
||||
args["why"] = "a test"
|
||||
}
|
||||
if _, err := argvFor("plans", args); err != nil {
|
||||
t.Errorf("plans %s %q was refused: %v", arg, good, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Following a plan (novox/hq issue 229): `plans wait <id>` holds its answer until the plan moves, reaches its
|
||||
// next tier or ends, or until its bound passes, and says which. Before it, an agent following a rollout had
|
||||
// `plans` answer once, in prose, and wrote a loop of its own round the mesh's tools to be told the plan moved.
|
||||
//
|
||||
// **No wait is longer than a call is answered within** (link.LongestAnswer, inside the console's thirty
|
||||
// seconds): a longer one would answer "still running" and leave its caller to ask `calls` — the loop again.
|
||||
// So a caller following a plan to its end asks again; each call answers the moment the plan moves.
|
||||
|
||||
// maxPlanWait is the longest bound a wait takes, and the bound when none is given: what a call is answered
|
||||
// within, less the time a verb's command takes to start and read the store.
|
||||
var maxPlanWait = link.LongestAnswer - planWaitStart
|
||||
|
||||
// planWaitStart is what the verb's window allows beyond the bound: starting the command and opening the stores.
|
||||
// A store slow enough to push the read and the answer past it degrades as any long call does: the caller is
|
||||
// answered that the call is still running, and `calls` with its id says what came of it.
|
||||
const planWaitStart = 5 * time.Second
|
||||
|
||||
// planWaitEvery is how often a wait reads the plan again.
|
||||
const planWaitEvery = time.Second
|
||||
|
||||
// The things a wait waits until.
|
||||
var planWaitUntil = []string{"moved", "tier", "done"}
|
||||
|
||||
// planWaitAsked checks what a wait is asked for: until (moved when empty) and a bound (a duration, or a number
|
||||
// of seconds; maxPlanWait when empty).
|
||||
func planWaitAsked(until, bound string) (string, time.Duration, error) {
|
||||
if until == "" {
|
||||
until = "moved"
|
||||
}
|
||||
known := false
|
||||
for _, u := range planWaitUntil {
|
||||
known = known || u == until
|
||||
}
|
||||
if !known {
|
||||
return "", 0, fmt.Errorf("a wait is until %s, not %q", strings.Join(planWaitUntil, ", "), until)
|
||||
}
|
||||
d := maxPlanWait
|
||||
if bound = strings.TrimSpace(bound); bound != "" {
|
||||
var err error
|
||||
if secs, isNumber := strconv.Atoi(bound); isNumber == nil {
|
||||
d = time.Duration(secs) * time.Second
|
||||
} else if d, err = time.ParseDuration(bound); err != nil {
|
||||
return "", 0, fmt.Errorf("the bound %q is not a duration (such as 20s) or a number of seconds", bound)
|
||||
}
|
||||
if d <= 0 || d > maxPlanWait {
|
||||
return "", 0, fmt.Errorf("the bound is at most %s, the longest a call is answered within; %s was asked "+
|
||||
"— to follow a plan longer, ask again: each call answers the moment the plan moves", maxPlanWait, d)
|
||||
}
|
||||
}
|
||||
return until, d, nil
|
||||
}
|
||||
|
||||
// planEnded is a plan that will not move again: done, failed or superseded. A batch being assembled is not one.
|
||||
func planEnded(p inventory.Plan) bool { return !p.Open() && !p.Batch() }
|
||||
|
||||
// waitOnPlan reads the plan until what it waits for happens or the bound passes, and answers the plan as last
|
||||
// read and what ended the wait: moved (any save of it), tier (its tier changed), done (it ended — which ends
|
||||
// every wait, since nothing comes after it), or bound. Moved and tier are judged against the plan as first read.
|
||||
func waitOnPlan(ctx context.Context, read func(context.Context) (inventory.Plan, error), until string,
|
||||
bound, every time.Duration) (inventory.Plan, string, error) {
|
||||
first, err := read(ctx)
|
||||
if err != nil {
|
||||
return inventory.Plan{}, "", err
|
||||
}
|
||||
if planEnded(first) {
|
||||
return first, "done", nil
|
||||
}
|
||||
deadline := time.Now().Add(bound)
|
||||
tick := time.NewTicker(every)
|
||||
defer tick.Stop()
|
||||
last := first
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return last, "", ctx.Err()
|
||||
case <-tick.C:
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return last, "bound", nil
|
||||
}
|
||||
p, err := read(ctx)
|
||||
if err != nil {
|
||||
return last, "", err
|
||||
}
|
||||
last = p
|
||||
switch {
|
||||
case planEnded(p):
|
||||
return p, "done", nil
|
||||
case until == "moved" && p.Revision != first.Revision:
|
||||
return p, "moved", nil
|
||||
case until == "tier" && p.Tier != first.Tier:
|
||||
return p, "tier", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// planWaited is what `plans wait` answers: the plan, what ended the wait, and the line a person reads (`text`,
|
||||
// which the verb answers as its output and not again in the data).
|
||||
type planWaited struct {
|
||||
// Came is what ended the wait: moved, tier, done, or bound when nothing did.
|
||||
Came string `json:"came"`
|
||||
Until string `json:"until"`
|
||||
Waited string `json:"waited"`
|
||||
Plan planStatus `json:"plan"`
|
||||
// Revision is the plan's as read: a caller asking again with the same plan sees whether it moved since.
|
||||
Revision int64 `json:"revision"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func planWaitedDoc(p inventory.Plan, st planStatus, until, came string, waited time.Duration, line string) planWaited {
|
||||
said := map[string]string{
|
||||
"moved": "moved", "tier": "reached another tier", "done": "ended",
|
||||
"bound": "did not " + map[string]string{"moved": "move", "tier": "reach another tier", "done": "end"}[until],
|
||||
}[came]
|
||||
text := fmt.Sprintf("%s %s in %s (waited until %s)\n%s — %s\n", p.ID, said, waited.Round(time.Second), until,
|
||||
p.ID, line)
|
||||
return planWaited{Came: came, Until: until, Waited: waited.Round(time.Millisecond).String(), Plan: st,
|
||||
Revision: p.Revision, Text: text}
|
||||
}
|
||||
|
||||
// planWaitCommand is `plans wait <id>`: the plan followed until it moves, or the bound passes.
|
||||
func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsked, boundAsked string) error {
|
||||
until, bound, err := planWaitAsked(untilAsked, boundAsked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
began := time.Now()
|
||||
p, came, err := waitOnPlan(ctx, func(ctx context.Context) (inventory.Plan, error) {
|
||||
return inv.PlanByID(ctx, id)
|
||||
}, until, bound, planWaitEvery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Said as `plans <id>` says its first line, and as `status` says the plan.
|
||||
now, one := time.Now(), []inventory.Plan{p}
|
||||
bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one)
|
||||
line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}, waitsOf(ctx, inv, one))
|
||||
doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line)
|
||||
return json.NewEncoder(os.Stdout).Encode(doc)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A plan as the store would answer it at each read: the reads are counted, and each answers the next.
|
||||
type planReads struct {
|
||||
plans []inventory.Plan
|
||||
n int
|
||||
}
|
||||
|
||||
func (r *planReads) read(context.Context) (inventory.Plan, error) {
|
||||
p := r.plans[min(r.n, len(r.plans)-1)]
|
||||
r.n++
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func followedPlan(rev int64, state string, tier int) inventory.Plan {
|
||||
return inventory.Plan{ID: "plan-1", Repository: "novox/x", Commit: "abc", State: state, Tier: tier,
|
||||
Tiers: [][]string{{"a"}, {"b"}}, Revision: rev}
|
||||
}
|
||||
|
||||
// **`plans` waits for a plan, and says what ended the wait** (novox/hq issue 229): an agent following a
|
||||
// rollout is told when the plan moved, reached its next tier or ended, or that the bound passed — in one
|
||||
// call, without a loop of its own.
|
||||
func TestAWaitForAPlanEndsWhenWhatItWaitsForHappens(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
until string
|
||||
reads []inventory.Plan
|
||||
came string
|
||||
times int
|
||||
}{
|
||||
{"moved: any save", "moved", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(1, inventory.PlanBuilding, 0),
|
||||
followedPlan(2, inventory.PlanBuilding, 0)}, "moved", 3},
|
||||
{"tier: a save inside the tier is not enough", "tier", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanBuilding, 0),
|
||||
followedPlan(3, inventory.PlanRolling, 1)}, "tier", 3},
|
||||
{"tier: a plan that ended has no next tier to wait for", "tier", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanFailed, 0)}, "done", 2},
|
||||
{"done: a new tier is not the end", "done", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanRolling, 1),
|
||||
followedPlan(3, inventory.PlanDone, 1)}, "done", 3},
|
||||
{"done: a plan already ended answers at once", "done", []inventory.Plan{
|
||||
followedPlan(4, inventory.PlanSuperseded, 1)}, "done", 1},
|
||||
{"the bound passes", "moved", []inventory.Plan{followedPlan(1, inventory.PlanBuilding, 0)}, "bound", -1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &planReads{plans: c.reads}
|
||||
p, came, err := waitOnPlan(context.Background(), r.read, c.until, 50*time.Millisecond, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if came != c.came {
|
||||
t.Fatalf("the wait ended as %q, want %q", came, c.came)
|
||||
}
|
||||
if c.times > 0 && r.n != c.times {
|
||||
t.Errorf("read the plan %d times, want %d: it answered before or after what it waited for", r.n, c.times)
|
||||
}
|
||||
if want := c.reads[min(r.n, len(c.reads))-1]; p.Revision != want.Revision {
|
||||
t.Errorf("answered revision %d, not the plan as it was last read (%d)", p.Revision, want.Revision)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What a wait is asked for is checked before anything is read: what it waits until, and a bound no longer than
|
||||
// a call is answered within.
|
||||
func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) {
|
||||
for _, bad := range []struct{ until, bound string }{{"landed", ""}, {"", "1h"}, {"", "-3s"}, {"", "soon"}} {
|
||||
if _, _, err := planWaitAsked(bad.until, bad.bound); err == nil {
|
||||
t.Errorf("until %q, bound %q was taken", bad.until, bad.bound)
|
||||
}
|
||||
}
|
||||
until, bound, err := planWaitAsked("", "")
|
||||
if err != nil || until != "moved" || bound != maxPlanWait {
|
||||
t.Fatalf("the defaults are %q %s %v, want moved and %s", until, bound, err, maxPlanWait)
|
||||
}
|
||||
if _, bound, _ := planWaitAsked("tier", "12"); bound != 12*time.Second {
|
||||
t.Errorf("a bare number is seconds; read %s", bound)
|
||||
}
|
||||
}
|
||||
|
||||
// **The answer is data, with its prose as a field beside it** (novox/hq issue 229): the verb's `answer` carries
|
||||
// the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string.
|
||||
func TestAWaitForAPlanAnswersAsData(t *testing.T) {
|
||||
p := followedPlan(7, inventory.PlanRolling, 1)
|
||||
st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0]
|
||||
doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2")
|
||||
stdout, _ := json.Marshal(doc)
|
||||
a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true)
|
||||
data, ok := a.Answer.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("the answer is not data: %v", a.Answer)
|
||||
}
|
||||
if data["came"] != "tier" || data["revision"] != float64(7) {
|
||||
t.Errorf("the data does not say what ended the wait: %v", data)
|
||||
}
|
||||
if plan, _ := data["plan"].(map[string]any); plan["state"] != "rolling" || plan["tier"] != float64(1) {
|
||||
t.Errorf("the data does not carry the plan: %v", data["plan"])
|
||||
}
|
||||
if _, text := data["text"]; text {
|
||||
t.Errorf("the prose is in the data too: %v", data)
|
||||
}
|
||||
if strings.Contains(a.Output, "{") || !strings.Contains(a.Output, "plan-1") || !strings.Contains(a.Output, "tier") {
|
||||
t.Errorf("the output is not the line a person reads: %q", a.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// The verb composes the wait, and asks for a window as long as its bound.
|
||||
func TestThePlansVerbComposesAWait(t *testing.T) {
|
||||
argv, err := argvFor("plans", map[string]any{"wait": "plan-1", "until": "done", "bound": "15s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(argv, " "); got != "plans wait plan-1 --until done --bound 15s" {
|
||||
t.Fatalf("composed %q", got)
|
||||
}
|
||||
if _, err := argvFor("plans", map[string]any{"until": "done"}); err == nil {
|
||||
t.Error("until without wait was taken")
|
||||
}
|
||||
if _, err := argvFor("plans", map[string]any{"wait": "plan-1", "bound": "1h"}); err == nil {
|
||||
t.Error("a bound past what a call can wait was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Live, with the store: `plans wait` run as the verb runs it answers when the plan reaches its next tier, as
|
||||
// data, and says the bound passed when nothing moved (novox/hq issue 229).
|
||||
func TestAPlanFollowedFromTheStoreAnswersWhenItMoves(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC()
|
||||
walk := inventory.Plan{ID: "plan-229", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c229c229",
|
||||
Created: now, State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}}
|
||||
if err := open.inventory.SavePlan(ctx, &walk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := make(chan error, 1)
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
p, err := open.inventory.PlanByID(ctx, walk.ID)
|
||||
if err == nil {
|
||||
p.Tier, p.State = 1, inventory.PlanRolling
|
||||
err = open.inventory.SavePlan(ctx, &p)
|
||||
}
|
||||
moved <- err
|
||||
}()
|
||||
began := time.Now()
|
||||
var got planWaited
|
||||
out := printed(t, func() error {
|
||||
return plansCommand(ctx, []string{"wait", walk.ID, "--until", "tier", "--bound", "15s"})
|
||||
})
|
||||
if err := <-moved; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("plans wait did not answer data: %v: %q", err, out)
|
||||
}
|
||||
if got.Came != "tier" || got.Plan.Tier != 1 || got.Plan.State != inventory.PlanRolling {
|
||||
t.Fatalf("the wait answered %+v", got)
|
||||
}
|
||||
if waited := time.Since(began); waited > 10*time.Second {
|
||||
t.Errorf("it answered after %s, not when the plan moved", waited)
|
||||
}
|
||||
out = printed(t, func() error {
|
||||
return plansCommand(ctx, []string{"wait", walk.ID, "--bound", "2"})
|
||||
})
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil || got.Came != "bound" || got.Until != "moved" {
|
||||
t.Fatalf("a plan that did not move answered %+v (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The verb's schema says the longest bound in words, and the words are the value (review 75 on #266): the
|
||||
// catalogue cannot read maxPlanWait, so this holds them together.
|
||||
func TestThePlansSchemaSaysTheLongestBoundThereIs(t *testing.T) {
|
||||
v, ok := controllerVerb("plans")
|
||||
if !ok {
|
||||
t.Fatal("no plans verb")
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
bound, _ := props["bound"].(map[string]any)
|
||||
said, _ := bound["description"].(string)
|
||||
if !strings.Contains(said, maxPlanWait.String()+", the default, is the longest") {
|
||||
t.Fatalf("the schema says %q; the longest bound is %s", said, maxPlanWait)
|
||||
}
|
||||
}
|
||||
@@ -1027,9 +1027,6 @@ func (b overTheBus) declare(ctx context.Context, s readyNode, body []byte) (stri
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A send that carried the mesh's record of the controller and the node-engine — a person's push included —
|
||||
// ends the condition of one kept over it (novox/hq issue 201).
|
||||
settleCoreDowngrades(ctx, b.open.inventory, s.node, s.declared.Builds)
|
||||
if len(s.declared.Bindings) > 0 {
|
||||
// And where it bound each consumer of a provision that keeps its data (novox/hq ADR 0232):
|
||||
// what the next resolution keeps it at. On the same outliving context as the send's record.
|
||||
|
||||
@@ -135,17 +135,6 @@ func recordedKept(ctx context.Context, open *stores, node string) (map[string]st
|
||||
}
|
||||
out[m] = was
|
||||
}
|
||||
// **And the controller and the node-engine at the build the machine was last sent, over an older record**
|
||||
// (novox/hq issue 201, core_downgrade.go). Under keepingEveryBuild they are kept already.
|
||||
if !every {
|
||||
core, err := coreKept(ctx, inv, node, sent, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for m, was := range core {
|
||||
out[m] = was
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -170,12 +159,6 @@ func keepRecorded(ctx context.Context, open *stores, node string, shelf map[stri
|
||||
return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+
|
||||
"list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m]))
|
||||
}
|
||||
if !found && guarded(coreComponent(m)) {
|
||||
return nil, fmt.Errorf("%s was last sent %s's build %s, newer than the one the mesh holds, and the build records "+
|
||||
"no longer hold it: this send cannot keep it and does not move it back — `push %s` sends the older build on "+
|
||||
"a person's word, and a newer merge or `rebuild %s` moves the record forward (novox/hq issue 201)",
|
||||
node, m, short(kept[m]), node, m)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%s records rather than rolls out, and %s runs its build %s, which the build "+
|
||||
"records no longer hold: this send cannot keep it and does not move it — `push %s` sends the new "+
|
||||
|
||||
@@ -51,9 +51,6 @@ type sendScope struct {
|
||||
modules map[string]bool
|
||||
// person is a person's act: it carries what it carries.
|
||||
person bool
|
||||
// puttingBack is a gate's own put-back of its modules: the one send that may carry a core module to a build
|
||||
// older than the one its machine runs (novox/hq issue 201).
|
||||
puttingBack bool
|
||||
}
|
||||
|
||||
type sendScopeKey struct{}
|
||||
@@ -240,47 +237,6 @@ var machineMoves = func(ctx context.Context, open *stores, f moveFacts, node str
|
||||
return f.moves(node, modules, sent, known, all), nil
|
||||
}
|
||||
|
||||
// withoutKept is a machine's moves less those its send keeps at the build the machine was last sent, and so does
|
||||
// not make: the grants step keeps every module (novox/hq issue 490), and every send keeps the controller and the
|
||||
// node-engine over an older record (issue 201). Read as the send composes it (sendKeeps, set after the checks that
|
||||
// call this), so a move it could not keep is still counted.
|
||||
func withoutKept(ctx context.Context, open *stores, node string, moves []inventory.CarriedMove) ([]inventory.CarriedMove, error) {
|
||||
if len(moves) == 0 {
|
||||
return moves, nil
|
||||
}
|
||||
keeping := ctx
|
||||
if _, on := keptExcept(ctx); !on {
|
||||
keeping = keepingRecorded(ctx)
|
||||
}
|
||||
keeps, err := recordedKept(keeping, open, node)
|
||||
if err != nil {
|
||||
return moves, err
|
||||
}
|
||||
// And a joining module held at the build it runs (novox/hq ADR 0328 §8): its new build is no move this send makes,
|
||||
// and judging it would read the gate as superseded by its own send (the re-review of 96e15f86, item 1).
|
||||
held, err := holdJoining(ctx, open.inventory, node, nil)
|
||||
if err != nil {
|
||||
return moves, err
|
||||
}
|
||||
if len(held) > 0 && keeps == nil {
|
||||
keeps = map[string]string{}
|
||||
}
|
||||
for m, h := range held {
|
||||
keeps[m] = h.commit
|
||||
}
|
||||
if len(keeps) == 0 {
|
||||
return moves, nil
|
||||
}
|
||||
var out []inventory.CarriedMove
|
||||
for _, mv := range moves {
|
||||
if was, kept := keeps[mv.Module]; kept && sameCommit(was, mv.From) {
|
||||
continue
|
||||
}
|
||||
out = append(out, mv)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ungatedIn refuses a send whose machines would move a build no gate has seen, outside its scope: the
|
||||
// machines named, and why; the machine holding the bus, added only for its user list, is left instead.
|
||||
func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder string) ([]string, error) {
|
||||
@@ -298,11 +254,19 @@ func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if moves, err = withoutKept(ctx, open, n, moves); err != nil {
|
||||
return nil, err
|
||||
// The grants step composes every module at the build its machine was last sent (novox/hq issue 490):
|
||||
// a move it keeps is not made. Read, not assumed, so a move it could not keep is still refused here.
|
||||
var keeps map[string]string
|
||||
if everyBuildKept(ctx, n) {
|
||||
if keeps, err = recordedKept(ctx, open, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var waiting []string
|
||||
for _, mv := range moves {
|
||||
if was, kept := keeps[mv.Module]; kept && sameCommit(was, mv.From) {
|
||||
continue
|
||||
}
|
||||
if !scope.judged[n] && !scope.modules[mv.Module] {
|
||||
waiting = append(waiting, fmt.Sprintf("%s %s → %s", mv.Module, short(mv.From), short(mv.To)))
|
||||
}
|
||||
@@ -344,11 +308,6 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
// A move the send keeps is not made, and is no move for the gate to judge (novox/hq issue 201): a core module
|
||||
// kept over an older record judged as moving back to it would read the gate as superseded by its own send.
|
||||
if moves, err = withoutKept(ctx, open, node, moves); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
own := func(module string) bool {
|
||||
return slices.ContainsFunc(owns, func(o inventory.CarriedMove) bool { return o.Module == module })
|
||||
}
|
||||
|
||||
@@ -690,7 +690,7 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
// being behind. Now every module of the tier whose first machine is the same is sent there in one
|
||||
// send, judged there by one gate — kept on the first of them, the others gated by it — and the rest
|
||||
// of the tier's machines are sent once, together, when the gates have passed.
|
||||
var pending, waitsForAPerson []string
|
||||
var pending []string
|
||||
firstOn := map[string][]string{}
|
||||
runningOf := map[string][]string{}
|
||||
var rest []string
|
||||
@@ -706,20 +706,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
return false, err
|
||||
}
|
||||
runningOf[m] = running
|
||||
// **A joining build held for a person is not sent** (novox/hq ADR 0328 §8, the re-review of 96e15f86): its
|
||||
// machines keep the build they run until its library is taken (or their engine keeps own groups), so sending
|
||||
// it would send nothing new and judge the old build. It waits for a person, said; the plan stays open, the other
|
||||
// modules are sent and judged, and it is sent on the walk after nothing holds it — no new merge.
|
||||
if state.FirstAt == nil {
|
||||
why, err := heldIn(ctx, inv, m, running)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if why != "" {
|
||||
waitsForAPerson = append(waitsForAPerson, why)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// **A build whose source is unchanged is never a move** (novox/hq issue 280): made from the source
|
||||
// of the build every machine running it was sent, it is registered with that build's artifacts —
|
||||
// nothing to send, and nothing for a gate to judge.
|
||||
@@ -882,17 +868,9 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
strings.Join(sent, ", "))
|
||||
return true, nil
|
||||
}
|
||||
if len(pending) > 0 || len(waitsForAPerson) > 0 {
|
||||
note := "tier " + fmt.Sprint(p.Tier) + " built; "
|
||||
if len(pending) > 0 {
|
||||
note += "waiting for " + strings.Join(pending, "; ") + " to report it applied before the rest are sent"
|
||||
}
|
||||
if len(waitsForAPerson) > 0 {
|
||||
if len(pending) > 0 {
|
||||
note += "; and "
|
||||
}
|
||||
note += "waiting for a person: " + strings.Join(waitsForAPerson, "; ")
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
note := "tier " + fmt.Sprint(p.Tier) + " built; waiting for " + strings.Join(pending, "; ") +
|
||||
" to report it applied before the rest are sent"
|
||||
changed := p.State != inventory.PlanRolling || p.Note != note
|
||||
p.State = inventory.PlanRolling
|
||||
p.Note = note
|
||||
@@ -1485,10 +1463,6 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
modules := set.String("modules", "", "or the modules it would change, comma-separated")
|
||||
moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+
|
||||
"comma-separated, as the forge's announcer says them (novox/hq issue 278)")
|
||||
// Following a plan (novox/hq issue 229): `plans wait <id>`.
|
||||
until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)")
|
||||
bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+
|
||||
"longest a call is answered within)")
|
||||
// Ending a plan by hand is a repair, and says why (novox/hq to-be 45 §7).
|
||||
why := addHandActFlags(set)
|
||||
positionals, err := parseAround(set, args)
|
||||
@@ -1508,9 +1482,6 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
now := time.Now()
|
||||
if len(positionals) == 2 && positionals[0] == "wait" {
|
||||
return planWaitCommand(ctx, inv, positionals[1], *until, *bound)
|
||||
}
|
||||
if len(positionals) == 1 {
|
||||
p, err := inv.PlanByID(ctx, positionals[0])
|
||||
if err != nil {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// TestReplay401 replays novox/hq issue 401 (2026-10-10): the mounts module's smb-credentials, an own secret
|
||||
// only an outside party issues, was given by nobody, and the send made a random value for it and placed it on
|
||||
// the machine; the module read it as a credential given and wrong. The outcome asserted (ADR 0228): the send
|
||||
// makes no value for an own secret issued outside the mesh that nobody gave and places none, and a value the
|
||||
// mesh made for it before is not placed either; once a person gives one, that value is placed. Written with
|
||||
// only what the controller had before its fix (mesh-controller #262) — register, assign, planFor,
|
||||
// declarationFor and the inventory's secret reads — so it is laid over the commit before. Needs the store
|
||||
// (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
|
||||
func TestReplay401(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
const made, outside = "/var/lib/mesh/mounts/password", "/var/lib/mesh/mounts/smb-credentials"
|
||||
register(t, open, catalogue.Manifest{Module: "mounts", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{
|
||||
"password": {Path: made, Taken: catalogue.TakenAtStart},
|
||||
"smb-credentials": {Path: outside, Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
|
||||
},
|
||||
Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "path": "/var/lib/mesh/mounts", "mode": "0700"},
|
||||
}})
|
||||
if _, err := assign(ctx, open, "laptop", "mounts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := func() []map[string]any {
|
||||
t.Helper()
|
||||
plan, settings, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
|
||||
if err != nil {
|
||||
t.Fatalf("the send to laptop did not compose: %v", err)
|
||||
}
|
||||
return declared.Resources
|
||||
}
|
||||
placed := func(resources []map[string]any, path string) map[string]any {
|
||||
for _, r := range resources {
|
||||
if r["path"] == path && r["sealed"] != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
first := sent()
|
||||
if placed(first, made) == nil {
|
||||
t.Fatal("the secret the mesh makes was not placed")
|
||||
}
|
||||
if got := placed(first, outside); got != nil {
|
||||
t.Errorf("the send placed a value for smb-credentials, which nobody gave: %v", got)
|
||||
}
|
||||
if _, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "mounts", "smb-credentials"); err != nil || held {
|
||||
t.Fatalf("the send made a value for a secret issued outside the mesh (held %v, %v)", held, err)
|
||||
}
|
||||
|
||||
// A value the mesh made for it before the rule is not one a person gave: it is not placed.
|
||||
if _, err := open.inventory.SecretForModule(ctx, "laptop", "mounts", "smb-credentials"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := placed(sent(), outside); got != nil {
|
||||
t.Fatalf("a value the mesh made for a secret issued outside was placed: %v", got)
|
||||
}
|
||||
|
||||
// Given by a person, it is placed, as given.
|
||||
if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "mounts", "smb-credentials", "the share's credentials"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "mounts", "smb-credentials")
|
||||
if err != nil || !held {
|
||||
t.Fatalf("the value given is not held (%v)", err)
|
||||
}
|
||||
if got := placed(sent(), outside); got == nil || got["sealed"] != given {
|
||||
t.Fatalf("the value given is placed as %v", got)
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,8 @@ type seatRow struct {
|
||||
Delivers string `json:"delivers,omitempty"`
|
||||
Decision string `json:"decision"`
|
||||
// Replicated says the seat may be held on several machines at once (novox/hq ADR 0223).
|
||||
Replicated bool `json:"replicated,omitempty"`
|
||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287).
|
||||
Paths []catalogue.SeatPath `json:"paths,omitempty"`
|
||||
Holders []seatHolder `json:"holders"`
|
||||
Replicated bool `json:"replicated,omitempty"`
|
||||
Holders []seatHolder `json:"holders"`
|
||||
}
|
||||
|
||||
// seatsHeld is every seat the mesh defines with its holders, and every claim held that names no
|
||||
@@ -51,7 +49,7 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata
|
||||
rows := make([]seatRow, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision,
|
||||
Replicated: s.Replicated, Paths: s.Paths, Holders: []seatHolder{}}
|
||||
Replicated: s.Replicated, Holders: []seatHolder{}}
|
||||
seen := map[seatHolder]bool{}
|
||||
for _, h := range held {
|
||||
// Resolve the held claim to a seat rather than comparing names, so a record naming a
|
||||
@@ -257,16 +255,12 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
rows, outside := seatsHeld(catalogue.Seats(), world.Held)
|
||||
// Which module owns each seat's path on each machine (novox/hq ADR 0287): the seat naming it, and
|
||||
// that seat's holder there, with the stepping back applied.
|
||||
owners := catalogue.PathOwners(catalogue.Seats(), world.Held)
|
||||
|
||||
if *asJSON {
|
||||
out := struct {
|
||||
Seats []seatRow `json:"seats"`
|
||||
Owners []catalogue.PathOwner `json:"path-owners,omitempty"`
|
||||
Outside []catalogue.Held `json:"outside,omitempty"`
|
||||
}{rows, owners, outside}
|
||||
Seats []seatRow `json:"seats"`
|
||||
Outside []catalogue.Held `json:"outside,omitempty"`
|
||||
}{rows, outside}
|
||||
body, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -276,7 +270,7 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tPATHS\tHELD BY")
|
||||
fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tHELD BY")
|
||||
for _, r := range rows {
|
||||
delivers := r.Delivers
|
||||
if delivers == "" {
|
||||
@@ -290,22 +284,11 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
holders = strings.Join(parts, ", ")
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, seatPathsText(r.Paths), holders)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, holders)
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(owners) > 0 {
|
||||
fmt.Println("\nwho owns each seat's path, by machine (novox/hq ADR 0287):")
|
||||
w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "PATH\tMACHINE\tSEAT\tMODULE")
|
||||
for _, o := range owners {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", o.Path, o.Node, o.Seat, o.Module)
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(outside) > 0 {
|
||||
fmt.Println("\nheld, and not a seat this mesh defines (registered before the set closed — novox/hq ADR 0110):")
|
||||
for _, h := range outside {
|
||||
@@ -314,19 +297,3 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seatPathsText is a seat's paths in one column: each path, and the seat it steps back for, if any.
|
||||
func seatPathsText(paths []catalogue.SeatPath) string {
|
||||
if len(paths) == 0 {
|
||||
return "—"
|
||||
}
|
||||
parts := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if p.StepsBackFor != "" {
|
||||
parts = append(parts, p.Path+" (steps back for "+p.StepsBackFor+")")
|
||||
continue
|
||||
}
|
||||
parts = append(parts, p.Path)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
@@ -74,22 +74,3 @@ func TestAKindedBenchIsNotHandedOver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The listing shows each seat's paths, and the seat a path steps back for (novox/hq ADR 0287).
|
||||
func TestTheSeatsListingShowsEachSeatsPaths(t *testing.T) {
|
||||
rows, _ := seatsHeld(catalogue.Seats(), nil)
|
||||
got := map[string]string{}
|
||||
for _, r := range rows {
|
||||
got[r.Seat] = seatPathsText(r.Paths)
|
||||
}
|
||||
for seat, want := range map[string]string{
|
||||
"node-hostname": "/etc/hosts, /etc/hostname",
|
||||
"node-uplink": "/etc/resolv.conf (steps back for node-resolver)",
|
||||
"node-resolver": "/etc/resolv.conf",
|
||||
"node-packet-filter": "—",
|
||||
} {
|
||||
if got[seat] != want {
|
||||
t.Errorf("%s lists its paths as %q, not %q", seat, got[seat], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,9 +336,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
}
|
||||
return argv, nil
|
||||
case "plans":
|
||||
if err := plainPlanIDs(a.given); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r := str("repository"); r != "" {
|
||||
argv := []string{"plans", "--what-if", r}
|
||||
if p := str("paths"); p != "" {
|
||||
@@ -368,21 +365,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return argv, nil
|
||||
}
|
||||
}
|
||||
if id := str("wait"); id != "" {
|
||||
// Following a plan (novox/hq issue 229): checked here too, so a call asking what no wait can do is
|
||||
// refused before a command is started.
|
||||
if _, _, err := planWaitAsked(a.given["until"], a.given["bound"]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := []string{"plans", "wait", id}
|
||||
if u := str("until"); u != "" {
|
||||
argv = append(argv, "--until", u)
|
||||
}
|
||||
if b := str("bound"); b != "" {
|
||||
argv = append(argv, "--bound", b)
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
if id := str("id"); id != "" {
|
||||
return []string{"plans", id}, nil
|
||||
}
|
||||
@@ -1003,14 +985,12 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co
|
||||
// The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239.
|
||||
"delivery": true,
|
||||
// The build queue, each ask with its position among the waiting (novox/hq issue 455).
|
||||
"queue": true,
|
||||
// A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data.
|
||||
"plans": true}
|
||||
"queue": true}
|
||||
|
||||
// tableBesideVerbs are the JSON verbs whose document carries the table a person reads as its `text`: the
|
||||
// verb answers that table as its output, as it did before it answered data, and the rest as `answer` —
|
||||
// both from the one read, so they never disagree (novox/hq issue 455).
|
||||
var tableBesideVerbs = map[string]bool{"queue": true, "plans": true}
|
||||
var tableBesideVerbs = map[string]bool{"queue": true}
|
||||
|
||||
// overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the
|
||||
// command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs
|
||||
@@ -1284,16 +1264,6 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
// Whatever it did, `status` is composed again once it has.
|
||||
defer statusFrom.nudge()
|
||||
}
|
||||
if verb == "plans" && len(argv) > 1 && argv[1] == "wait" {
|
||||
// A wait holds its answer up to its bound (novox/hq issue 229), and is answered in full inside it.
|
||||
asked := ""
|
||||
if i := slices.Index(argv, "--bound"); i > 0 && i+1 < len(argv) {
|
||||
asked = argv[i+1]
|
||||
}
|
||||
if _, bound, err := planWaitAsked("", asked); err == nil {
|
||||
link.AnswersWithin(ctx, bound+planWaitStart)
|
||||
}
|
||||
}
|
||||
if answer, read := readHere(ctx, argv); read {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
@@ -18,12 +18,9 @@ import (
|
||||
// make a machine look out of date for ever, or send something `plan` never showed.
|
||||
type sendable struct {
|
||||
Resources []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 not sent at all when empty — an older engine
|
||||
// refuses a key it does not know, whole.
|
||||
Groups []catalogue.GroupEntry
|
||||
Identities []catalogue.IdentityEntry
|
||||
Libraries []catalogue.LibraryEntry
|
||||
// Identities is the declaration's `identities` (novox/hq ADR 0325 §3): composed only for an engine that keeps
|
||||
// folders, and not sent at all when empty — an older engine refuses a key it does not know, whole.
|
||||
Identities []map[string]any
|
||||
// Sequence orders this send against every other to the same node: one higher each time, taken
|
||||
// under the node's hold just before the body is made (novox/hq 04-ISSUES/107). Zero is not sent
|
||||
// at all, which a host reads as "no order claimed" — the shape of every declaration before this.
|
||||
@@ -116,15 +113,9 @@ func (s sendable) Body() ([]byte, error) {
|
||||
if s.Generation > 0 {
|
||||
envelope["generation"] = s.Generation
|
||||
}
|
||||
if len(s.Groups) > 0 {
|
||||
envelope["groups"] = s.Groups
|
||||
}
|
||||
if len(s.Identities) > 0 {
|
||||
envelope["identities"] = s.Identities
|
||||
}
|
||||
if len(s.Libraries) > 0 {
|
||||
envelope["libraries"] = s.Libraries
|
||||
}
|
||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||
|
||||
@@ -8,7 +8,6 @@ require (
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
github.com/novox/mesh-host v0.0.0
|
||||
github.com/therootcompany/xz v1.0.1
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.48.0
|
||||
@@ -36,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012222214-7a41e120635b
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012222214-7a41e120635b h1:PGdZJHUU9rCDucdrZIqSs4DGyxlo4lokvwmofpGk9RI=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012222214-7a41e120635b/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e h1:RxvY7T12EizGs2e9W1dVZFEnQFJBPNKRY+oUPgovcBc=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
@@ -38,8 +38,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
|
||||
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
|
||||
@@ -82,7 +82,7 @@ func theCarriedAccounts(t *testing.T) string {
|
||||
// the commit the mesh runs, or as captured in testdata/beside (internal/beside, novox/hq issue 432).
|
||||
func theTemplate(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
path := filepath.Join(beside.Dir(t, "mesh-host"), "examples", "foundation-first-node.lock")
|
||||
path := filepath.Join(beside.Dir(t, "mesh-host"), "examples", "foundation-first-node-nats.lock")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
BIN
Binary file not shown.
@@ -14,13 +14,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/therootcompany/xz"
|
||||
)
|
||||
|
||||
// An archive whose source is somebody else's published file, pinned by URL and sha256 (novox/hq ADR 0316
|
||||
@@ -183,6 +183,7 @@ func unpackUpstream(ctx context.Context, file, into, within string) error {
|
||||
buffered := bufio.NewReader(f)
|
||||
magic, _ := buffered.Peek(6)
|
||||
var stream io.Reader
|
||||
var wait func() error
|
||||
switch {
|
||||
case bytes.HasPrefix(magic, []byte{0x1f, 0x8b}):
|
||||
zipped, err := gzip.NewReader(buffered)
|
||||
@@ -194,33 +195,53 @@ func unpackUpstream(ctx context.Context, file, into, within string) error {
|
||||
case bytes.HasPrefix(magic, []byte("BZh")):
|
||||
stream = bzip2.NewReader(buffered)
|
||||
case bytes.HasPrefix(magic, []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}):
|
||||
// Read in Go (github.com/therootcompany/xz, a port of xz-embedded), never by a tool the build seat's image
|
||||
// may not carry: the seat's image is the catalogue's build-agent recipe, which installs git and docker-cli
|
||||
// alone, and a release pinned as .tar.xz failed there on 2026-10-12 (hq issue 345). xzDictMax is a real
|
||||
// maximum: a block whose LZMA2 dictionary is larger is refused before its dictionary is allocated
|
||||
// (ErrMemlimit). The reader keeps a filter decoder for every block of a stream, so the file's framing is
|
||||
// walked first and a file of more blocks than xzBlocksMax is refused before anything is decoded
|
||||
// (xz_framing.go). What the stream unpacks to is bounded by untar (maxUpstream, maxEntries).
|
||||
if err := xzWithinLimits(file); err != nil {
|
||||
// Go's standard library reads no xz; the build seat's image carries xz (cmd/mesh-builder/Dockerfile).
|
||||
if _, err := exec.LookPath("xz"); err != nil {
|
||||
return errors.New("it is compressed with xz, and this build seat has no xz to read it")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "xz", "-dc")
|
||||
cmd.Stdin = buffered
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zipped, err := xz.NewReader(buffered, xzDictMax)
|
||||
if err != nil {
|
||||
return fmt.Errorf("it is not an xz stream this build seat reads (a dictionary of %d MiB at most): %v",
|
||||
xzDictMax>>20, err)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
stream = zipped
|
||||
stream = out
|
||||
wait = func() error {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
return fmt.Errorf("xz could not read it: %v %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
if wait != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
_ = cmd.Wait()
|
||||
}
|
||||
}()
|
||||
default:
|
||||
return errors.New("it is not a tar archive compressed with gzip, bzip2 or xz; a single file is kept " +
|
||||
"whole by naming it in `file`")
|
||||
}
|
||||
return untar(stream, into, within)
|
||||
if err := untar(stream, into, within); err != nil {
|
||||
return err
|
||||
}
|
||||
if wait != nil {
|
||||
// Whatever follows the tar's end is read before xz is waited for: xz blocks on a full pipe otherwise.
|
||||
if _, err := io.Copy(io.Discard, stream); err != nil {
|
||||
return err
|
||||
}
|
||||
w := wait
|
||||
wait = nil
|
||||
return w()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// xzDictMax is the largest xz dictionary read: xz's own presets use at most 64 MiB (-9); a stream asking for
|
||||
// more is refused rather than given the memory.
|
||||
const xzDictMax = 64 << 20
|
||||
|
||||
// untar writes a tar stream's files and directories within `within` into a directory.
|
||||
func untar(stream io.Reader, into, within string) error {
|
||||
reader := tar.NewReader(stream)
|
||||
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,7 +15,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -386,84 +383,3 @@ func TestARedirectOffHttpsIsNotFollowed(t *testing.T) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzReleaseIsReadWithoutAnyXzToolOnTheBuildSeat(t *testing.T) {
|
||||
// The build seat's image (the catalogue's build-agent recipe) carries git and docker-cli alone: Betterbird's
|
||||
// .tar.xz failed there on 2026-10-12 with "this build seat has no xz to read it" (hq issue 345). It is
|
||||
// read in Go, with nothing on PATH.
|
||||
t.Setenv("PATH", "")
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "release.tar.xz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.xz": body}}
|
||||
base := serveVendor(t, v)
|
||||
packed, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "archive", Kind: "archive",
|
||||
URL: base + "/r.tar.xz", SHA256: sumHex(body), Version: "1", Within: "betterbird"}, func(string, string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := entriesOf(t, packed)
|
||||
if len(e) != 2 || e["betterbird"] != "#!/bin/sh\n -rwxr-xr-x" || e["defaults/p.js"] != "pref(\"a\", 1);\n -rw-r--r--" {
|
||||
t.Fatalf("%v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// withDictionary is an xz stream whose first block's LZMA2 dictionary property is props, its header's CRC32
|
||||
// recomputed: a stream asking for a dictionary of its own choosing.
|
||||
func withDictionary(t *testing.T, stream []byte, props byte) []byte {
|
||||
t.Helper()
|
||||
out := append([]byte{}, stream...)
|
||||
const at = 12 // the stream header
|
||||
size := (int(out[at]) + 1) * 4
|
||||
header := out[at : at+size]
|
||||
flags := header[1]
|
||||
i := 2
|
||||
readVarint := func() {
|
||||
for header[i]&0x80 != 0 {
|
||||
i++
|
||||
}
|
||||
i++
|
||||
}
|
||||
if flags&0x40 != 0 {
|
||||
readVarint()
|
||||
}
|
||||
if flags&0x80 != 0 {
|
||||
readVarint()
|
||||
}
|
||||
if header[i] != 0x21 || header[i+1] != 0x01 {
|
||||
t.Fatalf("the fixture's first filter is not LZMA2: % x", header)
|
||||
}
|
||||
header[i+2] = props
|
||||
binary.LittleEndian.PutUint32(header[size-4:], crc32.ChecksumIEEE(header[:size-4]))
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAnXzStreamAskingForTooLargeADictionaryIsRefused(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "release.tar.xz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 1 GiB (props 36) and 4 GiB less one (props 40): over the 64 MiB maximum, refused before allocation.
|
||||
for _, props := range []byte{36, 40} {
|
||||
crafted := withDictionary(t, body, props)
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.xz": crafted}}
|
||||
base := serveVendor(t, v)
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
_, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "archive", Kind: "archive",
|
||||
URL: base + "/r.tar.xz", SHA256: sumHex(crafted), Version: "1", Within: "betterbird"}, func(string, string, ...any) {})
|
||||
runtime.ReadMemStats(&after)
|
||||
if err == nil || !strings.Contains(err.Error(), "dictionary") {
|
||||
t.Errorf("props %d: a dictionary over the maximum was not refused: %v", props, err)
|
||||
}
|
||||
if grew := after.TotalAlloc - before.TotalAlloc; grew > 256<<20 {
|
||||
t.Errorf("props %d: %d bytes were allocated for a refused stream", props, grew)
|
||||
}
|
||||
}
|
||||
// The fixture as it is (a small dictionary) still reads.
|
||||
if crafted := withDictionary(t, body, 0); len(crafted) == 0 {
|
||||
t.Fatal("no fixture")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// xzBlocksMax and xzStreamsMax are the most blocks and the most concatenated streams an xz file may hold for
|
||||
// the build seat to read it.
|
||||
//
|
||||
// **Why a block count is limited at all.** The xz reader (github.com/therootcompany/xz v1.0.1) keeps one
|
||||
// filter decoder for every block that names a delta or BCJ filter, and frees them only when a stream ends:
|
||||
// its block counters are reset per stream, not per block. A stream of many tiny blocks therefore grows the
|
||||
// heap with its length: an `xz --block-size=1 --delta` file of 6 MB kept about 67 MiB live, which comes to
|
||||
// about 5.6 GiB at the 512 MiB download cap (review 68 on mesh-controller pull request 264, hq issue 345).
|
||||
//
|
||||
// **Why 1,024.** At about 340 bytes of decoder a block, 1,024 blocks keep at most about 0.35 MiB live. A
|
||||
// real release is far below it: Betterbird's archive is 1 stream with 1 block, and multi-threaded xz cuts
|
||||
// blocks of three times the dictionary (24 MiB at the default preset), so even 512 MiB unpacked in blocks
|
||||
// of 512 KiB stays within it. Streams are limited to 16 because a release is one stream; the decoders are
|
||||
// reused from one stream to the next, so the stream limit only bounds the work of this walk.
|
||||
const (
|
||||
xzBlocksMax = 1024
|
||||
xzStreamsMax = 16
|
||||
)
|
||||
|
||||
// xzWithinLimits walks an xz file's framing, without decompressing anything, and refuses one holding more
|
||||
// blocks or streams than the build seat reads.
|
||||
//
|
||||
// **The framing, not the index.** Each stream's index at its end lists its blocks, but the reader compares
|
||||
// the index with the blocks only after it has decoded them all, so a file whose index lies would still be
|
||||
// decoded block by block. This walk follows the same framing the reader follows: stream header, each block
|
||||
// header, the block's LZMA2 chunks (each chunk header carries its own compressed size, so a chunk is skipped
|
||||
// without being decoded), its padding and check, then the index and the footer. It counts the blocks the
|
||||
// reader would decode, and it also refuses an index whose count of blocks is not the count walked.
|
||||
func xzWithinLimits(file string) error {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, _, err = xzFraming(bufio.NewReader(f))
|
||||
if errors.Is(err, errXzTooManyBlocks) || errors.Is(err, errXzTooManyStreams) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("it is not an xz file this build seat reads: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errXzTooManyBlocks = fmt.Errorf("it holds more than %d xz blocks, the most this build seat reads: "+
|
||||
"its xz reader keeps a filter decoder for every block (hq issue 345)", xzBlocksMax)
|
||||
errXzTooManyStreams = fmt.Errorf("it holds more than %d concatenated xz streams, the most this build "+
|
||||
"seat reads", xzStreamsMax)
|
||||
)
|
||||
|
||||
// xzFraming counts the streams and blocks of an xz file, checking its framing as it goes. It stops with
|
||||
// errXzTooManyBlocks or errXzTooManyStreams at the first block or stream over the limit.
|
||||
func xzFraming(r *bufio.Reader) (streams, blocks int, err error) {
|
||||
full := func(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
if err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
skip := func(n int) error {
|
||||
if got, err := r.Discard(n); err != nil || got != n {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
header, err := full(12)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: header: %v", streams+1, err)
|
||||
}
|
||||
if !bytes.Equal(header[:6], []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}) {
|
||||
return streams, blocks, fmt.Errorf("stream %d: no xz stream header", streams+1)
|
||||
}
|
||||
if header[6] != 0 || header[7]&0xf0 != 0 ||
|
||||
crc32.ChecksumIEEE(header[6:8]) != binary.LittleEndian.Uint32(header[8:12]) {
|
||||
return streams, blocks, fmt.Errorf("stream %d: a corrupt stream header", streams+1)
|
||||
}
|
||||
if streams++; streams > xzStreamsMax {
|
||||
return streams, blocks, errXzTooManyStreams
|
||||
}
|
||||
checkSize := 0
|
||||
if check := int(header[7]); check != 0 {
|
||||
checkSize = 4 << ((check - 1) / 3)
|
||||
}
|
||||
inStream := 0
|
||||
for {
|
||||
first, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: no index: %v", streams, io.ErrUnexpectedEOF)
|
||||
}
|
||||
if first == 0 {
|
||||
break // the index indicator
|
||||
}
|
||||
rest, err := full(int(first)*4 + 3)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: header: %v", streams, inStream+1, err)
|
||||
}
|
||||
if err := xzBlockHeader(append([]byte{first}, rest...)); err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: %v", streams, inStream+1, err)
|
||||
}
|
||||
inStream++
|
||||
if blocks++; blocks > xzBlocksMax {
|
||||
return streams, blocks, errXzTooManyBlocks
|
||||
}
|
||||
// The block's LZMA2 chunks, up to the end marker.
|
||||
size := 0
|
||||
for {
|
||||
control, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: %v", streams, inStream, io.ErrUnexpectedEOF)
|
||||
}
|
||||
size++
|
||||
if control == 0 {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case control == 1 || control == 2: // stored: a 16-bit size less one, then the bytes
|
||||
b, err := full(2)
|
||||
if err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(b)) + 1
|
||||
if err := skip(n); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
size += 2 + n
|
||||
case control >= 0x80: // LZMA: unpacked and packed sizes, the properties byte on a reset
|
||||
b, err := full(4)
|
||||
if err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(b[2:])) + 1
|
||||
size += 4
|
||||
if control >= 0xc0 {
|
||||
if err := skip(1); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
size++
|
||||
}
|
||||
if err := skip(n); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
size += n
|
||||
default:
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: an LZMA2 chunk of kind %#x",
|
||||
streams, inStream, control)
|
||||
}
|
||||
}
|
||||
if pad := (4 - size%4) % 4; pad > 0 {
|
||||
b, err := full(pad)
|
||||
if err != nil || !bytes.Equal(b, make([]byte, pad)) {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: corrupt block padding", streams, inStream)
|
||||
}
|
||||
}
|
||||
if err := skip(checkSize); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
}
|
||||
// The index: its count of records, two numbers each, padding, CRC32.
|
||||
used := 1
|
||||
count, n, err := xzVLI(r)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err)
|
||||
}
|
||||
used += n
|
||||
if count != uint64(inStream) {
|
||||
return streams, blocks, fmt.Errorf("stream %d: its index lists %d blocks, but it holds %d",
|
||||
streams, count, inStream)
|
||||
}
|
||||
for i := 0; i < 2*inStream; i++ {
|
||||
_, n, err := xzVLI(r)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err)
|
||||
}
|
||||
used += n
|
||||
}
|
||||
if err := skip((4-used%4)%4 + 4); err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err)
|
||||
}
|
||||
footer, err := full(12)
|
||||
if err != nil || footer[10] != 'Y' || footer[11] != 'Z' {
|
||||
return streams, blocks, fmt.Errorf("stream %d: no stream footer", streams)
|
||||
}
|
||||
// Stream padding: zero bytes, a multiple of four, then the end or another stream.
|
||||
zeros := 0
|
||||
for {
|
||||
b, err := r.ReadByte()
|
||||
if err == io.EOF {
|
||||
if zeros%4 != 0 {
|
||||
return streams, blocks, errors.New("stream padding not a multiple of four bytes")
|
||||
}
|
||||
return streams, blocks, nil
|
||||
}
|
||||
if err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
if b != 0 {
|
||||
_ = r.UnreadByte()
|
||||
break
|
||||
}
|
||||
zeros++
|
||||
}
|
||||
if zeros%4 != 0 {
|
||||
return streams, blocks, errors.New("stream padding not a multiple of four bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xzBlockHeader checks one block header: its CRC32, its flags, and that its last filter is LZMA2, the only
|
||||
// one whose chunks this walk can skip (and the only last filter the reader decodes).
|
||||
func xzBlockHeader(h []byte) error {
|
||||
size := len(h)
|
||||
if crc32.ChecksumIEEE(h[:size-4]) != binary.LittleEndian.Uint32(h[size-4:]) {
|
||||
return errors.New("a corrupt block header")
|
||||
}
|
||||
flags := h[1]
|
||||
if flags&0x3c != 0 {
|
||||
return errors.New("a block header with reserved flags")
|
||||
}
|
||||
body := bytes.NewReader(h[2 : size-4])
|
||||
if flags&0x40 != 0 {
|
||||
if _, _, err := xzVLI(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if flags&0x80 != 0 {
|
||||
if _, _, err := xzVLI(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var last uint64
|
||||
for i := 0; i <= int(flags&3); i++ {
|
||||
id, _, err := xzVLI(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
props, _, err := xzVLI(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if props > uint64(body.Len()) {
|
||||
return errors.New("a corrupt block header")
|
||||
}
|
||||
if _, err := body.Seek(int64(props), io.SeekCurrent); err != nil {
|
||||
return err
|
||||
}
|
||||
last = id
|
||||
}
|
||||
if last != 0x21 {
|
||||
return fmt.Errorf("a block whose last filter is %#x, not LZMA2", last)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// xzVLI reads one of xz's variable-length integers (at most 9 bytes, 7 bits each), and how many bytes it took.
|
||||
func xzVLI(r io.ByteReader) (uint64, int, error) {
|
||||
var v uint64
|
||||
for i := 0; i < 9; i++ {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return 0, i, io.ErrUnexpectedEOF
|
||||
}
|
||||
v |= uint64(b&0x7f) << (7 * i)
|
||||
if b&0x80 == 0 {
|
||||
if i > 0 && b == 0 {
|
||||
return 0, i + 1, errors.New("a number not in its shortest form")
|
||||
}
|
||||
return v, i + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, 9, errors.New("a number longer than nine bytes")
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// xzStream is one xz stream (no integrity check) of data cut into blocks of blockSize bytes, each block's
|
||||
// LZMA2 data stored uncompressed, each block a delta filter before LZMA2 when delta is set: the shape of
|
||||
// `xz --block-size=N --filters=delta,lzma2`, built without any xz tool. listed is how many blocks its index
|
||||
// lists (-1: as many as it holds), so a stream whose index lies can be built too.
|
||||
func xzStream(data []byte, blockSize int, delta bool, listed int) []byte {
|
||||
var out bytes.Buffer
|
||||
flags := []byte{0, 0}
|
||||
out.Write([]byte{0xfd, '7', 'z', 'X', 'Z', 0x00})
|
||||
out.Write(flags)
|
||||
out.Write(binary.LittleEndian.AppendUint32(nil, crc32.ChecksumIEEE(flags)))
|
||||
type record struct{ unpadded, uncompressed uint64 }
|
||||
var records []record
|
||||
for at := 0; at < len(data); at += blockSize {
|
||||
block := data[at:min(at+blockSize, len(data))]
|
||||
if delta {
|
||||
// Delta-encoded with a distance of one; the decoder's history starts empty at each block.
|
||||
encoded := make([]byte, len(block))
|
||||
for i := range block {
|
||||
encoded[i] = block[i]
|
||||
if i > 0 {
|
||||
encoded[i] -= block[i-1]
|
||||
}
|
||||
}
|
||||
block = encoded
|
||||
}
|
||||
header := []byte{2, 0} // 12 bytes
|
||||
if delta {
|
||||
header[1] = 1 // two filters
|
||||
header = append(header, 0x03, 0x01, 0x00)
|
||||
}
|
||||
header = append(header, 0x21, 0x01, 0x00) // LZMA2, a 4 KiB dictionary
|
||||
for len(header) < 8 {
|
||||
header = append(header, 0)
|
||||
}
|
||||
header = binary.LittleEndian.AppendUint32(header, crc32.ChecksumIEEE(header))
|
||||
out.Write(header)
|
||||
size := 0
|
||||
for i, chunk := 0, block; len(chunk) > 0; i++ {
|
||||
n := min(len(chunk), 1<<16)
|
||||
control := byte(2)
|
||||
if i == 0 {
|
||||
control = 1 // the first chunk of a block resets the dictionary
|
||||
}
|
||||
out.WriteByte(control)
|
||||
out.Write(binary.BigEndian.AppendUint16(nil, uint16(n-1)))
|
||||
out.Write(chunk[:n])
|
||||
size += 3 + n
|
||||
chunk = chunk[n:]
|
||||
}
|
||||
out.WriteByte(0)
|
||||
size++
|
||||
out.Write(make([]byte, (4-size%4)%4))
|
||||
records = append(records, record{uint64(len(header) + size), uint64(len(block))})
|
||||
}
|
||||
if listed >= 0 {
|
||||
for len(records) < listed {
|
||||
records = append(records, records[0])
|
||||
}
|
||||
records = records[:listed]
|
||||
}
|
||||
vli := func(b []byte, v uint64) []byte {
|
||||
for v >= 0x80 {
|
||||
b = append(b, byte(v)|0x80)
|
||||
v >>= 7
|
||||
}
|
||||
return append(b, byte(v))
|
||||
}
|
||||
index := vli([]byte{0}, uint64(len(records)))
|
||||
for _, r := range records {
|
||||
index = vli(vli(index, r.unpadded), r.uncompressed)
|
||||
}
|
||||
for len(index)%4 != 0 {
|
||||
index = append(index, 0)
|
||||
}
|
||||
index = binary.LittleEndian.AppendUint32(index, crc32.ChecksumIEEE(index))
|
||||
out.Write(index)
|
||||
footer := binary.LittleEndian.AppendUint32(nil, uint32(len(index)/4-1))
|
||||
footer = append(footer, flags...)
|
||||
out.Write(binary.LittleEndian.AppendUint32(nil, crc32.ChecksumIEEE(footer)))
|
||||
out.Write(footer)
|
||||
out.WriteString("YZ")
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// tarHolding is a tar holding betterbird/data with body.
|
||||
func tarHolding(t *testing.T, body []byte) []byte {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
w := tar.NewWriter(&out)
|
||||
if err := w.WriteHeader(&tar.Header{Name: "betterbird/data", Mode: 0o644, Size: int64(len(body)),
|
||||
Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write(body)
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeTemp(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "r.tar.xz")
|
||||
if err := os.WriteFile(file, body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func TestAnXzFileOfAFewDeltaBlocksAndTwoStreamsIsRead(t *testing.T) {
|
||||
// The streams this test builds are ones the reader decodes, so what the walk refuses below is the
|
||||
// same framing the reader follows.
|
||||
body := bytes.Repeat([]byte("betterbird "), 300)
|
||||
whole := tarHolding(t, body)
|
||||
half := len(whole) / 2
|
||||
for name, file := range map[string][]byte{
|
||||
"one stream of delta blocks": xzStream(whole, 512, true, -1),
|
||||
"two streams with padding": append(append(xzStream(whole[:half], 700, true, -1), 0, 0, 0, 0),
|
||||
xzStream(whole[half:], 100, false, -1)...),
|
||||
} {
|
||||
path := writeTemp(t, file)
|
||||
if xzPath, err := exec.LookPath("xz"); err == nil {
|
||||
if out, err := exec.Command(xzPath, "-t", path).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s: xz itself refuses the built stream: %v %s", name, err, out)
|
||||
}
|
||||
}
|
||||
into := t.TempDir()
|
||||
if err := unpackUpstream(context.Background(), path, into, "betterbird"); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(into, "data"))
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("%s: unpacked %d bytes, want %d: %v", name, len(got), len(body), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzFileOfManyDeltaBlocksIsRefusedBeforeItIsDecoded(t *testing.T) {
|
||||
// Review 68 on pull request 264: `xz --block-size=1 --delta` of a 200 kB tar kept about 67 MiB live in
|
||||
// the reader, one delta decoder per block. Its index here lists one block, as a file crafted to pass a
|
||||
// check of the index alone would; the walk counts the blocks the reader would decode.
|
||||
body := make([]byte, 200_000)
|
||||
for i := range body {
|
||||
body[i] = byte(i * 7)
|
||||
}
|
||||
crafted := xzStream(tarHolding(t, body), 1, true, 1)
|
||||
path := writeTemp(t, crafted)
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
err := unpackUpstream(context.Background(), path, t.TempDir(), "betterbird")
|
||||
runtime.ReadMemStats(&after)
|
||||
if err == nil || !strings.Contains(err.Error(), "more than 1024 xz blocks") {
|
||||
t.Fatalf("a file of %d bytes in blocks of one byte was not refused for its blocks: %v", len(crafted), err)
|
||||
}
|
||||
if grew := after.TotalAlloc - before.TotalAlloc; grew > 4<<20 {
|
||||
t.Fatalf("%d bytes were allocated for a refused file", grew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheXzBlockLimitIsExact(t *testing.T) {
|
||||
data := make([]byte, xzBlocksMax+1)
|
||||
streams, blocks, err := xzFraming(bufio.NewReader(bytes.NewReader(xzStream(data[:xzBlocksMax], 1, true, -1))))
|
||||
if err != nil || streams != 1 || blocks != xzBlocksMax {
|
||||
t.Fatalf("%d blocks: %d streams, %d blocks, %v", xzBlocksMax, streams, blocks, err)
|
||||
}
|
||||
if _, _, err := xzFraming(bufio.NewReader(bytes.NewReader(xzStream(data, 1, true, -1)))); !errors.Is(err, errXzTooManyBlocks) {
|
||||
t.Fatalf("%d blocks: %v", xzBlocksMax+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzFileOfTooManyStreamsIsRefused(t *testing.T) {
|
||||
var file []byte
|
||||
for i := 0; i <= xzStreamsMax; i++ {
|
||||
file = append(file, xzStream([]byte("x"), 1, false, -1)...)
|
||||
}
|
||||
err := unpackUpstream(context.Background(), writeTemp(t, file), t.TempDir(), "")
|
||||
if err == nil || !strings.Contains(err.Error(), "more than 16 concatenated xz streams") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzIndexThatDoesNotListItsBlocksIsRefused(t *testing.T) {
|
||||
file := xzStream(tarHolding(t, []byte("x")), 512, false, 1)
|
||||
err := unpackUpstream(context.Background(), writeTemp(t, file), t.TempDir(), "betterbird")
|
||||
if err == nil || !strings.Contains(err.Error(), "its index lists 1 blocks, but it holds 4") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -247,24 +247,19 @@ type Rendering struct {
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
Identities map[string]ModuleIdentity
|
||||
// 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
|
||||
// RunnerJoinsOwnGroups says this machine's engine puts the tool runner's account in a joining module's own group
|
||||
// and takes a directory's setgid mode (novox/hq ADR 0334 §1, §2: its statement's contract is link.OwnGroupContract
|
||||
// or later); to an older one neither the identity's runner nor the setgid bit is sent.
|
||||
RunnerJoinsOwnGroups bool
|
||||
// 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
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -339,11 +334,9 @@ 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
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
|
||||
@@ -406,8 +399,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
received := map[string]map[string][]Contribution{}
|
||||
leftOut := map[string]string{}
|
||||
var foreseen, unplaced []string
|
||||
var joined []Joined
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &joined)
|
||||
var identities []ModuleIdentity
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &identities)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -423,7 +416,9 @@ 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}
|
||||
composed.Groups, composed.Identities, composed.Libraries = r.libraryEnvelope(with, joined)
|
||||
if len(identities) > 0 {
|
||||
composed.Identities = identitiesOf(identities)
|
||||
}
|
||||
return composed, nil
|
||||
}
|
||||
|
||||
@@ -435,26 +430,13 @@ 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, joinedOut *[]Joined) ([]map[string]any, error) {
|
||||
foreseen, unplaced *[]string, identities *[]ModuleIdentity) ([]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 {
|
||||
@@ -595,19 +577,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
resources := m.Resources
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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.
|
||||
//
|
||||
@@ -1059,12 +1040,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, err
|
||||
}
|
||||
ownerInto(copied, placed)
|
||||
// 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, with.RunnerJoinsOwnGroups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// **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
|
||||
@@ -1183,6 +1162,12 @@ 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
|
||||
|
||||
@@ -1,907 +0,0 @@
|
||||
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
|
||||
}
|
||||
problems = append(problems, toolsDirectoryProblems(m)...)
|
||||
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
|
||||
}
|
||||
|
||||
// toolsDirectoryProblems refuses a joining module whose tools name one of its directories whose mode closes it to its
|
||||
// group (novox/hq ADR 0334 §3): the tool runner reads it through the module's own group, so it needs group read and
|
||||
// enter.
|
||||
func toolsDirectoryProblems(m Manifest) []string {
|
||||
modes := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
mode, _ := r["mode"].(string)
|
||||
modes[fmt.Sprint(r["id"])] = mode
|
||||
}
|
||||
}
|
||||
var words []string
|
||||
for _, b := range m.Bundles {
|
||||
for _, v := range b.Env {
|
||||
words = append(words, v)
|
||||
}
|
||||
}
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
for _, v := range a.Env {
|
||||
words = append(words, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var problems []string
|
||||
for _, w := range words {
|
||||
for _, match := range dirRef.FindAllStringSubmatch(w, -1) {
|
||||
id := match[1]
|
||||
mode, declared := modes[id]
|
||||
if !declared || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
var n uint64
|
||||
if _, err := fmt.Sscanf(mode, "%o", &n); err != nil || mode == "" {
|
||||
continue // its mode is judged where the directory is
|
||||
}
|
||||
if n&0o050 != 0o050 {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools name its directory %s, whose mode %s closes it to its "+
|
||||
"own group: the tool runner reads it through that group, so it needs group read and enter (0750; "+
|
||||
"the mesh adds setgid, 2750) (novox/hq ADR 0334 §3)", m.Module, id, mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
// LibraryWarnings is what the module check warns of a module's libraries (§4): an image that drops added groups
|
||||
// reaches only the first library the module uses.
|
||||
func LibraryWarnings(m Manifest) []string {
|
||||
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
|
||||
AddedGIDs []int
|
||||
}
|
||||
|
||||
// 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))
|
||||
j.AddedGIDs = append(j.AddedGIDs, gid)
|
||||
}
|
||||
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), or an engine that cannot be told it joins. A module held at the
|
||||
// user it ran as until its libraries are taken (§8) is not left out: it is composed at the build it ran before it
|
||||
// joined (cmd/mesh-controller holdJoining).
|
||||
func libraryLeftOut(m Manifest, node string, with Rendering) string {
|
||||
if !Joins(m) {
|
||||
return ""
|
||||
}
|
||||
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)
|
||||
}
|
||||
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, setgid bool) 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
|
||||
}
|
||||
// Its own directories belong to its user and its own group, with setgid (ADR 0334 §1, v4): the library's group is
|
||||
// its processes' primary, so what they make in a library keeps that group — and what they make in their own
|
||||
// directories keeps mesh-<module> by the setgid bit. The bit only to an engine that takes a mode with it
|
||||
// (Rendering.RunnerJoinsOwnGroups, link.OwnGroupContract): the one pinned refuses a mode not starting with 0.
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.UID)
|
||||
if mode, ok := resource["mode"].(string); ok && setgid {
|
||||
var n uint64
|
||||
if _, err := fmt.Sscanf(mode, "%o", &n); err == nil && mode != "" {
|
||||
resource["mode"] = fmt.Sprintf("%04o", n|0o2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
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; mesh-host internal/declaration
|
||||
// Identity): the engine always makes its own group with its uid; GID is its primary group's number — its first
|
||||
// library's, or its own — and Groups the numbers of its added groups.
|
||||
type IdentityEntry struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Groups []int `json:"groups,omitempty"`
|
||||
// Runner is the tool runner's account, put in this module's own group, where its tools run there (ADR 0334 §2).
|
||||
// Only to an engine that reads it (Rendering.RunnerJoinsOwnGroups).
|
||||
Runner string `json:"runner,omitempty"`
|
||||
}
|
||||
|
||||
// libraryEnvelope is what a machine's declaration says of libraries and joining modules: the group of every library
|
||||
// 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.PrimaryGID,
|
||||
Groups: append([]int(nil), j.AddedGIDs...)}
|
||||
if with.RunnerJoinsOwnGroups {
|
||||
for _, m := range r.Modules {
|
||||
if m.Module == j.Module {
|
||||
e.Runner = r.toolsAccount(m, with)
|
||||
}
|
||||
}
|
||||
}
|
||||
ids = append(ids, e)
|
||||
}
|
||||
sort.Slice(ids, func(a, b int) bool { return ids[a].Module < ids[b].Module })
|
||||
used := map[string]map[string]bool{}
|
||||
runner := map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
if 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
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// 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:50020" {
|
||||
t.Errorf("its directory is owned by %v; want its user and own group (ADR 0334 §1)", owner)
|
||||
}
|
||||
if len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr",
|
||||
Name: "mesh-radarr", UID: 50020, GID: 50010, Groups: []int{50011}}) {
|
||||
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 14: a module that neither declares libraries nor uses its identity is composed exactly as before — the whole
|
||||
// declaration: its resources, the filter its port reaches, the contributions it makes — on an engine that keeps
|
||||
// libraries or not; on a machine a library reaches, only that library's group is added. A joining module on an older
|
||||
// engine is left out, naming why.
|
||||
func TestAModuleThatDoesNotJoinIsComposedAsBefore(t *testing.T) {
|
||||
plain := aPlain()
|
||||
plain.Listens = []Listening{{Port: 8080, From: FromEverywhere, Name: "web", Why: "its page"}}
|
||||
plain.Contributes = map[string]map[string]any{"reverse-proxy": {"host": "plain", "port": 8080}}
|
||||
plain.Requires = []string{"reverse-proxy"}
|
||||
modules := []Manifest{plain, proxy(), {Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
theRuntime(t)}
|
||||
base := Rendering{Mesh: []string{"198.51.100.2"}, OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"}
|
||||
keeping := func(node string) Rendering {
|
||||
with := librariesRendering()
|
||||
with.Mesh, with.OutwardLinks, with.TunnelInterface = base.Mesh, base.OutwardLinks, base.TunnelInterface
|
||||
return with
|
||||
}
|
||||
for _, node := range []string{"far", "ace"} {
|
||||
r := Resolution{Node: node, Account: "jochen", Modules: modules}
|
||||
before := composeLibraries(t, r, base)
|
||||
after := composeLibraries(t, r, keeping(node))
|
||||
if node == "far" {
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatalf("on a machine no library reaches, a module that does not join composed otherwise:\n %+v\n %+v",
|
||||
before, after)
|
||||
}
|
||||
continue
|
||||
}
|
||||
groups := after.Groups
|
||||
after.Groups, after.Libraries = nil, nil
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatalf("on %s, a module that does not join composed otherwise:\n %+v\n %+v", node, before, after)
|
||||
}
|
||||
if len(groups) == 0 || len(after.Identities) > 0 {
|
||||
t.Fatalf("on the library's home: groups %v, identities %v", groups, after.Identities)
|
||||
}
|
||||
filter := fmt.Sprint(byID(before.Resources)["nftables."+FilteringID()]["content"])
|
||||
if !strings.Contains(filter, "8080") || received(t, before.Resources)[0].From != "plain" {
|
||||
t.Fatalf("the comparison held no filter or contribution of the module: %s", filter)
|
||||
}
|
||||
}
|
||||
if Joins(aPlain()) || !Joins(aRadarr(t)) {
|
||||
t.Fatal("joining is not read from the libraries and the identity")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// What the node-engine this controller pins (mesh-host #95, ADR 0328's half) makes of a composed declaration with
|
||||
// libraries and a joining module: nothing refused.
|
||||
func TestTheEngineTakesWhatIsComposedForLibraries(t *testing.T) {
|
||||
for _, node := range []string{"ace", "novox"} {
|
||||
r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
with := librariesRendering()
|
||||
with.RunnerJoinsOwnGroups = true // contract 7 (mesh-host #97): the runner on identities, setgid on own directories
|
||||
with.Libraries["books"] = Library{Name: "books", Home: "ace", Root: "/storage/books", Reach: []string{"novox"}}
|
||||
composed := composeLibraries(t, r, with)
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources,
|
||||
"groups": composed.Groups, "identities": composed.Identities, "libraries": composed.Libraries})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("%s: the pinned node-engine refuses it: %v", node, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0334 §1, §2 (the review of 7ea6d36e, item 2): a joining module's own directories are owned by its user
|
||||
// and its own group, not the library's; and the tool runner's account joins the own group of every joining module whose
|
||||
// tools run on the machine — sent only to an engine that reads it (link.OwnGroupContract).
|
||||
func TestAJoiningModulesOwnDirectoriesAreItsOwnGroups(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
got := byID(composeLibraries(t, r, librariesRendering()).Resources)
|
||||
if owner := got["radarr.config"]["owner"]; owner != "50020:50020" {
|
||||
t.Errorf("its directory is owned by %v; want its user and its own group", owner)
|
||||
}
|
||||
// ADR 0334 v4: with setgid, so what is made in it later keeps mesh-radarr though the process's primary group is the
|
||||
// library's.
|
||||
if mode := got["radarr.config"]["mode"]; mode != "0750" {
|
||||
t.Errorf("an engine that refuses setgid modes was sent %v", mode)
|
||||
}
|
||||
if env := got["radarr.app"]["env"].(map[string]any); env["PGID"] != "50010" {
|
||||
t.Errorf("its process's primary group is %v; want the library's, so what it makes there keeps the group", env["PGID"])
|
||||
}
|
||||
older := composeLibraries(t, r, librariesRendering())
|
||||
if older.Identities[0].Runner != "" {
|
||||
t.Errorf("an engine that cannot put the runner in a module's own group was told %q", older.Identities[0].Runner)
|
||||
}
|
||||
with := librariesRendering()
|
||||
with.RunnerJoinsOwnGroups = true
|
||||
newer := composeLibraries(t, r, with)
|
||||
if got := newer.Identities[0].Runner; got != "jochen" {
|
||||
t.Errorf("the runner joins %q's own group; want jochen, whose tools it runs", got)
|
||||
}
|
||||
if mode := byID(newer.Resources)["radarr.config"]["mode"]; mode != "2750" {
|
||||
t.Errorf("its directory's mode is %v; want 2750, setgid on its own 0750", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0334 §3: a joining module whose tools name a directory whose mode closes it to its group is refused at
|
||||
// the module check, naming the directory.
|
||||
func TestAToolsDirectoryIsOpenToItsGroup(t *testing.T) {
|
||||
closed := map[string]any{
|
||||
"libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read"}},
|
||||
"resources": []any{
|
||||
map[string]any{"id": "config", "type": "directory", "mode": "0700"},
|
||||
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}},
|
||||
"build": map[string]any{"artifacts": []any{map[string]any{"name": "tools", "kind": "bundle",
|
||||
"language": "go", "system": "arch", "from": "cmd/app", "binary": "app", "loads": []any{"app"},
|
||||
"env": map[string]any{"APP_CONFIG": "${dir:config}/config.xml"}}}}}
|
||||
_, err := ParseManifest(libraryManifest(t, closed))
|
||||
if err == nil || !strings.Contains(err.Error(), "config") || !strings.Contains(err.Error(), "ADR 0334") {
|
||||
t.Fatalf("a tools' directory closed to its group: %v", err)
|
||||
}
|
||||
open := closed
|
||||
open["resources"] = []any{
|
||||
map[string]any{"id": "config", "type": "directory", "mode": "0750"},
|
||||
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}}
|
||||
if _, err := ParseManifest(libraryManifest(t, open)); err != nil {
|
||||
t.Fatalf("a tools' directory open to its group was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 7ea6d36e, item 6: a service's unit takes the identity too — `User=${identity:user}` — at the module
|
||||
// check and in what is composed.
|
||||
func TestAServicesUnitTakesTheIdentity(t *testing.T) {
|
||||
unit := "[Service]\nUser=${identity:user}\nGroup=${identity:group}\nUMask=0002\nExecStart=/usr/bin/importer\n"
|
||||
raw := libraryManifest(t, map[string]any{"resources": []any{
|
||||
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${library:movies}:/movies"}},
|
||||
map[string]any{"id": "watch", "type": "service", "name": "app-watch", "unit": unit}}})
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("a unit naming the identity was refused: %v", err)
|
||||
}
|
||||
with := librariesRendering()
|
||||
with.Identities["app"] = ModuleIdentity{Module: "app", UID: 50030}
|
||||
composed := composeLibraries(t, Resolution{Node: "ace", Modules: []Manifest{m}}, with)
|
||||
got := fmt.Sprint(byID(composed.Resources)["app.watch"]["unit"])
|
||||
if !strings.Contains(got, "User=mesh-app\nGroup=mesh-media\n") {
|
||||
t.Fatalf("the unit reads %q", got)
|
||||
}
|
||||
}
|
||||
@@ -536,9 +536,6 @@ 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.
|
||||
@@ -1863,8 +1860,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
// Its libraries and its identity, and what a module that joins is held to (novox/hq ADR 0328).
|
||||
problems = append(problems, libraryProblems(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)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import "path"
|
||||
|
||||
// The machine's own resolver (novox/hq ADR 0247).
|
||||
//
|
||||
// **Most machines have none.** Every machine lists the mesh's resolvers in /etc/resolv.conf and nothing
|
||||
@@ -68,39 +66,43 @@ func holdsSeat(m Manifest, seat string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// stepsBack is the module as it composes among the modules of one machine (novox/hq ADR 0287,
|
||||
// ADR 0247):
|
||||
// **where it holds a seat that names a path stepping back for another seat, and that other seat's holder
|
||||
// is on the machine and declares the path, its own rendering of the path is left out**, and nothing else
|
||||
// of it changes. Only a file the mesh renders for it steps back, as before: a resource on the path is
|
||||
// composed as declared (the declaration copies a module's resources whole), so an uplink holder declaring
|
||||
// the resolver file as a resource beside the resolver's holder is two owners of one path, and refused. The stepping back is the seat's definition — node-uplink stepping back from the resolver
|
||||
// file for node-resolver — so any other module declaring the path beside the owner's is still two owners
|
||||
// of one path, and is refused as before.
|
||||
func stepsBack(m Manifest, modules []Manifest) Manifest {
|
||||
drop := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
seat, known := SeatNamed(c.Name)
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
for _, p := range seat.Paths {
|
||||
if p.StepsBackFor == "" {
|
||||
continue
|
||||
}
|
||||
for _, o := range modules {
|
||||
if o.Module != m.Module && holdsSeat(o, p.StepsBackFor) && declaresPath(o, p.Path) {
|
||||
drop[p.Path] = true
|
||||
}
|
||||
}
|
||||
// rendersResolverFile says whether a module asks the mesh to render the machine's resolver file.
|
||||
func rendersResolverFile(m Manifest) bool {
|
||||
for _, f := range m.Facts {
|
||||
if !f.Home && f.Path == ResolverFile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(drop) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolverFileOwner is the module that writes the machine's resolver file among the modules on one
|
||||
// machine (novox/hq ADR 0247): the holder of node-resolver when one renders it, else nobody is named here
|
||||
// and the file is whoever's it always was — the uplink holder's (ADR 0223).
|
||||
func ResolverFileOwner(modules []Manifest) string {
|
||||
for _, m := range modules {
|
||||
if holdsSeat(m, ResolverSeat) && rendersResolverFile(m) {
|
||||
return m.Module
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stepsBack is the module as it composes on a machine whose resolver file is the node-resolver holder's:
|
||||
// **the uplink holder's rendering of that file is left out**, and nothing else of it changes. Only the
|
||||
// uplink's holder steps back — any other module rendering the file beside the resolver's is still two
|
||||
// owners of one path, and is refused as before.
|
||||
func stepsBack(m Manifest, modules []Manifest) Manifest {
|
||||
if !holdsSeat(m, "node-uplink") || !rendersResolverFile(m) {
|
||||
return m
|
||||
}
|
||||
owner := ResolverFileOwner(modules)
|
||||
if owner == "" || owner == m.Module {
|
||||
return m
|
||||
}
|
||||
facts := make(map[string]RosterFile, len(m.Facts))
|
||||
for name, f := range m.Facts {
|
||||
if !f.Home && drop[path.Clean(f.Path)] {
|
||||
if !f.Home && f.Path == ResolverFile {
|
||||
continue
|
||||
}
|
||||
facts[name] = f
|
||||
@@ -111,6 +113,9 @@ func stepsBack(m Manifest, modules []Manifest) Manifest {
|
||||
|
||||
// steppedBack is every module of one machine as it composes there (stepsBack, each).
|
||||
func steppedBack(modules []Manifest) []Manifest {
|
||||
if ResolverFileOwner(modules) == "" {
|
||||
return modules
|
||||
}
|
||||
out := make([]Manifest, len(modules))
|
||||
for i, m := range modules {
|
||||
out[i] = stepsBack(m, modules)
|
||||
|
||||
@@ -957,9 +957,7 @@ func checkResources(modules []Manifest) []string {
|
||||
// Judged as the machine composes them: where the node-resolver holder writes the resolver file, the
|
||||
// uplink holder's rendering of it is not composed, so it is not a second owner (novox/hq ADR 0247).
|
||||
modules = steppedBack(modules)
|
||||
// And only a claimant of a seat naming a path declares it — the controller's generated resources
|
||||
// included, which are judged here at composition (novox/hq ADR 0287).
|
||||
problems := seatPathsOnMachine(modules)
|
||||
var problems []string
|
||||
owner := map[string]string{}
|
||||
ownedPath := map[string]string{} // path → owning module, for the access check below
|
||||
packagePresent, packageAbsent := map[string][]string{}, map[string][]string{}
|
||||
|
||||
@@ -410,10 +410,6 @@ 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{}
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The paths a seat's holder owns (novox/hq ADR 0287).
|
||||
//
|
||||
// **Who owns a machine-wide file is the role's question, answered once.** Until this, which module owned
|
||||
// /etc/hosts or /etc/resolv.conf was decided record by record (ADRs 0199, 0222, 0223, 0241) and enforced
|
||||
// only after the fact, by checkResources finding two modules on one machine declaring the same path: a
|
||||
// module that did not hold node-hostname could declare /etc/hosts on a machine where nobody held it, and
|
||||
// nothing refused it. Now a seat's definition names the paths the role owns on any machine, and three
|
||||
// refusals follow from it, each where the mesh can still say no:
|
||||
//
|
||||
// - a claim whose module does not declare every path its seat names, by CanHold, at registration and at
|
||||
// handover;
|
||||
// - a module declaring a path a seat names while claiming none of the seats that name it, at
|
||||
// registration (CatalogueProblems), and what the controller generates for a machine, at composition;
|
||||
// - a module's own seat naming a path another seat already names, at registration — the second loses.
|
||||
//
|
||||
// Reading a seat's path is access, never a resource, so it stays allowed.
|
||||
|
||||
// SeatPath is one path a seat's holder owns: absolute, literal, on the machine where the holder runs.
|
||||
type SeatPath struct {
|
||||
Path string `json:"path"`
|
||||
// StepsBackFor is a node seat whose holder owns the path instead, on a machine where that seat is held
|
||||
// (novox/hq ADR 0287, ADR 0247): this seat's holder still declares the path, and its declaration is not
|
||||
// composed there. Only the mesh's own seats step back, each by a record.
|
||||
StepsBackFor string `json:"steps-back-for,omitempty"`
|
||||
}
|
||||
|
||||
// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally
|
||||
// with no placeholder, and clean, so it compares with what a module declares byte for byte.
|
||||
func seatPathProblem(seat, scope, p string) string {
|
||||
switch {
|
||||
case scope != ScopeNode:
|
||||
return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+
|
||||
"because a path is a machine's (novox/hq ADR 0287)", seat, p, scope)
|
||||
case strings.Contains(p, "${"):
|
||||
return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+
|
||||
"literally (novox/hq ADR 0287)", seat, p)
|
||||
case !strings.HasPrefix(p, "/") || path.Clean(p) != p:
|
||||
return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)",
|
||||
seat, p)
|
||||
case path.Dir(p) == "/":
|
||||
// The root or a top-level directory is every role's, never one role's own (ADR 0287).
|
||||
return fmt.Sprintf("the seat %s names the path %q, the root or a top-level directory, which no one "+
|
||||
"role owns (novox/hq ADR 0287)", seat, p)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR
|
||||
// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it
|
||||
// without stepping back; and a seat steps back only for another node seat naming the same path without
|
||||
// stepping back, so the stepping back cannot cycle.
|
||||
func SeatPathProblems(set []Seat) []string {
|
||||
var problems []string
|
||||
owners := map[string][]string{} // path → the seats naming it without stepping back
|
||||
stepping := map[string][]SeatPath{} // seat → the paths it names stepping back
|
||||
byName := map[string]Seat{}
|
||||
for _, s := range set {
|
||||
byName[s.Name] = s
|
||||
for _, p := range s.Paths {
|
||||
if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" {
|
||||
problems = append(problems, why)
|
||||
}
|
||||
if p.StepsBackFor == "" {
|
||||
owners[p.Path] = append(owners[p.Path], s.Name)
|
||||
} else {
|
||||
stepping[s.Name] = append(stepping[s.Name], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range set {
|
||||
for _, p := range stepping[s.Name] {
|
||||
other, known := byName[p.StepsBackFor]
|
||||
switch {
|
||||
case !known || other.Scope != ScopeNode:
|
||||
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+
|
||||
"node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor))
|
||||
case !namesWithoutSteppingBack(other, p.Path):
|
||||
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+
|
||||
"own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)",
|
||||
s.Name, p.Path, other.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range sortedKeys(owners) {
|
||||
if len(owners[p]) > 1 {
|
||||
problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+
|
||||
"no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and ")))
|
||||
}
|
||||
}
|
||||
for _, s := range set {
|
||||
for _, p := range stepping[s.Name] {
|
||||
if len(owners[p.Path]) == 0 {
|
||||
problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+
|
||||
"(novox/hq ADR 0287)", p.Path))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func namesWithoutSteppingBack(s Seat, p string) bool {
|
||||
for _, sp := range s.Paths {
|
||||
if sp.Path == p && sp.StepsBackFor == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file
|
||||
// the mesh renders for it outside the operator's home. An access is not here — reading is not owning.
|
||||
//
|
||||
// **Cleaned, because the machine writes the cleaned path**: /etc//hosts, /etc/./hosts, /etc/../etc/hosts and
|
||||
// /etc/hosts/ are all /etc/hosts to it, and a seat's path is clean by its own rule, so a declaration compared
|
||||
// as written would pass for a path nobody named.
|
||||
func declaredPaths(m Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||||
out[path.Clean(p)] = true
|
||||
}
|
||||
}
|
||||
for _, f := range m.Facts {
|
||||
if !f.Home && strings.HasPrefix(f.Path, "/") {
|
||||
out[path.Clean(f.Path)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declaresPath says whether a module declares one path, as a resource or as a file the mesh renders.
|
||||
func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] }
|
||||
|
||||
// missingPaths is each path a seat names that a module does not declare, in the seat's order.
|
||||
func missingPaths(m Manifest, paths []SeatPath) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
has := declaredPaths(m)
|
||||
var missing []string
|
||||
for _, p := range paths {
|
||||
if !has[p.Path] {
|
||||
missing = append(missing, p.Path)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// pathSeats is which seats name each path: the mesh's own, and then those modules declare, in the order
|
||||
// given. A module's seat naming a path a seat already names loses it, and the refusal says so (novox/hq
|
||||
// ADR 0287 — as for a seat's name, the second declaration loses).
|
||||
func pathSeats(order []string, shelf Shelf, declaredBy map[string]string) (map[string][]string, []string) {
|
||||
named := map[string][]string{}
|
||||
for _, s := range Seats() {
|
||||
for _, p := range s.Paths {
|
||||
named[p.Path] = append(named[p.Path], s.Name)
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
for _, module := range order {
|
||||
for _, s := range shelf[module].DefinesSeats {
|
||||
if declaredBy != nil && declaredBy[s.Name] != module {
|
||||
continue // a second declaration of the seat's name, refused for that already
|
||||
}
|
||||
for _, p := range s.Paths {
|
||||
if others := named[p]; len(others) > 0 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares the seat %s naming the path %q, which %s already names; no two seats "+
|
||||
"name one path (novox/hq ADR 0287)", module, s.Name, p, strings.Join(others, " and ")))
|
||||
continue
|
||||
}
|
||||
named[p] = append(named[p], s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return named, problems
|
||||
}
|
||||
|
||||
// unclaimedSeatPaths is each path a module declares that a seat names while it claims none of the seats
|
||||
// naming it (novox/hq ADR 0287): only a claimant declares a seat's path, and a module claiming any one of
|
||||
// them may.
|
||||
func unclaimedSeatPaths(m Manifest, named map[string][]string) []string {
|
||||
claims := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
claims[canonicalSeat(c.Name)] = true
|
||||
}
|
||||
var problems []string
|
||||
declared := declaredPaths(m)
|
||||
for _, p := range sortedKeys(declared) {
|
||||
seats := named[p]
|
||||
if len(seats) == 0 {
|
||||
continue
|
||||
}
|
||||
claimsOne := false
|
||||
for _, s := range seats {
|
||||
claimsOne = claimsOne || claims[s]
|
||||
}
|
||||
if !claimsOne {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares the path %q, which the seat %s names, and claims none of them; only a holder of "+
|
||||
"a seat naming a path declares it (novox/hq ADR 0287)", m.Module, p, strings.Join(seats, " and ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path
|
||||
// only on a node seat, absolute, literal and clean.
|
||||
func declaredSeatPathProblems(m Manifest, s SeatDeclaration) []string {
|
||||
var problems []string
|
||||
for _, p := range s.Paths {
|
||||
if why := seatPathProblem(s.Name, s.At(), p); why != "" {
|
||||
problems = append(problems, m.Module+": "+why)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat
|
||||
// never steps back: only the mesh's own seats do, each by a record.
|
||||
func asSeatPaths(paths []string) []SeatPath {
|
||||
out := make([]SeatPath, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
out = append(out, SeatPath{Path: p})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming
|
||||
// the path, and that seat's holder there, with the stepping back applied.
|
||||
type PathOwner struct {
|
||||
Path string `json:"path"`
|
||||
Node string `json:"node"`
|
||||
Seat string `json:"seat"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// PathOwners is, for every path a seat in the set names and every machine where a seat naming it is held,
|
||||
// who owns the path there. A seat that steps back for another held on the same machine is passed over.
|
||||
func PathOwners(set []Seat, held []Held) []PathOwner {
|
||||
// node → seat → holding module
|
||||
holders := map[string]map[string]string{}
|
||||
for _, h := range held {
|
||||
s, ok := SeatNamed(h.Claim)
|
||||
if !ok || h.Scope != ScopeNode || s.Scope != ScopeNode {
|
||||
continue
|
||||
}
|
||||
if holders[h.Node] == nil {
|
||||
holders[h.Node] = map[string]string{}
|
||||
}
|
||||
if _, taken := holders[h.Node][s.Name]; !taken {
|
||||
holders[h.Node][s.Name] = h.Module
|
||||
}
|
||||
}
|
||||
naming := map[string][]Seat{} // path → the seats naming it
|
||||
steps := map[string]map[string]string{} // path → seat → the seat it steps back for
|
||||
for _, s := range set {
|
||||
for _, p := range s.Paths {
|
||||
naming[p.Path] = append(naming[p.Path], s)
|
||||
if p.StepsBackFor != "" {
|
||||
if steps[p.Path] == nil {
|
||||
steps[p.Path] = map[string]string{}
|
||||
}
|
||||
steps[p.Path][s.Name] = p.StepsBackFor
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []PathOwner
|
||||
for _, p := range sortedKeys(naming) {
|
||||
for _, node := range sortedKeys(holders) {
|
||||
for _, s := range naming[p] {
|
||||
module, holds := holders[node][s.Name]
|
||||
if !holds {
|
||||
continue
|
||||
}
|
||||
if forSeat := steps[p][s.Name]; forSeat != "" {
|
||||
if _, there := holders[node][forSeat]; there {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, PathOwner{Path: p, Node: node, Seat: s.Name, Module: module})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatPathsOnMachine is each path the modules of one machine declare that a seat names, for a module
|
||||
// claiming none of the seats naming it (novox/hq ADR 0287). Registration refuses the same of a manifest;
|
||||
// this holds what the controller generates for a machine to it too, because a computed module's resources
|
||||
// exist only once composed (checkResources is asked them there). The seats are the mesh's own and those the
|
||||
// machine's modules declare.
|
||||
func seatPathsOnMachine(modules []Manifest) []string {
|
||||
shelf := Shelf{}
|
||||
var order []string
|
||||
for _, m := range modules {
|
||||
shelf[m.Module] = m
|
||||
order = append(order, m.Module)
|
||||
}
|
||||
named, _ := pathSeats(order, shelf, nil)
|
||||
var problems []string
|
||||
for _, m := range modules {
|
||||
problems = append(problems, unclaimedSeatPaths(m, named)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0287 (issue 412): a seat names the paths its holder owns, a claim without them is
|
||||
// refused, and only a claimant declares a seat's path.
|
||||
|
||||
// The starting set, exactly: node-hostname names /etc/hosts and /etc/hostname, node-uplink the resolver
|
||||
// file stepping back for node-resolver, node-resolver the resolver file, and every other seat names none.
|
||||
func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"node-hostname": "/etc/hosts /etc/hostname",
|
||||
"node-uplink": "/etc/resolv.conf>node-resolver",
|
||||
ResolverSeat: "/etc/resolv.conf",
|
||||
}
|
||||
for _, s := range DefaultSeats() {
|
||||
var got []string
|
||||
for _, p := range s.Paths {
|
||||
if p.StepsBackFor != "" {
|
||||
got = append(got, p.Path+">"+p.StepsBackFor)
|
||||
} else {
|
||||
got = append(got, p.Path)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, " ") != want[s.Name] {
|
||||
t.Errorf("%s names %v; ADR 0287 gives it %q", s.Name, got, want[s.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without
|
||||
// stepping back, and a stepping back that cannot cycle.
|
||||
func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) {
|
||||
if p := SeatPathProblems(DefaultSeats()); len(p) != 0 {
|
||||
t.Fatalf("the compiled set breaks ADR 0287: %v", p)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
why string
|
||||
set []Seat
|
||||
want string
|
||||
}{
|
||||
{"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}},
|
||||
"only a node seat names paths"},
|
||||
{"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}},
|
||||
"placeholder"},
|
||||
{"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}},
|
||||
"not a clean absolute path"},
|
||||
{"two owners", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}},
|
||||
{Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"},
|
||||
{"a cycle", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}},
|
||||
{Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}},
|
||||
"does not own that path itself"},
|
||||
{"stepping back for nobody", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}},
|
||||
"not a node seat"},
|
||||
} {
|
||||
if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s: %q does not say %q", c.why, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the store's rows carry is never the paths: a row read back keeps the compiled ones.
|
||||
func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) {
|
||||
before := seats
|
||||
t.Cleanup(func() { seats = before })
|
||||
UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}})
|
||||
s, _ := SeatNamed("node-hostname")
|
||||
if len(s.Paths) != 2 {
|
||||
t.Fatalf("node-hostname read back from its row names %v", s.Paths)
|
||||
}
|
||||
}
|
||||
|
||||
func hostnameSeat(t *testing.T) Seat {
|
||||
t.Helper()
|
||||
s, ok := SeatNamed("node-hostname")
|
||||
if !ok {
|
||||
t.Fatal("no node-hostname seat")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hostnameHolder(resources ...map[string]any) Manifest {
|
||||
return Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode,
|
||||
Serves: []string{"entries", "add", "remove"}}}, Resources: resources}
|
||||
}
|
||||
|
||||
// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or
|
||||
// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share.
|
||||
func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) {
|
||||
seat := hostnameSeat(t)
|
||||
hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"}
|
||||
name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}
|
||||
|
||||
if err := CanHold(hostnameHolder(hosts, name), seat); err != nil {
|
||||
t.Errorf("a holder declaring both paths as resources was refused: %v", err)
|
||||
}
|
||||
rendered := hostnameHolder(hosts)
|
||||
rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}}
|
||||
if err := CanHold(rendered, seat); err != nil {
|
||||
t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err)
|
||||
}
|
||||
err := CanHold(hostnameHolder(), seat)
|
||||
if err == nil {
|
||||
t.Fatal("a holder declaring neither path was accepted")
|
||||
}
|
||||
for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
// A file under the operator's home is placed per account: it is not the machine's path.
|
||||
home := hostnameHolder(hosts)
|
||||
home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}}
|
||||
if err := CanHold(home, seat); err == nil {
|
||||
t.Error("a file in the operator's home counted as the machine's /etc/hostname")
|
||||
}
|
||||
// At registration, the same refusal.
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got,
|
||||
"does not declare /etc/hostname") {
|
||||
t.Errorf("registration let a holder without /etc/hostname through: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring
|
||||
// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring
|
||||
// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed.
|
||||
func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) {
|
||||
stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}}
|
||||
got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ")
|
||||
if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) {
|
||||
t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got)
|
||||
}
|
||||
// However the path is spelled (novox/hq issue 412 review).
|
||||
unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") {
|
||||
t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got)
|
||||
}
|
||||
reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}}
|
||||
if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 {
|
||||
t.Errorf("reading /etc/hosts was refused: %v", got)
|
||||
}
|
||||
uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}}
|
||||
resolver := localResolver()
|
||||
resolver.Claims[0].Serves = []string{"routes", "route", "unroute"}
|
||||
if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 {
|
||||
t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat
|
||||
// names — the second declaration loses. Its holder declares them, as a mesh seat's does.
|
||||
func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) {
|
||||
meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Paths: []string{"/etc/a"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") {
|
||||
t.Errorf("a module's mesh seat naming a path was accepted: %q", got)
|
||||
}
|
||||
placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") {
|
||||
t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got)
|
||||
}
|
||||
|
||||
taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got,
|
||||
`b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) {
|
||||
t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got)
|
||||
}
|
||||
|
||||
own := Manifest{Module: "c", Version: "1",
|
||||
DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}},
|
||||
Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got,
|
||||
"c claims c-seat but does not declare /etc/c.conf") {
|
||||
t.Errorf("a holder of a module's seat without its path was accepted: %q", got)
|
||||
}
|
||||
own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}
|
||||
if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 {
|
||||
t.Errorf("a holder declaring its own seat's path was refused: %v", got)
|
||||
}
|
||||
other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got,
|
||||
`d declares the path "/etc/c.conf", which the seat c-seat names`) {
|
||||
t.Errorf("a module not claiming c-seat declared its path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type writesHosts struct{}
|
||||
|
||||
func (writesHosts) Resources(string) ([]map[string]any, bool, error) {
|
||||
return []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block",
|
||||
"content": "10.42.0.1 anchor\n"}}, true, nil
|
||||
}
|
||||
|
||||
// The controller is bound too: what it generates for a machine on a seat's path is refused at composition.
|
||||
func TestAGeneratedResourceOnASeatsPathIsRefused(t *testing.T) {
|
||||
r := Resolution{Node: "workstation", Modules: []Manifest{
|
||||
{Module: "mesh-names", Computed: "mesh-names"},
|
||||
}}
|
||||
_, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-names": writesHosts{}}})
|
||||
if err == nil {
|
||||
t.Fatal("a generated resource on /etc/hosts was composed")
|
||||
}
|
||||
for _, want := range []string{"mesh-names", "/etc/hosts", "node-hostname"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Who owns a seat's path on a machine: the seat naming it and its holder there, with the stepping back
|
||||
// applied — the resolver's holder where node-resolver is held, else the uplink's.
|
||||
func TestPathOwnersApplyTheSteppingBack(t *testing.T) {
|
||||
held := []Held{
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "laptop", Module: "networkmanager"},
|
||||
{Claim: ResolverSeat, Scope: ScopeNode, Node: "laptop", Module: "systemd-resolved"},
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "server", Module: "systemd-networkd"},
|
||||
{Claim: "node-hostname", Scope: ScopeNode, Node: "server", Module: "hostname"},
|
||||
}
|
||||
var got []string
|
||||
for _, o := range PathOwners(Seats(), held) {
|
||||
got = append(got, o.Path+" "+o.Node+" "+o.Seat+" "+o.Module)
|
||||
}
|
||||
want := []string{
|
||||
"/etc/hostname server node-hostname hostname",
|
||||
"/etc/hosts server node-hostname hostname",
|
||||
"/etc/resolv.conf laptop node-resolver systemd-resolved",
|
||||
"/etc/resolv.conf server node-uplink systemd-networkd",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("the owners are\n%s\nnot\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// The stepping back comes from the seat, and is of a rendered file only: an uplink holder declaring the
|
||||
// resolver file as a resource beside a node-resolver holder is two writers of one path, refused when the
|
||||
// machine is resolved — never composed twice — as before the seat named its paths.
|
||||
func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t *testing.T) {
|
||||
shelf := resolverShelf(t)
|
||||
uplink := shelf["systemd-networkd"]
|
||||
uplink.Facts = nil
|
||||
uplink.Resources = append(append([]map[string]any(nil), uplink.Resources...),
|
||||
map[string]any{"id": "resolvers", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.1\n"})
|
||||
shelf["systemd-networkd"] = uplink
|
||||
shelf["local-resolver"] = localResolver()
|
||||
_, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), `both declare the path "/etc/resolv.conf"`) {
|
||||
t.Fatalf("an uplink declaring the resolver file as a resource beside the resolver was not refused: %v", err)
|
||||
}
|
||||
// Its rendered file steps back, by the seat's definition, and nothing else of it does.
|
||||
rendered := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: "/etc//resolv.conf", Template: "x"},
|
||||
"other": {Path: "/etc/uplink.conf", Template: "x"}}}
|
||||
got := stepsBack(rendered, []Manifest{rendered, localResolver()})
|
||||
if len(got.Facts) != 1 || got.Facts["other"].Path == "" {
|
||||
t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts.
|
||||
func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) {
|
||||
for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} {
|
||||
stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got,
|
||||
`stranger declares the path "/etc/hosts", which the seat node-hostname names`) {
|
||||
t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got)
|
||||
}
|
||||
holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"},
|
||||
map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"})
|
||||
if err := CanHold(holder, hostnameSeat(t)); err != nil {
|
||||
t.Errorf("a holder declaring %s was refused: %v", spelled, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A seat names a role's own path, never the root or a top-level directory.
|
||||
func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) {
|
||||
for _, p := range []string{"/", "/etc"} {
|
||||
m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}}
|
||||
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") {
|
||||
t.Errorf("a module's seat naming %s was accepted: %q", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else
|
||||
// declares one.
|
||||
func TestTheCataloguePassesTheSeatPathRules(t *testing.T) {
|
||||
shelf := Shelf{}
|
||||
for _, m := range theCatalogue(t) {
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
for _, p := range CatalogueProblems(shelf) {
|
||||
if strings.Contains(p, "ADR 0287") {
|
||||
t.Errorf("the catalogue breaks ADR 0287: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,6 @@ type Seat struct {
|
||||
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
|
||||
// definition of the role, and the store's rows carry no column for it.
|
||||
Receives []Receivable
|
||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287): a holder declares
|
||||
// every one, and a module claiming no seat that names a path may not declare it. Only a node seat
|
||||
// names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision,
|
||||
// recorded, and the store's rows carry no column for it.
|
||||
Paths []SeatPath
|
||||
// Decision is the record that made it a seat.
|
||||
Decision string
|
||||
}
|
||||
@@ -183,8 +178,6 @@ var defaultSeats = append([]Seat{
|
||||
// holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an
|
||||
// alias on a mesh that knew it.
|
||||
{Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223",
|
||||
// The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287).
|
||||
Paths: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}},
|
||||
Serves: []Verb{
|
||||
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
|
||||
"the operator's, or the block of the module or tool that writes it.",
|
||||
@@ -306,17 +299,12 @@ var defaultSeats = append([]Seat{
|
||||
// resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers
|
||||
// the manager knows for it — the same for every holder, so a question about a machine's names is asked
|
||||
// the same way whatever manages its network. Optional until both holders serve them.
|
||||
//
|
||||
// It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287,
|
||||
// ADR 0247).
|
||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(),
|
||||
Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: ResolverSeat}}},
|
||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs()},
|
||||
// The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a
|
||||
// VPN client whose domains must go to its own servers while every other name goes to the mesh's.
|
||||
// Where it is held, its holder writes the resolver file and the uplink's holder steps back from it
|
||||
// (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link.
|
||||
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(),
|
||||
Paths: []SeatPath{{Path: ResolverFile}}},
|
||||
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs()},
|
||||
// A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server
|
||||
// exports a machine's folders to the private network and provides each as `nfs-share`; the holder of
|
||||
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
||||
@@ -376,11 +364,10 @@ func UseSeats(s []Seat) {
|
||||
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
|
||||
}
|
||||
}
|
||||
// What a seat receives, whether it is replicated and the paths it names are never stored
|
||||
// (novox/hq ADR 0212, ADR 0223, ADR 0287), so they are always the compiled ones.
|
||||
// What a seat receives and whether it is replicated are never stored (novox/hq ADR 0212, ADR
|
||||
// 0223), so they are always the compiled ones.
|
||||
if d, known := byName[row.Name]; known {
|
||||
row.Receives = d.Receives
|
||||
row.Paths = d.Paths
|
||||
row.Replicated = d.Replicated
|
||||
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
|
||||
}
|
||||
@@ -554,14 +541,6 @@ func CanHold(m Manifest, seat Seat) error {
|
||||
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
||||
}
|
||||
// **A holder owns what the role owns** (novox/hq ADR 0287): every path the seat names, declared as
|
||||
// a resource or as a file the mesh renders for it, so which module owns /etc/hosts on a machine is
|
||||
// answered by the seat's definition and not by reading every manifest.
|
||||
if missing := missingPaths(m, seat.Paths); len(missing) > 0 {
|
||||
return fmt.Errorf("%s claims %s but does not declare %s, which that seat's holder owns (novox/hq "+
|
||||
"ADR 0287) — a holder declares every path its seat names, as a resource or as a file the mesh "+
|
||||
"renders for it", m.Module, seat.Name, strings.Join(missing, ", "))
|
||||
}
|
||||
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
|
||||
// does not answer what the role promises is every caller's timeout, found at registration and
|
||||
// at handover instead, naming the verbs rather than the fact that something is missing.
|
||||
|
||||
@@ -67,10 +67,6 @@ type SeatDeclaration struct {
|
||||
// Records are state buckets of the declaring module that each user reads under its own name — the
|
||||
// keys `<user>.…` and no other (ADR 0259 §3).
|
||||
Records []string `json:"records,omitempty"`
|
||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287), under the rules the
|
||||
// mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat
|
||||
// names. A module's seat never steps back.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
}
|
||||
|
||||
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
|
||||
@@ -167,7 +163,6 @@ func declaredSeatProblems(m Manifest) []string {
|
||||
}
|
||||
}
|
||||
problems = append(problems, trafficProblems(m, s)...)
|
||||
problems = append(problems, declaredSeatPathProblems(m, s)...)
|
||||
}
|
||||
|
||||
for _, u := range m.Uses {
|
||||
@@ -276,10 +271,6 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses.
|
||||
named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy)
|
||||
problems = append(problems, pathProblems...)
|
||||
|
||||
// Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2).
|
||||
kindsTaken := map[string]string{}
|
||||
|
||||
@@ -336,15 +327,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
"%s claims %s but does not serve %s, which that seat's protocol promises",
|
||||
module, c.Name, strings.Join(missing, ", ")))
|
||||
}
|
||||
// And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287).
|
||||
if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)",
|
||||
module, c.Name, strings.Join(missing, ", ")))
|
||||
}
|
||||
}
|
||||
// Only a claimant declares a seat's path (novox/hq ADR 0287).
|
||||
problems = append(problems, unclaimedSeatPaths(m, named)...)
|
||||
}
|
||||
// **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or
|
||||
// speaking for a kind that proves its sender, is never carried by a machine's runtime.
|
||||
|
||||
@@ -337,10 +337,6 @@ 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 {
|
||||
@@ -525,22 +521,6 @@ 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,10 +79,6 @@ 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,12 +59,6 @@ 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", "unit"}}},
|
||||
{namespace: "library", patterns: []*regexp.Regexp{libraryRef}, fields: map[string][]string{"container": {"volumes"}},
|
||||
why: "a library's path is a host path, filled only in a container's volume source; the process reads the " +
|
||||
"container side its volume gives (novox/hq ADR 0328 §5)"},
|
||||
{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)"},
|
||||
|
||||
@@ -33,9 +33,7 @@ func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) {
|
||||
if !slices.Equal(names, []string{"resolvers", "links"}) {
|
||||
t.Fatalf("node-uplink promises %v", names)
|
||||
}
|
||||
// The resolver file is the seat's path (novox/hq ADR 0287), which every holder of today renders.
|
||||
today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}}
|
||||
today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}}
|
||||
if err := CanHold(today, seat); err != nil {
|
||||
t.Fatalf("a holder that serves no verb yet is refused: %v", err)
|
||||
}
|
||||
@@ -57,8 +55,7 @@ func TestTheUplinkVerbsReadBackFromTheRowStayOptional(t *testing.T) {
|
||||
// The row as seeding stores it: both verbs, the mark not a column.
|
||||
UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "resolvers"}, {Name: "links"}}}})
|
||||
seat := uplinkSeat(t)
|
||||
holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}}
|
||||
holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}}
|
||||
if err := CanHold(holder, seat); err != nil {
|
||||
t.Fatalf("read back from the row, the verbs are required: %v", err)
|
||||
}
|
||||
|
||||
@@ -116,15 +116,9 @@ var ControllerVerbs = []Verb{
|
||||
"limit": "how many builds to list (default 20); not with log",
|
||||
}, nil)},
|
||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id; " +
|
||||
"or, given wait, one plan followed until it moves.",
|
||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||
"wait": "a plan's id: wait until it moves, then answer it as data — the plan, and what ended the wait " +
|
||||
"(moved, tier, done, or bound); ask again to follow it further (novox/hq issue 229)",
|
||||
"until": "with wait: moved (any change, the default), tier (it reaches another tier) or done (it ends)",
|
||||
"bound": "with wait: how long to wait at most, as 15s or seconds — 20s, the default, is the longest, " +
|
||||
"inside what a call is answered within",
|
||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
||||
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
||||
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
||||
|
||||
@@ -117,10 +117,6 @@ 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
|
||||
@@ -933,10 +929,6 @@ 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)
|
||||
@@ -995,8 +987,9 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
|
||||
}
|
||||
m = ahead
|
||||
}
|
||||
// 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 {
|
||||
// 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 {
|
||||
return fmt.Errorf("refused: %w", err)
|
||||
}
|
||||
// And a key that reaches nothing, refused here where somebody can still fix the spelling:
|
||||
|
||||
@@ -36,6 +36,17 @@ 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
|
||||
@@ -51,8 +62,10 @@ 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".
|
||||
// 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 string `json:"reason,omitempty"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 on any machine asks of each library (§6): what its home keeps. A module on a
|
||||
// machine is one assigned there or one a requirement pulled in — what the machine was last sent.
|
||||
func (i *Inventory) LibraryPaths(ctx context.Context) (map[string][]string, error) {
|
||||
shelf, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Every module assigned anywhere, and every module a machine was last sent — those a requirement pulled in too.
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select module from assignment
|
||||
union select jsonb_object_keys(sent_builds) from node where sent_builds is not null and jsonb_typeof(sent_builds) = 'object'`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
Root string
|
||||
Placed string
|
||||
By string
|
||||
Ask string
|
||||
Warrant string
|
||||
Said string
|
||||
// Done is whether the engine stated the take's act done: only then is the library taken.
|
||||
Done bool
|
||||
}
|
||||
|
||||
// TakenLibraries is every library whose take was started, by name; Done says it is taken.
|
||||
func (i *Inventory) TakenLibraries(ctx context.Context) (map[string]TakenLibrary, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select library, home, root, placed, taken_by, ask, warrant, said, done 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.Root, &t.Placed, &t.By, &t.Ask, &t.Warrant, &t.Said, &t.Done); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[t.Library] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecordTakeStarted keeps that a take of a library was started on its home, under the operator's warrant: the
|
||||
// engine said it began. The library is taken only once the engine states the act done (TakeDone). Once per home, root
|
||||
// and filesystem: a second start of the same keeps the first, and one on another (it moved, and the operator was asked
|
||||
// again) replaces it, not done.
|
||||
func (i *Inventory) RecordTakeStarted(ctx context.Context, t TakenLibrary) error {
|
||||
switch {
|
||||
case t.Library == "" || t.Home == "" || !strings.HasPrefix(t.Root, "/"):
|
||||
return errors.New("a taken library names itself, its home and its root")
|
||||
case strings.TrimSpace(t.Warrant) == "" || strings.TrimSpace(t.Ask) == "":
|
||||
return errors.New("a library is taken only on the operator's warrant, and this names none")
|
||||
}
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`insert into kept_library (library, home, root, placed, taken_by, ask, warrant, said, done)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, false)
|
||||
on conflict (library) do update
|
||||
set home = excluded.home, root = excluded.root, placed = excluded.placed, taken_at = now(),
|
||||
taken_by = excluded.taken_by, ask = excluded.ask, warrant = excluded.warrant, said = excluded.said,
|
||||
done = false
|
||||
where kept_library.home <> excluded.home or kept_library.root <> excluded.root
|
||||
or kept_library.placed <> excluded.placed`,
|
||||
t.Library, t.Home, t.Root, t.Placed, t.By, t.Ask, t.Warrant, t.Said)
|
||||
return err
|
||||
}
|
||||
|
||||
// TakeDone keeps that the engine stated a library's take done under a warrant: the library is taken. A warrant the
|
||||
// recorded take was not started under changes nothing.
|
||||
func (i *Inventory) TakeDone(ctx context.Context, library, warrant string) error {
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`update kept_library set done = true where library = $1 and warrant = $2 and not done`, library, warrant)
|
||||
return err
|
||||
}
|
||||
|
||||
// TakeFailed forgets a take the engine stated failed under a warrant, never one done: the library is asked about again.
|
||||
func (i *Inventory) TakeFailed(ctx context.Context, library, warrant string) error {
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`delete from kept_library where library = $1 and warrant = $2 and not done`, library, warrant)
|
||||
return err
|
||||
}
|
||||
|
||||
// SettingsAsOf is a module's settings layers on a machine as they stood at a moment: each layer — the whole mesh's,
|
||||
// then the machine's — as it was set then, read from the layer standing now or from its history. What a module held at
|
||||
// the build it ran is composed with: the settings it last ran with (novox/hq ADR 0328 §8).
|
||||
func (i *Inventory) SettingsAsOf(ctx context.Context, nodeName, module string, at time.Time) ([]catalogue.Layer, error) {
|
||||
node, err := i.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var layers []catalogue.Layer
|
||||
for _, scope := range []struct {
|
||||
id *string
|
||||
from string
|
||||
}{{nil, catalogue.MeshWideLayer}, {&node.ID, nodeName}} {
|
||||
var raw []byte
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select values from settings
|
||||
where module = $1 and node is not distinct from $2::uuid and set_at <= $3
|
||||
union all
|
||||
select values from (select values from settings_history
|
||||
where module = $1 and node is not distinct from $2::uuid
|
||||
and (set_at is null or set_at <= $3) and replaced_at > $3
|
||||
order by replaced_at asc limit 1) h
|
||||
limit 1`, module, scope.id, at).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
layers = append(layers, catalogue.Layer{From: scope.from, Values: values})
|
||||
}
|
||||
return layers, nil
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"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.RecordTakeStarted(ctx, TakenLibrary{Library: "media", Home: "ace", By: "the operator"}); err == nil {
|
||||
t.Fatal("a library was recorded taken with no warrant")
|
||||
}
|
||||
first := TakenLibrary{Library: "media", Home: "ace", Root: "/storage/media", Placed: "fs-000000000001",
|
||||
By: "the operator", Ask: "c01", Warrant: "c01"}
|
||||
if err := inv.RecordTakeStarted(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := first
|
||||
again.Ask, again.Warrant, again.By = "c02", "c02", "someone else"
|
||||
if err := inv.RecordTakeStarted(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.RecordTakeStarted(ctx, moved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c03" {
|
||||
t.Fatalf("a take on another filesystem: %+v (%v)", taken["media"], err)
|
||||
}
|
||||
// The review of 7ea6d36e, item 3: the root is part of what was taken — a library whose root changed is taken
|
||||
// again, and the new take replaces the old.
|
||||
if got := taken["media"].Root; got != "/storage/media" {
|
||||
t.Fatalf("the take recorded the root %q", got)
|
||||
}
|
||||
rooted := moved
|
||||
rooted.Root, rooted.Ask, rooted.Warrant = "/storage/media2", "c04", "c04"
|
||||
if err := inv.RecordTakeStarted(ctx, rooted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c04" || taken["media"].Root != "/storage/media2" {
|
||||
t.Fatalf("a take at another root: %+v (%v)", taken["media"], err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it —
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 7ea6d36e, item 4: every sub-path a module asks of a library is kept on its home, whether the module
|
||||
// was assigned or pulled in by a requirement (it is in what its machine was last sent).
|
||||
func TestTheSubPathsOfModulesPulledInByARequirementAreKept(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
record, err := inv.AddNode(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []catalogue.Manifest{
|
||||
{Module: "radarr", Version: "1", Libraries: []catalogue.LibraryUse{{ID: "m", Library: "media", Path: "movies", Use: "read-write"}}},
|
||||
{Module: "importer", Version: "1", Libraries: []catalogue.LibraryUse{{ID: "i", Library: "media", Path: "incoming", Use: "read-write"}}},
|
||||
} {
|
||||
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "ace", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1", "importer": "c2"}, 1, 1,
|
||||
Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths, err := inv.LibraryPaths(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := append([]string{}, paths["media"]...)
|
||||
sort.Strings(got)
|
||||
if strings.Join(got, ",") != "incoming,movies" {
|
||||
t.Fatalf("the paths kept of media are %v; want the importer's, pulled in by a requirement, too", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 7ea6d36e, item 5: a take the engine says it started is recorded as started, not taken; it is taken when
|
||||
// the engine states the act done under that warrant, and forgotten when it states it failed, so the operator is asked
|
||||
// again.
|
||||
func TestALibraryIsTakenWhenTheActIsDone(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
started := TakenLibrary{Library: "media", Home: "ace", Root: "/storage/media", Placed: "fs-000000000001",
|
||||
By: "the operator", Ask: "c01", Warrant: "c01"}
|
||||
if err := inv.RecordTakeStarted(ctx, started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taken, err := inv.TakenLibraries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := taken["media"]; got.Done || got.Ask != "c01" {
|
||||
t.Fatalf("a take only started reads %+v; want started, not done", got)
|
||||
}
|
||||
if err := inv.TakeDone(ctx, "media", "c99"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, _ = inv.TakenLibraries(ctx); taken["media"].Done {
|
||||
t.Fatal("another warrant's act made the take done")
|
||||
}
|
||||
if err := inv.TakeDone(ctx, "media", "c01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, _ = inv.TakenLibraries(ctx); !taken["media"].Done {
|
||||
t.Fatal("the act done under the take's warrant did not make it taken")
|
||||
}
|
||||
// A failed act forgets a take not done, so it is asked again; never one done.
|
||||
again := started
|
||||
again.Root, again.Ask, again.Warrant = "/storage/media2", "c02", "c02"
|
||||
if err := inv.RecordTakeStarted(ctx, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.TakeFailed(ctx, "media", "c02"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, _ = inv.TakenLibraries(ctx); taken["media"].Ask == "c02" {
|
||||
t.Fatalf("a failed take is still recorded: %+v", taken["media"])
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
-- 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)
|
||||
);
|
||||
@@ -1,50 +0,0 @@
|
||||
-- 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
|
||||
-- root and filesystem, who approved it, under which warrant. A library whose home, root or filesystem changed since
|
||||
-- (it moved to another machine, path or disk) is not taken by that warrant, and the operator is asked again; a new take
|
||||
-- replaces the row.
|
||||
create table kept_library (
|
||||
library text primary key,
|
||||
home text not null,
|
||||
-- The root it was taken at: a library whose root changed since is taken again.
|
||||
root text not null check (root like '/%'),
|
||||
-- The filesystem the root was on when taken, as the engine names it without a path ("fs-<12 hex>"); empty when
|
||||
-- the engine named none.
|
||||
placed text not null default '',
|
||||
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 '',
|
||||
-- Whether the engine has stated the take's act done under this warrant: only then is the library taken. Until
|
||||
-- then the take is under way — the library still waits, and the operator is not asked again — and an act stated
|
||||
-- failed forgets the row, so the operator is asked again.
|
||||
done boolean not null default false
|
||||
);
|
||||
@@ -1,45 +0,0 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A plan's id is a value, never SQL** (novox/hq issue 561): the id reaches PlanByID from a caller of the
|
||||
// controller's plans verb, so an id holding a quote is read as a plain id — no error from the store, and no
|
||||
// other plan answered for it.
|
||||
func TestAPlanIdHoldingAQuoteIsReadAsAPlainId(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
p := Plan{ID: "plan-561", Repository: "novox/mesh-controller", Commit: "abc", Created: time.Now().UTC(),
|
||||
State: PlanBuilding, Tiers: [][]string{{"a"}}, Modules: map[string]*PlanModule{"a": {}}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{
|
||||
"x' or '1'='1", // every plan, when pasted into the query
|
||||
"plan-561' or id like '%", // the plan above, by a pattern
|
||||
"it's", // a quote alone: a syntax error, when pasted
|
||||
} {
|
||||
got, err := inv.PlanByID(ctx, id)
|
||||
if err == nil {
|
||||
t.Fatalf("PlanByID(%q) answered %s, a plan of another id", id, got.ID)
|
||||
}
|
||||
if want := "no plan " + id; err.Error() != want {
|
||||
t.Fatalf("PlanByID(%q) failed with %q, not as a plain id no plan has (%q)", id, err, want)
|
||||
}
|
||||
}
|
||||
// A plan whose id holds a quote is found by it, and by nothing else.
|
||||
quoted := p
|
||||
quoted.ID, quoted.Revision = "plan-'561'", 0
|
||||
if err := inv.SavePlan(ctx, "ed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := inv.PlanByID(ctx, quoted.ID); err != nil || got.ID != quoted.ID {
|
||||
t.Fatalf("the plan with a quote in its id was not read back: %v %+v", err, got.ID)
|
||||
}
|
||||
if recent, err := inv.RecentPlans(ctx, 1); err != nil || len(recent) != 1 || !strings.HasPrefix(recent[0].ID, "plan-") {
|
||||
t.Fatalf("RecentPlans with a parameter: %v %+v", err, recent)
|
||||
}
|
||||
}
|
||||
@@ -623,7 +623,7 @@ func (i *Inventory) PlansSince(ctx context.Context, since time.Time) ([]Plan, er
|
||||
|
||||
// RecentPlans is the last few plans, newest first, open or not — what the overview shows.
|
||||
func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) {
|
||||
return i.plans(ctx, `order by created desc limit $1`, limit)
|
||||
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
|
||||
}
|
||||
|
||||
// NewestMergeOf is the plan, in any state, of the newest merge into a repository's branch that the mesh
|
||||
@@ -638,10 +638,9 @@ func (i *Inventory) NewestMergeOf(ctx context.Context, repository, branch string
|
||||
return plans[0], true, nil
|
||||
}
|
||||
|
||||
// PlanByID is one plan. The id is a parameter, never part of the query (novox/hq issue 561): it comes from a
|
||||
// caller of the plans verb.
|
||||
// PlanByID is one plan.
|
||||
func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
||||
plans, err := i.plans(ctx, `where id = $1`, id)
|
||||
plans, err := i.plans(ctx, `where id = '`+id+`'`)
|
||||
if err != nil {
|
||||
return Plan{}, err
|
||||
}
|
||||
|
||||
+10
-47
@@ -601,24 +601,6 @@ func Acknowledge(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// LongestAnswer is the longest a handler that waits on purpose may hold its caller's one answer
|
||||
// (AnswersWithin, novox/hq issue 229): inside the console's thirty seconds with room for the trip back,
|
||||
// and inside the bus's window for an answer (broker.ResponseTTL). A variable so a test need not wait.
|
||||
var LongestAnswer = 25 * time.Second
|
||||
|
||||
type answerWithinKey struct{}
|
||||
|
||||
// AnswersWithin asks that the call's caller be answered in full if the handler ends within d of the
|
||||
// call's start, rather than within AnswerWithin: for a handler that waits on purpose, with a bound —
|
||||
// `plans` waiting for a plan to move (novox/hq issue 229). Past d, or past LongestAnswer whichever is
|
||||
// shorter, its caller is told it is running, as for any call. Never shortens the window; without effect
|
||||
// outside a served call, and once the caller was answered.
|
||||
func AnswersWithin(ctx context.Context, d time.Duration) {
|
||||
if f, ok := ctx.Value(answerWithinKey{}).(func(time.Duration)); ok {
|
||||
f(d)
|
||||
}
|
||||
}
|
||||
|
||||
// running is the interim answer: what a caller reads when the call has not finished.
|
||||
func running(c *Call, within time.Duration, acknowledged bool, follow string) []byte {
|
||||
why := fmt.Sprintf("it has not finished in %s", within)
|
||||
@@ -663,13 +645,6 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply
|
||||
acknowledged := make(chan struct{})
|
||||
var once sync.Once
|
||||
ctx = context.WithValue(ctx, answerNowKey{}, func() { once.Do(func() { close(acknowledged) }) })
|
||||
longer := make(chan time.Duration, 1)
|
||||
ctx = context.WithValue(ctx, answerWithinKey{}, func(d time.Duration) {
|
||||
select {
|
||||
case longer <- d:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
type outcome struct {
|
||||
body []byte
|
||||
@@ -728,29 +703,17 @@ func (l *CallLog) serveCallWithin(seat, verb string, args json.RawMessage, reply
|
||||
}
|
||||
}
|
||||
}
|
||||
within, start := AnswerWithin, time.Now()
|
||||
timer := time.NewTimer(within)
|
||||
timer := time.NewTimer(AnswerWithin)
|
||||
defer timer.Stop()
|
||||
waiting:
|
||||
for {
|
||||
select {
|
||||
case o := <-done:
|
||||
l.finish(c, o.body, o.failed, false)
|
||||
say(o.body)
|
||||
return
|
||||
case <-acknowledged:
|
||||
say(running(c, within, true, l.Follow))
|
||||
break waiting
|
||||
case d := <-longer:
|
||||
// A handler waiting on purpose (AnswersWithin): its window is stretched, never past LongestAnswer.
|
||||
if d = min(d, LongestAnswer); d > within {
|
||||
within = d
|
||||
timer.Reset(time.Until(start.Add(d)))
|
||||
}
|
||||
case <-timer.C:
|
||||
say(running(c, within, false, l.Follow))
|
||||
break waiting
|
||||
}
|
||||
select {
|
||||
case o := <-done:
|
||||
l.finish(c, o.body, o.failed, false)
|
||||
say(o.body)
|
||||
return
|
||||
case <-acknowledged:
|
||||
say(running(c, AnswerWithin, true, l.Follow))
|
||||
case <-timer.C:
|
||||
say(running(c, AnswerWithin, false, l.Follow))
|
||||
}
|
||||
o := <-done
|
||||
l.finish(c, o.body, o.failed, true)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **A handler that waits on purpose is answered in full when it ends inside the window it asked for**
|
||||
// (novox/hq issue 229): `plans` asked to wait for a plan holds its caller's one answer until the plan
|
||||
// moves or its bound passes, rather than being answered "still running" at AnswerWithin.
|
||||
func TestAHandlerThatWaitsOnPurposeIsAnsweredInFullInsideItsWindow(t *testing.T) {
|
||||
shortWindow(t, 20*time.Millisecond)
|
||||
l, a := NewCallLog(), newAnswers(t)
|
||||
l.serveCall("mesh-controller", "plans", json.RawMessage(`{"wait":"plan-1"}`), "_INBOX.x.3",
|
||||
func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
AnswersWithin(ctx, 200*time.Millisecond)
|
||||
time.Sleep(80 * time.Millisecond) // past AnswerWithin, inside what it asked for
|
||||
return "plan-1 moved", nil
|
||||
}, a.respond, nil)
|
||||
if got := a.only()["result"]; got != "plan-1 moved" {
|
||||
t.Fatalf("a waiting handler inside its window was answered %v, not in full", got)
|
||||
}
|
||||
}
|
||||
|
||||
// What a handler may ask for is bounded: past LongestAnswer its caller is told it is running, as any
|
||||
// call is, so no answer is ever later than the console waits.
|
||||
func TestAWaitIsNeverLongerThanTheLongestAnswer(t *testing.T) {
|
||||
shortWindow(t, 10*time.Millisecond)
|
||||
was := LongestAnswer
|
||||
LongestAnswer = 30 * time.Millisecond
|
||||
t.Cleanup(func() { LongestAnswer = was })
|
||||
l, a := NewCallLog(), newAnswers(t)
|
||||
release := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
l.serveCall("mesh-controller", "plans", nil, "_INBOX.x.4", func(ctx context.Context, _ json.RawMessage) (any, error) {
|
||||
AnswersWithin(ctx, time.Hour)
|
||||
<-release
|
||||
return "late", nil
|
||||
}, a.respond, nil)
|
||||
close(finished)
|
||||
}()
|
||||
select {
|
||||
case <-a.sent:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("a handler asking for an hour held its caller past LongestAnswer")
|
||||
}
|
||||
if got, _ := a.only()["result"].(map[string]any); got["running"] != true {
|
||||
t.Fatalf("past LongestAnswer the caller was answered %v, not that it is running", a.only())
|
||||
}
|
||||
close(release)
|
||||
<-finished
|
||||
}
|
||||
@@ -325,9 +325,6 @@ type ChangePlan struct {
|
||||
Steps []string `json:"steps,omitempty"`
|
||||
// Summary is the plan in one line, for a commit status.
|
||||
Summary string `json:"summary"`
|
||||
// Class is the class of the slowest-budgeted module the change moves (novox/hq ADR 0282 §1, ADR 0313 §2):
|
||||
// core, leaf, or none when it moves no module at all — a fix that moves nothing has no fix budget.
|
||||
Class string `json:"class,omitempty"`
|
||||
// Trunk is the trunk commit the head was merged into when it was judged (novox/hq ADR 0323 §3), twelve
|
||||
// characters: set on the plan a verdict carries, which mesh-delivery keeps with its other inputs.
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
|
||||
+21
-26
@@ -445,26 +445,6 @@ 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
|
||||
|
||||
// OwnGroupContract is the statement of an engine that also puts the tool runner's account in a joining module's own
|
||||
// group, named by an identity's `runner`, and takes a directory's mode with the setgid bit, such as "2750" (novox/hq
|
||||
// ADR 0334 §1, §2). Not stated by any engine yet: the engine must add `runner` (an account's name) to its declaration's
|
||||
// Identity and put that account in the group mesh-<module>, and accept a directory mode whose first digit is 2.
|
||||
const OwnGroupContract = 7
|
||||
|
||||
// KindRunner is the tool runner's membership as the engine states it (novox/hq ADR 0334 §2; mesh-host internal/link):
|
||||
// `runner:<module>` of a joining module, in its own group; `runner:library:<name>` of no module, in a library's group.
|
||||
const KindRunner = "runner"
|
||||
|
||||
// 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"
|
||||
@@ -625,15 +605,19 @@ 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 take the library) or WaitDisk (its disk
|
||||
// mounted).
|
||||
// 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 string `json:"reason"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
}
|
||||
|
||||
// The reasons of an access wait (mesh-host internal/link).
|
||||
// The reasons of an access wait, and what its filesystem says of ACLs (mesh-host internal/link).
|
||||
const (
|
||||
WaitKeep = "keep"
|
||||
WaitDisk = "disk"
|
||||
WaitKeep = "keep"
|
||||
WaitDisk = "disk"
|
||||
ACLNeedsEnabling = "needs-enabling"
|
||||
ACLUnsupported = "unsupported"
|
||||
)
|
||||
|
||||
// KindAccess is an access's verdict (novox/hq ADR 0325; mesh-host internal/link KindAccess): healthy while each user
|
||||
@@ -646,6 +630,16 @@ 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"`
|
||||
@@ -673,9 +667,10 @@ 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. From an engine at IdentityContract or later.
|
||||
// folder, kept as the engine said it; Entries the kept folder's listing. From an engine at IdentityContract only.
|
||||
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
|
||||
|
||||
Vendored
+2
-2
@@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev
|
||||
a second place to clean besides the catalogue itself.
|
||||
|
||||
mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile
|
||||
mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock
|
||||
mesh-host 303d4037e84ea4968da9880a11f4d5a374b3714a examples/foundation-first-node-nats.lock
|
||||
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
|
||||
|
||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||
@@ -18,7 +18,7 @@ To move them, from this repository's root, with the two repositories checked out
|
||||
git -C ../mesh-catalog archive <commit> modules | tar -x -C testdata/beside/mesh-catalog --wildcards \
|
||||
'modules/*/module.json' 'modules/nats/Dockerfile'
|
||||
find testdata/beside -name module.json -exec mv {} {}.captured \;
|
||||
git -C ../mesh-host archive <commit> examples/foundation-first-node.lock | tar -x -C testdata/beside/mesh-host
|
||||
git -C ../mesh-host archive <commit> examples/foundation-first-node-nats.lock | tar -x -C testdata/beside/mesh-host
|
||||
|
||||
and write the commits here. The SDK is captured at the commit go.mod pins for git.novox.be/novox/mesh-sdk/go
|
||||
(the last part of its pseudo-version; novox/hq issue 449), which internal/link's conformance test holds it to:
|
||||
|
||||
+8
-25
@@ -1,17 +1,11 @@
|
||||
// foundation-first-node.lock — what a machine must be before a mesh exists: the one genesis bundle
|
||||
// (novox/hq ADR 0331 §2), on the bus the mesh runs on (ADR 0106, ADR 0131, design 25).
|
||||
// foundation-first-node-nats.lock — what a machine must be before a mesh exists, on the bus being
|
||||
// built (novox/hq ADR 0106, design 25).
|
||||
//
|
||||
// The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-foundation.md): a container runtime, a
|
||||
// filter, a store, a database per context, those contexts' schemas, the bus with its certificate
|
||||
// and its first user list, and the control plane running on top of them.
|
||||
//
|
||||
// **The mesh writes its own user list, and at genesis there is no mesh yet to write it.** So this
|
||||
// carries the first one — the controller's own account — and the control plane's address names the
|
||||
// same account. Both say `bootstrap`, and both are placeholders: the installer makes the account's
|
||||
// password, puts it in the address and its hash in the list (mesh-host internal/bootstrap
|
||||
// rootsecrets.go), and refuses a bundle whose bus connection it does not recognise. Until the first
|
||||
// node has enrolled the installer places the list the controller composes; from the bus's adoption
|
||||
// as the `nats` module on, the module receives it like any module (ADR 0331 §1, §5).
|
||||
// The same twelve steps as foundation-first-node.lock, with one difference that matters: **the mesh
|
||||
// writes its own user list, and at genesis there is no mesh yet to write it.** So this carries the
|
||||
// first one — the controller's own account, at a well-known bootstrap password, exactly as the store
|
||||
// is reached at `postgres:bootstrap` and the old bus at `guest:guest`. It is rotated with those, and
|
||||
// from the controller's first composition onward the file is the controller's to write.
|
||||
//
|
||||
// The accounts file is its own file beside the server's configuration, because the server's own
|
||||
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
|
||||
@@ -21,18 +15,7 @@
|
||||
//
|
||||
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
|
||||
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
|
||||
// authenticates with a password (ADR 0004, design 25 §4). The control plane pins it too, through
|
||||
// MESH_BROKER_CERTIFICATE, and dials the bus on the machine's own loopback.
|
||||
//
|
||||
// PINNED BY DIGEST, and the digest is not decoration: a tag can be made to point at a different
|
||||
// image, and this file is applied on a machine with no mesh to ask about anything (ADR 0006). The
|
||||
// registry prefix is the lab's; the digests are upstream's.
|
||||
//
|
||||
// The store's data is a NAMED VOLUME, not a directory on the machine. A directory the host
|
||||
// creates is owned by root, and the database runs as somebody else inside the container — so it
|
||||
// could not write, and the container crash-looped. A named volume lets the image set up its own
|
||||
// ownership, and outlives the container, which is what you want for the thing holding the mesh's
|
||||
// state.
|
||||
// authenticates with a password (ADR 0004, design 25 §4).
|
||||
|
||||
{
|
||||
"declaration": 1,
|
||||
Vendored
+4
-20
@@ -7,14 +7,12 @@
|
||||
./cmd/mesh-controller TestAStopNamesWhatStaysOnTheMachinesAndWhatStillCarriesIt
|
||||
./cmd/mesh-controller TestAStopWithoutAMergeLeavesItOutOfABatch
|
||||
./cmd/mesh-controller TestSavingPlansAndMovingMergesIsOneAct
|
||||
./cmd/mesh-controller TestAPlanFollowedFromTheStoreAnswersWhenItMoves
|
||||
./internal/inventory TestTheLastSendSaysWhatTheMachineAnswered
|
||||
./internal/inventory TestAReportIsTheLastSendsAnswerOnlyWhenItIsAboutThatSend
|
||||
./cmd/mesh-controller TestNodeShowAndStatusReadTheLastSendFromTheStore
|
||||
./internal/inventory TestAnIssuedValueIsSealedToEveryHolderOnceAndNotAgain
|
||||
./internal/inventory TestAnIssuedValueIsSealedAgainAfterAKeyChangeOrAnAcceptance
|
||||
./internal/inventory TestAConditionsLooksAreKeptBesideTheBucket
|
||||
./internal/inventory TestAPlanIdHoldingAQuoteIsReadAsAPlainId
|
||||
./cmd/mesh-controller TestTheConditionsVerbReadsTheLooksTheBucketNoLongerHolds
|
||||
./internal/inventory TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid
|
||||
./cmd/mesh-controller TestAWalkLetGoAndStoppedByVerbsIsSaidStartedAndEnded
|
||||
@@ -31,23 +29,9 @@
|
||||
./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb
|
||||
./cmd/mesh-controller TestTheSendMakesNoValueForAnOutsideSecretNobodyGave
|
||||
./cmd/mesh-controller TestAQuestionComposesWithoutAnOutsideSecretNobodyGave
|
||||
./cmd/mesh-controller TestReplay201
|
||||
./cmd/mesh-controller TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend
|
||||
./cmd/mesh-controller TestAGateJudgesTheOtherMovesBesideAKeptCore
|
||||
./cmd/mesh-controller TestReplay401
|
||||
# novox/hq ADR 0328 §1, §3, §4, §7, §13, §15 and issue 575: numbers allocated once and never root, libraries defined
|
||||
# once and never overlapping, a library taken under a warrant, a placement judged against the build that brings it.
|
||||
./internal/inventory TestOneNumberPerModuleAndLibraryNeverRoot
|
||||
./internal/inventory TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps
|
||||
./internal/inventory TestALibraryIsTakenOnceUnderAWarrant
|
||||
# novox/hq ADR 0325 §3, §8, §11 and issue 575: identities allocated once and never root, a folder taken under a
|
||||
# warrant, a placement judged against the build that brings its access.
|
||||
./internal/inventory TestAModuleIsGivenOneIdentityOnceAndNeverRoot
|
||||
./internal/inventory TestAFolderIsTakenOnceUnderAWarrant
|
||||
./internal/inventory TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess
|
||||
./internal/inventory TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead
|
||||
./cmd/mesh-controller TestALibrarysVerdictIsSaidForTheModulesThatUseIt
|
||||
./cmd/mesh-controller TestAHeldModuleIsComposedAsBeforeItJoined
|
||||
./cmd/mesh-controller TestALibraryIsTakenOnlyWhenTheEngineSaysTheActIsDone
|
||||
./internal/inventory TestTheSubPathsOfModulesPulledInByARequirementAreKept
|
||||
./internal/inventory TestALibraryIsTakenWhenTheActIsDone
|
||||
./cmd/mesh-controller TestADeliveryWaitsForALibraryAndJudgesTheRest
|
||||
./cmd/mesh-controller TestAJoiningModuleIsHeldOnAnEngineOlderThanOwnGroups
|
||||
./cmd/mesh-controller TestAHeldModuleKeepsTheSettingsItRanWith
|
||||
./cmd/mesh-controller TestARunnersVerdictIsSaidAndJudgedByWhetherTheMoveChangesIt
|
||||
|
||||
+24
-163
@@ -12,7 +12,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
@@ -133,13 +132,7 @@ func (d *Directory) validate(where string, _ bool) []string {
|
||||
if d.Path == "" {
|
||||
problems = append(problems, where+": a directory needs a path")
|
||||
}
|
||||
// A directory's mode may carry the setgid bit (2xxx), so what is made beneath it keeps its group (novox/hq ADR
|
||||
// 0334 §1); never setuid or sticky, which mean nothing the mesh asks of a directory it owns.
|
||||
mode := d.Mode
|
||||
if len(mode) == 4 && mode[0] == '2' {
|
||||
mode = "0" + mode[1:]
|
||||
}
|
||||
return append(problems, checkMode(where, mode)...)
|
||||
return append(problems, checkMode(where, d.Mode)...)
|
||||
}
|
||||
|
||||
// File is a file with literal content. The host renders nothing.
|
||||
@@ -463,9 +456,10 @@ const (
|
||||
// **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own —
|
||||
// it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A
|
||||
// media library, a download spool is the operator's: it existed before the mesh, several modules
|
||||
// read and write it at once, and the mesh must not create, chown, chmod or remove it. The host
|
||||
// confirms it is there and mounts it; nothing else. Library data is a library's (novox/hq ADR 0328),
|
||||
// not an access.
|
||||
// read and write it at once, and the mesh must not chown, chmod or remove it. The host confirms it
|
||||
// is there and mounts it, and keeps it usable by the identities that use it through ACL entries it
|
||||
// adds and nothing else (novox/hq ADR 0325 §5); a missing one is made only below a parent on the
|
||||
// filesystem the data was placed on (§9).
|
||||
type Access struct {
|
||||
ID string `json:"id"`
|
||||
Type Type `json:"type"`
|
||||
@@ -473,11 +467,15 @@ type Access struct {
|
||||
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// Users and Taken are what a controller speaking ADR 0325 sends (the identities that use the folder, and
|
||||
// that the operator's warrant was carried out): accepted, so its declaration is not refused whole, and not
|
||||
// acted on since ADR 0328 superseded ADR 0325 — no ACL entry is kept.
|
||||
// Users are the identities that use the folder, resolved by the controller (novox/hq ADR 0325 §3, §4): each
|
||||
// module identity whose container mounts it, and the tool runner's account standing for every module whose
|
||||
// tools use it. The engine keeps one ACL entry per user on the folder and as its default (§5); several
|
||||
// accesses naming one folder are kept as one, each uid at the widest use any of them gives it. Absent is a
|
||||
// controller older than ADR 0325, or an access nobody but others' bits reach (§2): nothing is kept.
|
||||
Users []AccessUser `json:"users,omitempty"`
|
||||
Taken bool `json:"taken,omitempty"`
|
||||
// Taken is the controller saying the operator's warrant for this folder was carried out (§8): the folder is
|
||||
// kept although it holds data. The engine also keeps a folder it took itself, on the act, or found empty.
|
||||
Taken bool `json:"taken,omitempty"`
|
||||
|
||||
// Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted
|
||||
// so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5).
|
||||
@@ -1544,132 +1542,19 @@ type Declaration struct {
|
||||
// not refused for it.
|
||||
PutBack bool
|
||||
|
||||
// Identities is the identity of every module assigned to this machine (novox/hq ADR 0328 §4): an account
|
||||
// Identities is the identity of every module assigned to this machine (novox/hq ADR 0325 §3): an account
|
||||
// and a group `mesh-<module>` with the numbers the controller allocated once for the module, the same on
|
||||
// every machine. The engine makes each before any resource is applied, and removes what it made of one no
|
||||
// longer declared. Absent is a controller older than ADR 0325.
|
||||
Identities []Identity
|
||||
|
||||
// Groups is the group of every library this machine is reached by (novox/hq ADR 0328 §3): `mesh-<library>`
|
||||
// with the number the controller allocated once, mesh-wide. The engine makes each, and removes what it made of
|
||||
// one no longer declared. Absent is a controller older than ADR 0328.
|
||||
Groups []Group
|
||||
|
||||
// Libraries is every library this machine is reached by (ADR 0328 §1, §6, §9): its root here, its group, whether
|
||||
// this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for,
|
||||
// and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328.
|
||||
Libraries []Library
|
||||
}
|
||||
|
||||
// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-<module>` with
|
||||
// uid, its own group of the same name and number, its primary group gid (its own, or its first library's group),
|
||||
// and the groups of its other libraries as added groups.
|
||||
// Identity is one module's identity, as the controller allocated it.
|
||||
type Identity struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Groups []int `json:"groups,omitempty"`
|
||||
// Runner is the tool runner's account, put in the module's own group (novox/hq ADR 0334 §2) when its tools run
|
||||
// on this machine. Absent for a module that declares no tools here, and from a controller older than ADR 0334.
|
||||
Runner string `json:"runner,omitempty"`
|
||||
}
|
||||
|
||||
// Group is one library's group, as the controller allocated it (ADR 0328 §3).
|
||||
type Group struct {
|
||||
Name string `json:"name"`
|
||||
GID int `json:"gid"`
|
||||
}
|
||||
|
||||
// GroupPrefix starts every library group's name, as IdentityPrefix starts every module's.
|
||||
const GroupPrefix = "mesh-"
|
||||
|
||||
// LibraryAccessPrefix starts the access a library's wait and its take act name: `library:<name>`.
|
||||
const LibraryAccessPrefix = "library:"
|
||||
|
||||
// Library is one library as this machine sees it (ADR 0328).
|
||||
type Library struct {
|
||||
Library string `json:"library"`
|
||||
// Root is the library's root on this machine: on its home the data itself, elsewhere the mount point the mounts
|
||||
// module mounts it at.
|
||||
Root string `json:"root"`
|
||||
Group string `json:"group"`
|
||||
GID int `json:"gid"`
|
||||
// Home is that the library's data lives on this machine: only there does the engine keep it.
|
||||
Home bool `json:"home"`
|
||||
// Taken is the controller saying the operator's warrant to take it was carried out (§7).
|
||||
Taken bool `json:"taken,omitempty"`
|
||||
// Paths are the sub-paths its modules ask for, relative to the root.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// Runner is the tool runner's account, put in the library's group (§11). Absent when no module's tools use it.
|
||||
Runner string `json:"runner,omitempty"`
|
||||
}
|
||||
|
||||
// checkLibraries judges the groups and libraries on arrival: every library's group declared, named for it, never
|
||||
// root's number; paths that stay below the root; a runner that is an account's name; and no two roots that nest
|
||||
// (§13).
|
||||
func checkLibraries(groups []Group, libs []Library) []string {
|
||||
var problems []string
|
||||
byName := map[string]int{}
|
||||
numbers := map[int]string{}
|
||||
for _, g := range groups {
|
||||
where := fmt.Sprintf("group %q", g.Name)
|
||||
if !strings.HasPrefix(g.Name, GroupPrefix) || !accountName.MatchString(g.Name) {
|
||||
problems = append(problems, fmt.Sprintf("%s: a library's group is named %s<library>", where, GroupPrefix))
|
||||
}
|
||||
if g.GID <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s: gid %d, and a library's group is never root, nor a number "+
|
||||
"below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, g.GID))
|
||||
}
|
||||
if _, twice := byName[g.Name]; twice {
|
||||
problems = append(problems, where+": declared twice")
|
||||
}
|
||||
if other, used := numbers[g.GID]; used {
|
||||
problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, g.GID, other))
|
||||
}
|
||||
byName[g.Name], numbers[g.GID] = g.GID, g.Name
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, l := range libs {
|
||||
where := fmt.Sprintf("library %q", l.Library)
|
||||
switch {
|
||||
case l.Library == "" || strings.ContainsAny(l.Library, "/ \t\n"):
|
||||
problems = append(problems, where+": a library is named by one word")
|
||||
case seen[l.Library]:
|
||||
problems = append(problems, where+": declared twice")
|
||||
}
|
||||
seen[l.Library] = true
|
||||
if !filepath.IsAbs(l.Root) || filepath.Clean(l.Root) != l.Root || l.Root == "/" {
|
||||
problems = append(problems, fmt.Sprintf("%s: its root %q is not an absolute, clean path below /", where,
|
||||
l.Root))
|
||||
}
|
||||
if gid, ok := byName[l.Group]; !ok || gid != l.GID {
|
||||
problems = append(problems, fmt.Sprintf("%s: its group %s (gid %d) is not one this declaration makes",
|
||||
where, l.Group, l.GID))
|
||||
}
|
||||
if l.Group != GroupPrefix+l.Library {
|
||||
problems = append(problems, fmt.Sprintf("%s: its group is %s%s, and %s is not", where, GroupPrefix,
|
||||
l.Library, l.Group))
|
||||
}
|
||||
for _, p := range l.Paths {
|
||||
if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || p == ".." || strings.HasPrefix(p, "../") {
|
||||
problems = append(problems, fmt.Sprintf("%s: the path %q is not a clean path below its root", where, p))
|
||||
}
|
||||
}
|
||||
if l.Runner != "" && !accountName.MatchString(l.Runner) {
|
||||
problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, l.Runner))
|
||||
}
|
||||
}
|
||||
for i, a := range libs {
|
||||
for _, b := range libs[i+1:] {
|
||||
if a.Root != "" && b.Root != "" && (a.Root == b.Root || strings.HasPrefix(a.Root, b.Root+"/") ||
|
||||
strings.HasPrefix(b.Root, a.Root+"/")) {
|
||||
problems = append(problems, fmt.Sprintf("library %q: its root and library %q's nest, and libraries "+
|
||||
"never overlap (novox/hq ADR 0328 §13)", a.Library, b.Library))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// IdentityPrefix starts every module identity's name.
|
||||
@@ -1677,13 +1562,9 @@ const IdentityPrefix = "mesh-"
|
||||
|
||||
// checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number
|
||||
// twice — two modules with one account would write as each other.
|
||||
func checkIdentities(ids []Identity, groups []Group) []string {
|
||||
func checkIdentities(ids []Identity) []string {
|
||||
var problems []string
|
||||
names, uids := map[string]bool{}, map[int]string{}
|
||||
libraryGroups := map[int]bool{}
|
||||
for _, g := range groups {
|
||||
libraryGroups[g.GID] = true
|
||||
}
|
||||
names, uids, gids := map[string]bool{}, map[int]string{}, map[int]string{}
|
||||
for _, id := range ids {
|
||||
where := fmt.Sprintf("identity %q", id.Name)
|
||||
if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) {
|
||||
@@ -1692,7 +1573,7 @@ func checkIdentities(ids []Identity, groups []Group) []string {
|
||||
}
|
||||
if id.UID <= 0 || id.GID <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+
|
||||
"nor a number below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where,
|
||||
"nor a number below it (novox/hq ADR 0325 §11); nothing of this declaration was made", where,
|
||||
id.UID, id.GID))
|
||||
}
|
||||
if names[id.Name] {
|
||||
@@ -1701,25 +1582,10 @@ func checkIdentities(ids []Identity, groups []Group) []string {
|
||||
if other, used := uids[id.UID]; used {
|
||||
problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other))
|
||||
}
|
||||
// Its primary group is its own (gid == uid) or a library's this declaration makes (ADR 0328 §4); so is
|
||||
// every added group. A module's own number is never a library group's.
|
||||
if id.GID != id.UID && !libraryGroups[id.GID] {
|
||||
problems = append(problems, fmt.Sprintf("%s: its primary group gid %d is neither its own (%d) nor a "+
|
||||
"library's this declaration makes", where, id.GID, id.UID))
|
||||
if other, used := gids[id.GID]; used {
|
||||
problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other))
|
||||
}
|
||||
if libraryGroups[id.UID] {
|
||||
problems = append(problems, fmt.Sprintf("%s: uid %d is a library group's number too", where, id.UID))
|
||||
}
|
||||
for _, g := range id.Groups {
|
||||
if !libraryGroups[g] {
|
||||
problems = append(problems, fmt.Sprintf("%s: its added group gid %d is not a library's this "+
|
||||
"declaration makes", where, g))
|
||||
}
|
||||
}
|
||||
if id.Runner != "" && !accountName.MatchString(id.Runner) {
|
||||
problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, id.Runner))
|
||||
}
|
||||
names[id.Name], uids[id.UID] = true, id.Name
|
||||
names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -1891,13 +1757,9 @@ type envelope struct {
|
||||
// sends them only to a host whose reports say `reads_generation`.
|
||||
Generation int64 `json:"generation,omitempty"`
|
||||
PutBack bool `json:"put_back,omitempty"`
|
||||
// Identities is optional on the wire too (novox/hq ADR 0325 §3, ADR 0328 §4). **An older host refuses this key**; a
|
||||
// Identities is optional on the wire too (novox/hq ADR 0325 §3). **An older host refuses this key**; a
|
||||
// controller sends it only to a host whose health says link.IdentityContract.
|
||||
Identities []Identity `json:"identities,omitempty"`
|
||||
// Groups and Libraries are optional too (novox/hq ADR 0328). **An older host refuses these keys**; a controller
|
||||
// sends them only to a host whose health says link.LibraryContract.
|
||||
Groups []Group `json:"groups,omitempty"`
|
||||
Libraries []Library `json:"libraries,omitempty"`
|
||||
}
|
||||
|
||||
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
@@ -1916,9 +1778,8 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
|
||||
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
|
||||
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
|
||||
Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries}
|
||||
problems := checkIdentities(env.Identities, env.Groups)
|
||||
problems = append(problems, checkLibraries(env.Groups, env.Libraries)...)
|
||||
Identities: env.Identities}
|
||||
problems := checkIdentities(env.Identities)
|
||||
if env.Generation < 0 {
|
||||
// Below zero would read as "none claimed" and pass every refusal of an older generation.
|
||||
problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
# Package xz authors
|
||||
|
||||
Michael Cross <https://github.com/xi2>
|
||||
|
||||
# XZ Embedded authors
|
||||
|
||||
Lasse Collin <lasse.collin@tukaani.org>
|
||||
Igor Pavlov <http://7-zip.org/>
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
Creative Commons CC0 1.0 Universal
|
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL
|
||||
SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT
|
||||
RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS.
|
||||
CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE
|
||||
INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES
|
||||
RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
||||
HEREUNDER.
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific
|
||||
works ("Commons") that the public can reliably and without fear of later
|
||||
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||
and redistribute as freely as possible in any form whatsoever and for any
|
||||
purposes, including without limitation commercial purposes. These owners may
|
||||
contribute to the Commons to promote the ideal of a free culture and the
|
||||
further production of creative, cultural and scientific works, or to gain
|
||||
reputation or greater distribution for their Work in part through the use and
|
||||
efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation
|
||||
of additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||
and translate a Work;
|
||||
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
|
||||
iii. publicity and privacy rights pertaining to a person's image or
|
||||
likeness depicted in a Work;
|
||||
|
||||
iv. rights protecting against unfair competition in regards to a Work, subject
|
||||
to the limitations in paragraph 4(a), below;
|
||||
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in a
|
||||
Work;
|
||||
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
|
||||
vii. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||
including without limitation warranties of title, merchantability, fitness for
|
||||
a particular purpose, non infringement, or the absence of latent or other
|
||||
defects, accuracy, or the present or absence of errors, whether or not
|
||||
discoverable, all to the greatest extent permissible under applicable law.
|
||||
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons that
|
||||
may apply to the Work or any use thereof, including without limitation any
|
||||
person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||
disclaims responsibility for obtaining any necessary consents, permissions or
|
||||
other rights required for any use of the Work.
|
||||
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a party
|
||||
to this document and has no duty or obligation with respect to this CC0 or use
|
||||
of the Work.
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Xz
|
||||
|
||||
Package xz implements XZ decompression natively in Go.
|
||||
|
||||
Documentation at <https://pkg.go.dev/github.com/therootcompany/xz>.
|
||||
|
||||
Download and install with `go get github.com/xi2/xz`.
|
||||
|
||||
If you need compression as well as decompression, you might want to
|
||||
look at <https://github.com/ulikunitz/xz>.
|
||||
|
||||
# LICENSE
|
||||
|
||||
This was originally released into the public domain by the AUTHORS.
|
||||
|
||||
Here it is licensed more explicitly as Creative Commons CC0 1.0 Universal
|
||||
(CC0-1.0) so that it can be detected by automated tooling, and satisfy the legal
|
||||
requirements for vendor integration for cases in which a "public domain"
|
||||
statement is not sufficient.
|
||||
-461
@@ -1,461 +0,0 @@
|
||||
/*
|
||||
* Branch/Call/Jump (BCJ) filter decoders
|
||||
*
|
||||
* Authors: Lasse Collin <lasse.collin@tukaani.org>
|
||||
* Igor Pavlov <http://7-zip.org/>
|
||||
*
|
||||
* Translation to Go: Michael Cross <https://github.com/xi2>
|
||||
*
|
||||
* This file has been put into the public domain.
|
||||
* You can do whatever you want with this file.
|
||||
*/
|
||||
|
||||
package xz
|
||||
|
||||
/* from linux/lib/xz/xz_dec_bcj.c *************************************/
|
||||
|
||||
type xzDecBCJ struct {
|
||||
/* Type of the BCJ filter being used */
|
||||
typ xzFilterID
|
||||
/*
|
||||
* Return value of the next filter in the chain. We need to preserve
|
||||
* this information across calls, because we must not call the next
|
||||
* filter anymore once it has returned xzStreamEnd
|
||||
*/
|
||||
ret xzRet
|
||||
/*
|
||||
* Absolute position relative to the beginning of the uncompressed
|
||||
* data (in a single .xz Block).
|
||||
*/
|
||||
pos int
|
||||
/* x86 filter state */
|
||||
x86PrevMask uint32
|
||||
/* Temporary space to hold the variables from xzBuf */
|
||||
out []byte
|
||||
outPos int
|
||||
temp struct {
|
||||
/* Amount of already filtered data in the beginning of buf */
|
||||
filtered int
|
||||
/*
|
||||
* Buffer to hold a mix of filtered and unfiltered data. This
|
||||
* needs to be big enough to hold Alignment + 2 * Look-ahead:
|
||||
*
|
||||
* Type Alignment Look-ahead
|
||||
* x86 1 4
|
||||
* PowerPC 4 0
|
||||
* IA-64 16 0
|
||||
* ARM 4 0
|
||||
* ARM-Thumb 2 2
|
||||
* SPARC 4 0
|
||||
*/
|
||||
buf []byte // slice buf will be backed by bufArray
|
||||
bufArray [16]byte
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* This is used to test the most significant byte of a memory address
|
||||
* in an x86 instruction.
|
||||
*/
|
||||
func bcjX86TestMSByte(b byte) bool {
|
||||
return b == 0x00 || b == 0xff
|
||||
}
|
||||
|
||||
func bcjX86Filter(s *xzDecBCJ, buf []byte) int {
|
||||
var maskToAllowedStatus = []bool{
|
||||
true, true, true, false, true, false, false, false,
|
||||
}
|
||||
var maskToBitNum = []byte{0, 1, 2, 2, 3, 3, 3, 3}
|
||||
var i int
|
||||
var prevPos int = -1
|
||||
var prevMask uint32 = s.x86PrevMask
|
||||
var src uint32
|
||||
var dest uint32
|
||||
var j uint32
|
||||
var b byte
|
||||
if len(buf) <= 4 {
|
||||
return 0
|
||||
}
|
||||
for i = 0; i < len(buf)-4; i++ {
|
||||
if buf[i]&0xfe != 0xe8 {
|
||||
continue
|
||||
}
|
||||
prevPos = i - prevPos
|
||||
if prevPos > 3 {
|
||||
prevMask = 0
|
||||
} else {
|
||||
prevMask = (prevMask << (uint(prevPos) - 1)) & 7
|
||||
if prevMask != 0 {
|
||||
b = buf[i+4-int(maskToBitNum[prevMask])]
|
||||
if !maskToAllowedStatus[prevMask] || bcjX86TestMSByte(b) {
|
||||
prevPos = i
|
||||
prevMask = prevMask<<1 | 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
prevPos = i
|
||||
if bcjX86TestMSByte(buf[i+4]) {
|
||||
src = getLE32(buf[i+1:])
|
||||
for {
|
||||
dest = src - uint32(s.pos+i+5)
|
||||
if prevMask == 0 {
|
||||
break
|
||||
}
|
||||
j = uint32(maskToBitNum[prevMask]) * 8
|
||||
b = byte(dest >> (24 - j))
|
||||
if !bcjX86TestMSByte(b) {
|
||||
break
|
||||
}
|
||||
src = dest ^ (1<<(32-j) - 1)
|
||||
}
|
||||
dest &= 0x01FFFFFF
|
||||
dest |= 0 - dest&0x01000000
|
||||
putLE32(dest, buf[i+1:])
|
||||
i += 4
|
||||
} else {
|
||||
prevMask = prevMask<<1 | 1
|
||||
}
|
||||
}
|
||||
prevPos = i - prevPos
|
||||
if prevPos > 3 {
|
||||
s.x86PrevMask = 0
|
||||
} else {
|
||||
s.x86PrevMask = prevMask << (uint(prevPos) - 1)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func bcjPowerPCFilter(s *xzDecBCJ, buf []byte) int {
|
||||
var i int
|
||||
var instr uint32
|
||||
for i = 0; i+4 <= len(buf); i += 4 {
|
||||
instr = getBE32(buf[i:])
|
||||
if instr&0xFC000003 == 0x48000001 {
|
||||
instr &= 0x03FFFFFC
|
||||
instr -= uint32(s.pos + i)
|
||||
instr &= 0x03FFFFFC
|
||||
instr |= 0x48000001
|
||||
putBE32(instr, buf[i:])
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
var bcjIA64BranchTable = [...]byte{
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
4, 4, 6, 6, 0, 0, 7, 7,
|
||||
4, 4, 0, 0, 4, 4, 0, 0,
|
||||
}
|
||||
|
||||
func bcjIA64Filter(s *xzDecBCJ, buf []byte) int {
|
||||
var branchTable = bcjIA64BranchTable[:]
|
||||
/*
|
||||
* The local variables take a little bit stack space, but it's less
|
||||
* than what LZMA2 decoder takes, so it doesn't make sense to reduce
|
||||
* stack usage here without doing that for the LZMA2 decoder too.
|
||||
*/
|
||||
/* Loop counters */
|
||||
var i int
|
||||
var j int
|
||||
/* Instruction slot (0, 1, or 2) in the 128-bit instruction word */
|
||||
var slot uint32
|
||||
/* Bitwise offset of the instruction indicated by slot */
|
||||
var bitPos uint32
|
||||
/* bit_pos split into byte and bit parts */
|
||||
var bytePos uint32
|
||||
var bitRes uint32
|
||||
/* Address part of an instruction */
|
||||
var addr uint32
|
||||
/* Mask used to detect which instructions to convert */
|
||||
var mask uint32
|
||||
/* 41-bit instruction stored somewhere in the lowest 48 bits */
|
||||
var instr uint64
|
||||
/* Instruction normalized with bit_res for easier manipulation */
|
||||
var norm uint64
|
||||
for i = 0; i+16 <= len(buf); i += 16 {
|
||||
mask = uint32(branchTable[buf[i]&0x1f])
|
||||
for slot, bitPos = 0, 5; slot < 3; slot, bitPos = slot+1, bitPos+41 {
|
||||
if (mask>>slot)&1 == 0 {
|
||||
continue
|
||||
}
|
||||
bytePos = bitPos >> 3
|
||||
bitRes = bitPos & 7
|
||||
instr = 0
|
||||
for j = 0; j < 6; j++ {
|
||||
instr |= uint64(buf[i+j+int(bytePos)]) << (8 * uint(j))
|
||||
}
|
||||
norm = instr >> bitRes
|
||||
if (norm>>37)&0x0f == 0x05 && (norm>>9)&0x07 == 0 {
|
||||
addr = uint32((norm >> 13) & 0x0fffff)
|
||||
addr |= (uint32(norm>>36) & 1) << 20
|
||||
addr <<= 4
|
||||
addr -= uint32(s.pos + i)
|
||||
addr >>= 4
|
||||
norm &= ^(uint64(0x8fffff) << 13)
|
||||
norm |= uint64(addr&0x0fffff) << 13
|
||||
norm |= uint64(addr&0x100000) << (36 - 20)
|
||||
instr &= 1<<bitRes - 1
|
||||
instr |= norm << bitRes
|
||||
for j = 0; j < 6; j++ {
|
||||
buf[i+j+int(bytePos)] = byte(instr >> (8 * uint(j)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func bcjARMFilter(s *xzDecBCJ, buf []byte) int {
|
||||
var i int
|
||||
var addr uint32
|
||||
for i = 0; i+4 <= len(buf); i += 4 {
|
||||
if buf[i+3] == 0xeb {
|
||||
addr = uint32(buf[i]) | uint32(buf[i+1])<<8 |
|
||||
uint32(buf[i+2])<<16
|
||||
addr <<= 2
|
||||
addr -= uint32(s.pos + i + 8)
|
||||
addr >>= 2
|
||||
buf[i] = byte(addr)
|
||||
buf[i+1] = byte(addr >> 8)
|
||||
buf[i+2] = byte(addr >> 16)
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func bcjARMThumbFilter(s *xzDecBCJ, buf []byte) int {
|
||||
var i int
|
||||
var addr uint32
|
||||
for i = 0; i+4 <= len(buf); i += 2 {
|
||||
if buf[i+1]&0xf8 == 0xf0 && buf[i+3]&0xf8 == 0xf8 {
|
||||
addr = uint32(buf[i+1]&0x07)<<19 |
|
||||
uint32(buf[i])<<11 |
|
||||
uint32(buf[i+3]&0x07)<<8 |
|
||||
uint32(buf[i+2])
|
||||
addr <<= 1
|
||||
addr -= uint32(s.pos + i + 4)
|
||||
addr >>= 1
|
||||
buf[i+1] = byte(0xf0 | (addr>>19)&0x07)
|
||||
buf[i] = byte(addr >> 11)
|
||||
buf[i+3] = byte(0xf8 | (addr>>8)&0x07)
|
||||
buf[i+2] = byte(addr)
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func bcjSPARCFilter(s *xzDecBCJ, buf []byte) int {
|
||||
var i int
|
||||
var instr uint32
|
||||
for i = 0; i+4 <= len(buf); i += 4 {
|
||||
instr = getBE32(buf[i:])
|
||||
if instr>>22 == 0x100 || instr>>22 == 0x1ff {
|
||||
instr <<= 2
|
||||
instr -= uint32(s.pos + i)
|
||||
instr >>= 2
|
||||
instr = (0x40000000 - instr&0x400000) |
|
||||
0x40000000 | (instr & 0x3FFFFF)
|
||||
putBE32(instr, buf[i:])
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
/*
|
||||
* Apply the selected BCJ filter. Update *pos and s.pos to match the amount
|
||||
* of data that got filtered.
|
||||
*/
|
||||
func bcjApply(s *xzDecBCJ, buf []byte, pos *int) {
|
||||
var filtered int
|
||||
buf = buf[*pos:]
|
||||
switch s.typ {
|
||||
case idBCJX86:
|
||||
filtered = bcjX86Filter(s, buf)
|
||||
case idBCJPowerPC:
|
||||
filtered = bcjPowerPCFilter(s, buf)
|
||||
case idBCJIA64:
|
||||
filtered = bcjIA64Filter(s, buf)
|
||||
case idBCJARM:
|
||||
filtered = bcjARMFilter(s, buf)
|
||||
case idBCJARMThumb:
|
||||
filtered = bcjARMThumbFilter(s, buf)
|
||||
case idBCJSPARC:
|
||||
filtered = bcjSPARCFilter(s, buf)
|
||||
default:
|
||||
/* Never reached */
|
||||
}
|
||||
*pos += filtered
|
||||
s.pos += filtered
|
||||
}
|
||||
|
||||
/*
|
||||
* Flush pending filtered data from temp to the output buffer.
|
||||
* Move the remaining mixture of possibly filtered and unfiltered
|
||||
* data to the beginning of temp.
|
||||
*/
|
||||
func bcjFlush(s *xzDecBCJ, b *xzBuf) {
|
||||
var copySize int
|
||||
copySize = len(b.out) - b.outPos
|
||||
if copySize > s.temp.filtered {
|
||||
copySize = s.temp.filtered
|
||||
}
|
||||
copy(b.out[b.outPos:], s.temp.buf[:copySize])
|
||||
b.outPos += copySize
|
||||
s.temp.filtered -= copySize
|
||||
copy(s.temp.buf, s.temp.buf[copySize:])
|
||||
s.temp.buf = s.temp.buf[:len(s.temp.buf)-copySize]
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode raw stream which has a BCJ filter as the first filter.
|
||||
*
|
||||
* The BCJ filter functions are primitive in sense that they process the
|
||||
* data in chunks of 1-16 bytes. To hide this issue, this function does
|
||||
* some buffering.
|
||||
*/
|
||||
func xzDecBCJRun(s *xzDecBCJ, b *xzBuf, chain func(*xzBuf) xzRet) xzRet {
|
||||
var outStart int
|
||||
/*
|
||||
* Flush pending already filtered data to the output buffer. Return
|
||||
* immediately if we couldn't flush everything, or if the next
|
||||
* filter in the chain had already returned xzStreamEnd.
|
||||
*/
|
||||
if s.temp.filtered > 0 {
|
||||
bcjFlush(s, b)
|
||||
if s.temp.filtered > 0 {
|
||||
return xzOK
|
||||
}
|
||||
if s.ret == xzStreamEnd {
|
||||
return xzStreamEnd
|
||||
}
|
||||
}
|
||||
/*
|
||||
* If we have more output space than what is currently pending in
|
||||
* temp, copy the unfiltered data from temp to the output buffer
|
||||
* and try to fill the output buffer by decoding more data from the
|
||||
* next filter in the chain. Apply the BCJ filter on the new data
|
||||
* in the output buffer. If everything cannot be filtered, copy it
|
||||
* to temp and rewind the output buffer position accordingly.
|
||||
*
|
||||
* This needs to be always run when len(temp.buf) == 0 to handle a special
|
||||
* case where the output buffer is full and the next filter has no
|
||||
* more output coming but hasn't returned xzStreamEnd yet.
|
||||
*/
|
||||
if len(s.temp.buf) < len(b.out)-b.outPos || len(s.temp.buf) == 0 {
|
||||
outStart = b.outPos
|
||||
copy(b.out[b.outPos:], s.temp.buf)
|
||||
b.outPos += len(s.temp.buf)
|
||||
s.ret = chain(b)
|
||||
if s.ret != xzStreamEnd && s.ret != xzOK {
|
||||
return s.ret
|
||||
}
|
||||
bcjApply(s, b.out[:b.outPos], &outStart)
|
||||
/*
|
||||
* As an exception, if the next filter returned xzStreamEnd,
|
||||
* we can do that too, since the last few bytes that remain
|
||||
* unfiltered are meant to remain unfiltered.
|
||||
*/
|
||||
if s.ret == xzStreamEnd {
|
||||
return xzStreamEnd
|
||||
}
|
||||
s.temp.buf = s.temp.bufArray[:b.outPos-outStart]
|
||||
b.outPos -= len(s.temp.buf)
|
||||
copy(s.temp.buf, b.out[b.outPos:])
|
||||
/*
|
||||
* If there wasn't enough input to the next filter to fill
|
||||
* the output buffer with unfiltered data, there's no point
|
||||
* to try decoding more data to temp.
|
||||
*/
|
||||
if b.outPos+len(s.temp.buf) < len(b.out) {
|
||||
return xzOK
|
||||
}
|
||||
}
|
||||
/*
|
||||
* We have unfiltered data in temp. If the output buffer isn't full
|
||||
* yet, try to fill the temp buffer by decoding more data from the
|
||||
* next filter. Apply the BCJ filter on temp. Then we hopefully can
|
||||
* fill the actual output buffer by copying filtered data from temp.
|
||||
* A mix of filtered and unfiltered data may be left in temp; it will
|
||||
* be taken care on the next call to this function.
|
||||
*/
|
||||
if b.outPos < len(b.out) {
|
||||
/* Make b.out temporarily point to s.temp. */
|
||||
s.out = b.out
|
||||
s.outPos = b.outPos
|
||||
b.out = s.temp.bufArray[:]
|
||||
b.outPos = len(s.temp.buf)
|
||||
s.ret = chain(b)
|
||||
s.temp.buf = s.temp.bufArray[:b.outPos]
|
||||
b.out = s.out
|
||||
b.outPos = s.outPos
|
||||
if s.ret != xzOK && s.ret != xzStreamEnd {
|
||||
return s.ret
|
||||
}
|
||||
bcjApply(s, s.temp.buf, &s.temp.filtered)
|
||||
/*
|
||||
* If the next filter returned xzStreamEnd, we mark that
|
||||
* everything is filtered, since the last unfiltered bytes
|
||||
* of the stream are meant to be left as is.
|
||||
*/
|
||||
if s.ret == xzStreamEnd {
|
||||
s.temp.filtered = len(s.temp.buf)
|
||||
}
|
||||
bcjFlush(s, b)
|
||||
if s.temp.filtered > 0 {
|
||||
return xzOK
|
||||
}
|
||||
}
|
||||
return s.ret
|
||||
}
|
||||
|
||||
/*
|
||||
* Allocate memory for BCJ decoders. xzDecBCJReset must be used before
|
||||
* calling xzDecBCJRun.
|
||||
*/
|
||||
func xzDecBCJCreate() *xzDecBCJ {
|
||||
return new(xzDecBCJ)
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode the Filter ID of a BCJ filter and check the start offset is
|
||||
* valid. Returns xzOK if the given Filter ID and offset is
|
||||
* supported. Otherwise xzOptionsError is returned.
|
||||
*/
|
||||
func xzDecBCJReset(s *xzDecBCJ, id xzFilterID, offset int) xzRet {
|
||||
switch id {
|
||||
case idBCJX86:
|
||||
case idBCJPowerPC:
|
||||
case idBCJIA64:
|
||||
case idBCJARM:
|
||||
case idBCJARMThumb:
|
||||
case idBCJSPARC:
|
||||
default:
|
||||
/* Unsupported Filter ID */
|
||||
return xzOptionsError
|
||||
}
|
||||
// check offset is a multiple of alignment
|
||||
switch id {
|
||||
case idBCJPowerPC, idBCJARM, idBCJSPARC:
|
||||
if offset%4 != 0 {
|
||||
return xzOptionsError
|
||||
}
|
||||
case idBCJIA64:
|
||||
if offset%16 != 0 {
|
||||
return xzOptionsError
|
||||
}
|
||||
case idBCJARMThumb:
|
||||
if offset%2 != 0 {
|
||||
return xzOptionsError
|
||||
}
|
||||
}
|
||||
s.typ = id
|
||||
s.ret = xzOK
|
||||
s.pos = offset
|
||||
s.x86PrevMask = 0
|
||||
s.temp.filtered = 0
|
||||
s.temp.buf = nil
|
||||
return xzOK
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* Delta decoder
|
||||
*
|
||||
* Author: Lasse Collin <lasse.collin@tukaani.org>
|
||||
*
|
||||
* Translation to Go: Michael Cross <https://github.com/xi2>
|
||||
*
|
||||
* This file has been put into the public domain.
|
||||
* You can do whatever you want with this file.
|
||||
*/
|
||||
|
||||
package xz
|
||||
|
||||
type xzDecDelta struct {
|
||||
delta [256]byte
|
||||
pos byte
|
||||
distance int // in range [1, 256]
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode raw stream which has a delta filter as the first filter.
|
||||
*/
|
||||
func xzDecDeltaRun(s *xzDecDelta, b *xzBuf, chain func(*xzBuf) xzRet) xzRet {
|
||||
outStart := b.outPos
|
||||
ret := chain(b)
|
||||
for i := outStart; i < b.outPos; i++ {
|
||||
tmp := b.out[i] + s.delta[byte(s.distance+int(s.pos))]
|
||||
s.delta[s.pos] = tmp
|
||||
b.out[i] = tmp
|
||||
s.pos--
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/*
|
||||
* Allocate memory for a delta decoder. xzDecDeltaReset must be used
|
||||
* before calling xzDecDeltaRun.
|
||||
*/
|
||||
func xzDecDeltaCreate() *xzDecDelta {
|
||||
return new(xzDecDelta)
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns xzOK if the given distance is valid. Otherwise
|
||||
* xzOptionsError is returned.
|
||||
*/
|
||||
func xzDecDeltaReset(s *xzDecDelta, distance int) xzRet {
|
||||
if distance < 1 || distance > 256 {
|
||||
return xzOptionsError
|
||||
}
|
||||
s.delta = [256]byte{}
|
||||
s.pos = 0
|
||||
s.distance = distance
|
||||
return xzOK
|
||||
}
|
||||
-1235
File diff suppressed because it is too large
Load Diff
-932
@@ -1,932 +0,0 @@
|
||||
/*
|
||||
* .xz Stream decoder
|
||||
*
|
||||
* Author: Lasse Collin <lasse.collin@tukaani.org>
|
||||
*
|
||||
* Translation to Go: Michael Cross <https://github.com/xi2>
|
||||
*
|
||||
* This file has been put into the public domain.
|
||||
* You can do whatever you want with this file.
|
||||
*/
|
||||
|
||||
package xz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"hash"
|
||||
"hash/crc32"
|
||||
"hash/crc64"
|
||||
)
|
||||
|
||||
/* from linux/lib/xz/xz_stream.h **************************************/
|
||||
|
||||
/*
|
||||
* See the .xz file format specification at
|
||||
* http://tukaani.org/xz/xz-file-format.txt
|
||||
* to understand the container format.
|
||||
*/
|
||||
const (
|
||||
streamHeaderSize = 12
|
||||
headerMagic = "\xfd7zXZ\x00"
|
||||
footerMagic = "YZ"
|
||||
)
|
||||
|
||||
/*
|
||||
* Variable-length integer can hold a 63-bit unsigned integer or a special
|
||||
* value indicating that the value is unknown.
|
||||
*/
|
||||
type vliType uint64
|
||||
|
||||
const (
|
||||
vliUnknown vliType = ^vliType(0)
|
||||
/* Maximum encoded size of a VLI */
|
||||
vliBytesMax = 8 * 8 / 7 // (Sizeof(vliType) * 8 / 7)
|
||||
)
|
||||
|
||||
/* from linux/lib/xz/xz_dec_stream.c **********************************/
|
||||
|
||||
/* Hash used to validate the Index field */
|
||||
type xzDecHash struct {
|
||||
unpadded vliType
|
||||
uncompressed vliType
|
||||
sha256 hash.Hash
|
||||
}
|
||||
|
||||
// type of xzDec.sequence
|
||||
type xzDecSeq int
|
||||
|
||||
const (
|
||||
seqStreamHeader xzDecSeq = iota
|
||||
seqBlockStart
|
||||
seqBlockHeader
|
||||
seqBlockUncompress
|
||||
seqBlockPadding
|
||||
seqBlockCheck
|
||||
seqIndex
|
||||
seqIndexPadding
|
||||
seqIndexCRC32
|
||||
seqStreamFooter
|
||||
)
|
||||
|
||||
// type of xzDec.index.sequence
|
||||
type xzDecIndexSeq int
|
||||
|
||||
const (
|
||||
seqIndexCount xzDecIndexSeq = iota
|
||||
seqIndexUnpadded
|
||||
seqIndexUncompressed
|
||||
)
|
||||
|
||||
/**
|
||||
* xzDec - Opaque type to hold the XZ decoder state
|
||||
*/
|
||||
type xzDec struct {
|
||||
/* Position in decMain */
|
||||
sequence xzDecSeq
|
||||
/* Position in variable-length integers and Check fields */
|
||||
pos int
|
||||
/* Variable-length integer decoded by decVLI */
|
||||
vli vliType
|
||||
/* Saved inPos and outPos */
|
||||
inStart int
|
||||
outStart int
|
||||
/* CRC32 checksum hash used in Index */
|
||||
crc32 hash.Hash
|
||||
/* Hashes used in Blocks */
|
||||
checkCRC32 hash.Hash
|
||||
checkCRC64 hash.Hash
|
||||
checkSHA256 hash.Hash
|
||||
/* for checkTypes CRC32/CRC64/SHA256, check is one of the above 3 hashes */
|
||||
check hash.Hash
|
||||
/* Embedded stream header struct containing CheckType */
|
||||
*Header
|
||||
/*
|
||||
* True if the next call to xzDecRun is allowed to return
|
||||
* xzBufError.
|
||||
*/
|
||||
allowBufError bool
|
||||
/* Information stored in Block Header */
|
||||
blockHeader struct {
|
||||
/*
|
||||
* Value stored in the Compressed Size field, or
|
||||
* vliUnknown if Compressed Size is not present.
|
||||
*/
|
||||
compressed vliType
|
||||
/*
|
||||
* Value stored in the Uncompressed Size field, or
|
||||
* vliUnknown if Uncompressed Size is not present.
|
||||
*/
|
||||
uncompressed vliType
|
||||
/* Size of the Block Header field */
|
||||
size int
|
||||
}
|
||||
/* Information collected when decoding Blocks */
|
||||
block struct {
|
||||
/* Observed compressed size of the current Block */
|
||||
compressed vliType
|
||||
/* Observed uncompressed size of the current Block */
|
||||
uncompressed vliType
|
||||
/* Number of Blocks decoded so far */
|
||||
count vliType
|
||||
/*
|
||||
* Hash calculated from the Block sizes. This is used to
|
||||
* validate the Index field.
|
||||
*/
|
||||
hash xzDecHash
|
||||
}
|
||||
/* Variables needed when verifying the Index field */
|
||||
index struct {
|
||||
/* Position in decIndex */
|
||||
sequence xzDecIndexSeq
|
||||
/* Size of the Index in bytes */
|
||||
size vliType
|
||||
/* Number of Records (matches block.count in valid files) */
|
||||
count vliType
|
||||
/*
|
||||
* Hash calculated from the Records (matches block.hash in
|
||||
* valid files).
|
||||
*/
|
||||
hash xzDecHash
|
||||
}
|
||||
/*
|
||||
* Temporary buffer needed to hold Stream Header, Block Header,
|
||||
* and Stream Footer. The Block Header is the biggest (1 KiB)
|
||||
* so we reserve space according to that. bufArray has to be aligned
|
||||
* to a multiple of four bytes; the variables before it
|
||||
* should guarantee this.
|
||||
*/
|
||||
temp struct {
|
||||
pos int
|
||||
buf []byte // slice buf will be backed by bufArray
|
||||
bufArray [1024]byte
|
||||
}
|
||||
// chain is the function (or to be more precise, closure) which
|
||||
// does the decompression and will call into the lzma2 and other
|
||||
// filter code as needed. It is constructed by decBlockHeader
|
||||
chain func(b *xzBuf) xzRet
|
||||
// lzma2 holds the state of the last filter (which must be LZMA2)
|
||||
lzma2 *xzDecLZMA2
|
||||
// pointers to allocated BCJ/Delta filters
|
||||
bcjs []*xzDecBCJ
|
||||
deltas []*xzDecDelta
|
||||
// number of currently in use BCJ/Delta filters from the above
|
||||
bcjsUsed int
|
||||
deltasUsed int
|
||||
}
|
||||
|
||||
/* Sizes of the Check field with different Check IDs */
|
||||
var checkSizes = [...]byte{
|
||||
0,
|
||||
4, 4, 4,
|
||||
8, 8, 8,
|
||||
16, 16, 16,
|
||||
32, 32, 32,
|
||||
64, 64, 64,
|
||||
}
|
||||
|
||||
/*
|
||||
* Fill s.temp by copying data starting from b.in[b.inPos]. Caller
|
||||
* must have set s.temp.pos to indicate how much data we are supposed
|
||||
* to copy into s.temp.buf. Return true once s.temp.pos has reached
|
||||
* len(s.temp.buf).
|
||||
*/
|
||||
func fillTemp(s *xzDec, b *xzBuf) bool {
|
||||
copySize := len(b.in) - b.inPos
|
||||
tempRemaining := len(s.temp.buf) - s.temp.pos
|
||||
if copySize > tempRemaining {
|
||||
copySize = tempRemaining
|
||||
}
|
||||
copy(s.temp.buf[s.temp.pos:], b.in[b.inPos:])
|
||||
b.inPos += copySize
|
||||
s.temp.pos += copySize
|
||||
if s.temp.pos == len(s.temp.buf) {
|
||||
s.temp.pos = 0
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/* Decode a variable-length integer (little-endian base-128 encoding) */
|
||||
func decVLI(s *xzDec, in []byte, inPos *int) xzRet {
|
||||
var byte byte
|
||||
if s.pos == 0 {
|
||||
s.vli = 0
|
||||
}
|
||||
for *inPos < len(in) {
|
||||
byte = in[*inPos]
|
||||
*inPos++
|
||||
s.vli |= vliType(byte&0x7f) << uint(s.pos)
|
||||
if byte&0x80 == 0 {
|
||||
/* Don't allow non-minimal encodings. */
|
||||
if byte == 0 && s.pos != 0 {
|
||||
return xzDataError
|
||||
}
|
||||
s.pos = 0
|
||||
return xzStreamEnd
|
||||
}
|
||||
s.pos += 7
|
||||
if s.pos == 7*vliBytesMax {
|
||||
return xzDataError
|
||||
}
|
||||
}
|
||||
return xzOK
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode the Compressed Data field from a Block. Update and validate
|
||||
* the observed compressed and uncompressed sizes of the Block so that
|
||||
* they don't exceed the values possibly stored in the Block Header
|
||||
* (validation assumes that no integer overflow occurs, since vliType
|
||||
* is uint64). Update s.check if presence of the CRC32/CRC64/SHA256
|
||||
* field was indicated in Stream Header.
|
||||
*
|
||||
* Once the decoding is finished, validate that the observed sizes match
|
||||
* the sizes possibly stored in the Block Header. Update the hash and
|
||||
* Block count, which are later used to validate the Index field.
|
||||
*/
|
||||
func decBlock(s *xzDec, b *xzBuf) xzRet {
|
||||
var ret xzRet
|
||||
s.inStart = b.inPos
|
||||
s.outStart = b.outPos
|
||||
ret = s.chain(b)
|
||||
s.block.compressed += vliType(b.inPos - s.inStart)
|
||||
s.block.uncompressed += vliType(b.outPos - s.outStart)
|
||||
/*
|
||||
* There is no need to separately check for vliUnknown since
|
||||
* the observed sizes are always smaller than vliUnknown.
|
||||
*/
|
||||
if s.block.compressed > s.blockHeader.compressed ||
|
||||
s.block.uncompressed > s.blockHeader.uncompressed {
|
||||
return xzDataError
|
||||
}
|
||||
switch s.CheckType {
|
||||
case CheckCRC32, CheckCRC64, CheckSHA256:
|
||||
_, _ = s.check.Write(b.out[s.outStart:b.outPos])
|
||||
}
|
||||
if ret == xzStreamEnd {
|
||||
if s.blockHeader.compressed != vliUnknown &&
|
||||
s.blockHeader.compressed != s.block.compressed {
|
||||
return xzDataError
|
||||
}
|
||||
if s.blockHeader.uncompressed != vliUnknown &&
|
||||
s.blockHeader.uncompressed != s.block.uncompressed {
|
||||
return xzDataError
|
||||
}
|
||||
s.block.hash.unpadded +=
|
||||
vliType(s.blockHeader.size) + s.block.compressed
|
||||
s.block.hash.unpadded += vliType(checkSizes[s.CheckType])
|
||||
s.block.hash.uncompressed += s.block.uncompressed
|
||||
var buf [2 * 8]byte // 2*Sizeof(vliType)
|
||||
putLE64(uint64(s.block.hash.unpadded), buf[:])
|
||||
putLE64(uint64(s.block.hash.uncompressed), buf[8:])
|
||||
_, _ = s.block.hash.sha256.Write(buf[:])
|
||||
s.block.count++
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/* Update the Index size and the CRC32 hash. */
|
||||
func indexUpdate(s *xzDec, b *xzBuf) {
|
||||
inUsed := b.inPos - s.inStart
|
||||
s.index.size += vliType(inUsed)
|
||||
_, _ = s.crc32.Write(b.in[s.inStart : s.inStart+inUsed])
|
||||
}
|
||||
|
||||
/*
|
||||
* Decode the Number of Records, Unpadded Size, and Uncompressed Size
|
||||
* fields from the Index field. That is, Index Padding and CRC32 are not
|
||||
* decoded by this function.
|
||||
*
|
||||
* This can return xzOK (more input needed), xzStreamEnd (everything
|
||||
* successfully decoded), or xzDataError (input is corrupt).
|
||||
*/
|
||||
func decIndex(s *xzDec, b *xzBuf) xzRet {
|
||||
var ret xzRet
|
||||
for {
|
||||
ret = decVLI(s, b.in, &b.inPos)
|
||||
if ret != xzStreamEnd {
|
||||
indexUpdate(s, b)
|
||||
return ret
|
||||
}
|
||||
switch s.index.sequence {
|
||||
case seqIndexCount:
|
||||
s.index.count = s.vli
|
||||
/*
|
||||
* Validate that the Number of Records field
|
||||
* indicates the same number of Records as
|
||||
* there were Blocks in the Stream.
|
||||
*/
|
||||
if s.index.count != s.block.count {
|
||||
return xzDataError
|
||||
}
|
||||
s.index.sequence = seqIndexUnpadded
|
||||
case seqIndexUnpadded:
|
||||
s.index.hash.unpadded += s.vli
|
||||
s.index.sequence = seqIndexUncompressed
|
||||
case seqIndexUncompressed:
|
||||
s.index.hash.uncompressed += s.vli
|
||||
var buf [2 * 8]byte // 2*Sizeof(vliType)
|
||||
putLE64(uint64(s.index.hash.unpadded), buf[:])
|
||||
putLE64(uint64(s.index.hash.uncompressed), buf[8:])
|
||||
_, _ = s.index.hash.sha256.Write(buf[:])
|
||||
s.index.count--
|
||||
s.index.sequence = seqIndexUnpadded
|
||||
}
|
||||
if !(s.index.count > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return xzStreamEnd
|
||||
}
|
||||
|
||||
/*
|
||||
* Validate that the next 4 bytes match s.crc32.Sum(nil). s.pos must
|
||||
* be zero when starting to validate the first byte.
|
||||
*/
|
||||
func crcValidate(s *xzDec, b *xzBuf) xzRet {
|
||||
sum := s.crc32.Sum(nil)
|
||||
// CRC32 - reverse slice
|
||||
sum[0], sum[1], sum[2], sum[3] = sum[3], sum[2], sum[1], sum[0]
|
||||
for {
|
||||
if b.inPos == len(b.in) {
|
||||
return xzOK
|
||||
}
|
||||
if sum[s.pos] != b.in[b.inPos] {
|
||||
return xzDataError
|
||||
}
|
||||
b.inPos++
|
||||
s.pos++
|
||||
if !(s.pos < 4) {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.crc32.Reset()
|
||||
s.pos = 0
|
||||
return xzStreamEnd
|
||||
}
|
||||
|
||||
/*
|
||||
* Validate that the next 4/8/32 bytes match s.check.Sum(nil). s.pos
|
||||
* must be zero when starting to validate the first byte.
|
||||
*/
|
||||
func checkValidate(s *xzDec, b *xzBuf) xzRet {
|
||||
sum := s.check.Sum(nil)
|
||||
if s.CheckType == CheckCRC32 || s.CheckType == CheckCRC64 {
|
||||
// CRC32/64 - reverse slice
|
||||
for i, j := 0, len(sum)-1; i < j; i, j = i+1, j-1 {
|
||||
sum[i], sum[j] = sum[j], sum[i]
|
||||
}
|
||||
}
|
||||
for {
|
||||
if b.inPos == len(b.in) {
|
||||
return xzOK
|
||||
}
|
||||
if sum[s.pos] != b.in[b.inPos] {
|
||||
return xzDataError
|
||||
}
|
||||
b.inPos++
|
||||
s.pos++
|
||||
if !(s.pos < len(sum)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.check.Reset()
|
||||
s.pos = 0
|
||||
return xzStreamEnd
|
||||
}
|
||||
|
||||
/*
|
||||
* Skip over the Check field when the Check ID is not supported.
|
||||
* Returns true once the whole Check field has been skipped over.
|
||||
*/
|
||||
func checkSkip(s *xzDec, b *xzBuf) bool {
|
||||
for s.pos < int(checkSizes[s.CheckType]) {
|
||||
if b.inPos == len(b.in) {
|
||||
return false
|
||||
}
|
||||
b.inPos++
|
||||
s.pos++
|
||||
}
|
||||
s.pos = 0
|
||||
return true
|
||||
}
|
||||
|
||||
/* polynomial table used in decStreamHeader below */
|
||||
var xzCRC64Table = crc64.MakeTable(crc64.ECMA)
|
||||
|
||||
/* Decode the Stream Header field (the first 12 bytes of the .xz Stream). */
|
||||
func decStreamHeader(s *xzDec) xzRet {
|
||||
if string(s.temp.buf[:len(headerMagic)]) != headerMagic {
|
||||
return xzFormatError
|
||||
}
|
||||
if crc32.ChecksumIEEE(s.temp.buf[len(headerMagic):len(headerMagic)+2]) !=
|
||||
getLE32(s.temp.buf[len(headerMagic)+2:]) {
|
||||
return xzDataError
|
||||
}
|
||||
if s.temp.buf[len(headerMagic)] != 0 {
|
||||
return xzOptionsError
|
||||
}
|
||||
/*
|
||||
* Of integrity checks, we support none (Check ID = 0),
|
||||
* CRC32 (Check ID = 1), CRC64 (Check ID = 4) and SHA256 (Check ID = 10)
|
||||
* However, we will accept other check types too, but then the check
|
||||
* won't be verified and a warning (xzUnsupportedCheck) will be given.
|
||||
*/
|
||||
s.CheckType = CheckID(s.temp.buf[len(headerMagic)+1])
|
||||
if s.CheckType > checkMax {
|
||||
return xzOptionsError
|
||||
}
|
||||
switch s.CheckType {
|
||||
case CheckNone:
|
||||
// CheckNone: no action needed
|
||||
case CheckCRC32:
|
||||
if s.checkCRC32 == nil {
|
||||
s.checkCRC32 = crc32.NewIEEE()
|
||||
} else {
|
||||
s.checkCRC32.Reset()
|
||||
}
|
||||
s.check = s.checkCRC32
|
||||
case CheckCRC64:
|
||||
if s.checkCRC64 == nil {
|
||||
s.checkCRC64 = crc64.New(xzCRC64Table)
|
||||
} else {
|
||||
s.checkCRC64.Reset()
|
||||
}
|
||||
s.check = s.checkCRC64
|
||||
case CheckSHA256:
|
||||
if s.checkSHA256 == nil {
|
||||
s.checkSHA256 = sha256.New()
|
||||
} else {
|
||||
s.checkSHA256.Reset()
|
||||
}
|
||||
s.check = s.checkSHA256
|
||||
default:
|
||||
return xzUnsupportedCheck
|
||||
}
|
||||
return xzOK
|
||||
}
|
||||
|
||||
/* Decode the Stream Footer field (the last 12 bytes of the .xz Stream) */
|
||||
func decStreamFooter(s *xzDec) xzRet {
|
||||
if string(s.temp.buf[10:10+len(footerMagic)]) != footerMagic {
|
||||
return xzDataError
|
||||
}
|
||||
if crc32.ChecksumIEEE(s.temp.buf[4:10]) != getLE32(s.temp.buf) {
|
||||
return xzDataError
|
||||
}
|
||||
/*
|
||||
* Validate Backward Size. Note that we never added the size of the
|
||||
* Index CRC32 field to s->index.size, thus we use s->index.size / 4
|
||||
* instead of s->index.size / 4 - 1.
|
||||
*/
|
||||
if s.index.size>>2 != vliType(getLE32(s.temp.buf[4:])) {
|
||||
return xzDataError
|
||||
}
|
||||
if s.temp.buf[8] != 0 || CheckID(s.temp.buf[9]) != s.CheckType {
|
||||
return xzDataError
|
||||
}
|
||||
/*
|
||||
* Use xzStreamEnd instead of xzOK to be more convenient
|
||||
* for the caller.
|
||||
*/
|
||||
return xzStreamEnd
|
||||
}
|
||||
|
||||
/* Decode the Block Header and initialize the filter chain. */
|
||||
func decBlockHeader(s *xzDec) xzRet {
|
||||
var ret xzRet
|
||||
/*
|
||||
* Validate the CRC32. We know that the temp buffer is at least
|
||||
* eight bytes so this is safe.
|
||||
*/
|
||||
crc := getLE32(s.temp.buf[len(s.temp.buf)-4:])
|
||||
s.temp.buf = s.temp.buf[:len(s.temp.buf)-4]
|
||||
if crc32.ChecksumIEEE(s.temp.buf) != crc {
|
||||
return xzDataError
|
||||
}
|
||||
s.temp.pos = 2
|
||||
/*
|
||||
* Catch unsupported Block Flags.
|
||||
*/
|
||||
if s.temp.buf[1]&0x3C != 0 {
|
||||
return xzOptionsError
|
||||
}
|
||||
/* Compressed Size */
|
||||
if s.temp.buf[1]&0x40 != 0 {
|
||||
if decVLI(s, s.temp.buf, &s.temp.pos) != xzStreamEnd {
|
||||
return xzDataError
|
||||
}
|
||||
if s.vli >= 1<<63-8 {
|
||||
// the whole block must stay smaller than 2^63 bytes
|
||||
// the block header cannot be smaller than 8 bytes
|
||||
return xzDataError
|
||||
}
|
||||
if s.vli == 0 {
|
||||
// compressed size must be non-zero
|
||||
return xzDataError
|
||||
}
|
||||
s.blockHeader.compressed = s.vli
|
||||
} else {
|
||||
s.blockHeader.compressed = vliUnknown
|
||||
}
|
||||
/* Uncompressed Size */
|
||||
if s.temp.buf[1]&0x80 != 0 {
|
||||
if decVLI(s, s.temp.buf, &s.temp.pos) != xzStreamEnd {
|
||||
return xzDataError
|
||||
}
|
||||
s.blockHeader.uncompressed = s.vli
|
||||
} else {
|
||||
s.blockHeader.uncompressed = vliUnknown
|
||||
}
|
||||
// get total number of filters (1-4)
|
||||
filterTotal := int(s.temp.buf[1]&0x03) + 1
|
||||
// slice to hold decoded filters
|
||||
filterList := make([]struct {
|
||||
id xzFilterID
|
||||
props uint32
|
||||
}, filterTotal)
|
||||
// decode the non-last filters which cannot be LZMA2
|
||||
for i := 0; i < filterTotal-1; i++ {
|
||||
/* Valid Filter Flags always take at least two bytes. */
|
||||
if len(s.temp.buf)-s.temp.pos < 2 {
|
||||
return xzDataError
|
||||
}
|
||||
s.temp.pos += 2
|
||||
switch id := xzFilterID(s.temp.buf[s.temp.pos-2]); id {
|
||||
case idDelta:
|
||||
// delta filter
|
||||
if s.temp.buf[s.temp.pos-1] != 0x01 {
|
||||
return xzOptionsError
|
||||
}
|
||||
/* Filter Properties contains distance - 1 */
|
||||
if len(s.temp.buf)-s.temp.pos < 1 {
|
||||
return xzDataError
|
||||
}
|
||||
props := uint32(s.temp.buf[s.temp.pos])
|
||||
s.temp.pos++
|
||||
filterList[i] = struct {
|
||||
id xzFilterID
|
||||
props uint32
|
||||
}{id: id, props: props}
|
||||
case idBCJX86, idBCJPowerPC, idBCJIA64,
|
||||
idBCJARM, idBCJARMThumb, idBCJSPARC:
|
||||
// bcj filter
|
||||
var props uint32
|
||||
switch s.temp.buf[s.temp.pos-1] {
|
||||
case 0x00:
|
||||
props = 0
|
||||
case 0x04:
|
||||
if len(s.temp.buf)-s.temp.pos < 4 {
|
||||
return xzDataError
|
||||
}
|
||||
props = getLE32(s.temp.buf[s.temp.pos:])
|
||||
s.temp.pos += 4
|
||||
default:
|
||||
return xzOptionsError
|
||||
}
|
||||
filterList[i] = struct {
|
||||
id xzFilterID
|
||||
props uint32
|
||||
}{id: id, props: props}
|
||||
default:
|
||||
return xzOptionsError
|
||||
}
|
||||
}
|
||||
/*
|
||||
* decode the last filter which must be LZMA2
|
||||
*/
|
||||
if len(s.temp.buf)-s.temp.pos < 2 {
|
||||
return xzDataError
|
||||
}
|
||||
/* Filter ID = LZMA2 */
|
||||
if xzFilterID(s.temp.buf[s.temp.pos]) != idLZMA2 {
|
||||
return xzOptionsError
|
||||
}
|
||||
s.temp.pos++
|
||||
/* Size of Properties = 1-byte Filter Properties */
|
||||
if s.temp.buf[s.temp.pos] != 0x01 {
|
||||
return xzOptionsError
|
||||
}
|
||||
s.temp.pos++
|
||||
/* Filter Properties contains LZMA2 dictionary size. */
|
||||
if len(s.temp.buf)-s.temp.pos < 1 {
|
||||
return xzDataError
|
||||
}
|
||||
props := uint32(s.temp.buf[s.temp.pos])
|
||||
s.temp.pos++
|
||||
filterList[filterTotal-1] = struct {
|
||||
id xzFilterID
|
||||
props uint32
|
||||
}{id: idLZMA2, props: props}
|
||||
/*
|
||||
* Process the filter list and create s.chain, going from last
|
||||
* filter (LZMA2) to first filter
|
||||
*
|
||||
* First, LZMA2.
|
||||
*/
|
||||
ret = xzDecLZMA2Reset(s.lzma2, byte(filterList[filterTotal-1].props))
|
||||
if ret != xzOK {
|
||||
return ret
|
||||
}
|
||||
s.chain = func(b *xzBuf) xzRet {
|
||||
return xzDecLZMA2Run(s.lzma2, b)
|
||||
}
|
||||
/*
|
||||
* Now the non-last filters
|
||||
*/
|
||||
for i := filterTotal - 2; i >= 0; i-- {
|
||||
switch id := filterList[i].id; id {
|
||||
case idDelta:
|
||||
// delta filter
|
||||
var delta *xzDecDelta
|
||||
if s.deltasUsed < len(s.deltas) {
|
||||
delta = s.deltas[s.deltasUsed]
|
||||
} else {
|
||||
delta = xzDecDeltaCreate()
|
||||
s.deltas = append(s.deltas, delta)
|
||||
}
|
||||
s.deltasUsed++
|
||||
ret = xzDecDeltaReset(delta, int(filterList[i].props)+1)
|
||||
if ret != xzOK {
|
||||
return ret
|
||||
}
|
||||
chain := s.chain
|
||||
s.chain = func(b *xzBuf) xzRet {
|
||||
return xzDecDeltaRun(delta, b, chain)
|
||||
}
|
||||
case idBCJX86, idBCJPowerPC, idBCJIA64,
|
||||
idBCJARM, idBCJARMThumb, idBCJSPARC:
|
||||
// bcj filter
|
||||
var bcj *xzDecBCJ
|
||||
if s.bcjsUsed < len(s.bcjs) {
|
||||
bcj = s.bcjs[s.bcjsUsed]
|
||||
} else {
|
||||
bcj = xzDecBCJCreate()
|
||||
s.bcjs = append(s.bcjs, bcj)
|
||||
}
|
||||
s.bcjsUsed++
|
||||
ret = xzDecBCJReset(bcj, id, int(filterList[i].props))
|
||||
if ret != xzOK {
|
||||
return ret
|
||||
}
|
||||
chain := s.chain
|
||||
s.chain = func(b *xzBuf) xzRet {
|
||||
return xzDecBCJRun(bcj, b, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
/* The rest must be Header Padding. */
|
||||
for s.temp.pos < len(s.temp.buf) {
|
||||
if s.temp.buf[s.temp.pos] != 0x00 {
|
||||
return xzOptionsError
|
||||
}
|
||||
s.temp.pos++
|
||||
}
|
||||
s.temp.pos = 0
|
||||
s.block.compressed = 0
|
||||
s.block.uncompressed = 0
|
||||
return xzOK
|
||||
}
|
||||
|
||||
func decMain(s *xzDec, b *xzBuf) xzRet {
|
||||
var ret xzRet
|
||||
/*
|
||||
* Store the start position for the case when we are in the middle
|
||||
* of the Index field.
|
||||
*/
|
||||
s.inStart = b.inPos
|
||||
for {
|
||||
switch s.sequence {
|
||||
case seqStreamHeader:
|
||||
/*
|
||||
* Stream Header is copied to s.temp, and then
|
||||
* decoded from there. This way if the caller
|
||||
* gives us only little input at a time, we can
|
||||
* still keep the Stream Header decoding code
|
||||
* simple. Similar approach is used in many places
|
||||
* in this file.
|
||||
*/
|
||||
if !fillTemp(s, b) {
|
||||
return xzOK
|
||||
}
|
||||
/*
|
||||
* If decStreamHeader returns
|
||||
* xzUnsupportedCheck, it is still possible
|
||||
* to continue decoding. Thus, update s.sequence
|
||||
* before calling decStreamHeader.
|
||||
*/
|
||||
s.sequence = seqBlockStart
|
||||
ret = decStreamHeader(s)
|
||||
if ret != xzOK {
|
||||
return ret
|
||||
}
|
||||
fallthrough
|
||||
case seqBlockStart:
|
||||
/* We need one byte of input to continue. */
|
||||
if b.inPos == len(b.in) {
|
||||
return xzOK
|
||||
}
|
||||
/* See if this is the beginning of the Index field. */
|
||||
if b.in[b.inPos] == 0 {
|
||||
s.inStart = b.inPos
|
||||
b.inPos++
|
||||
s.sequence = seqIndex
|
||||
break
|
||||
}
|
||||
/*
|
||||
* Calculate the size of the Block Header and
|
||||
* prepare to decode it.
|
||||
*/
|
||||
s.blockHeader.size = (int(b.in[b.inPos]) + 1) * 4
|
||||
s.temp.buf = s.temp.bufArray[:s.blockHeader.size]
|
||||
s.temp.pos = 0
|
||||
s.sequence = seqBlockHeader
|
||||
fallthrough
|
||||
case seqBlockHeader:
|
||||
if !fillTemp(s, b) {
|
||||
return xzOK
|
||||
}
|
||||
ret = decBlockHeader(s)
|
||||
if ret != xzOK {
|
||||
return ret
|
||||
}
|
||||
s.sequence = seqBlockUncompress
|
||||
fallthrough
|
||||
case seqBlockUncompress:
|
||||
ret = decBlock(s, b)
|
||||
if ret != xzStreamEnd {
|
||||
return ret
|
||||
}
|
||||
s.sequence = seqBlockPadding
|
||||
fallthrough
|
||||
case seqBlockPadding:
|
||||
/*
|
||||
* Size of Compressed Data + Block Padding
|
||||
* must be a multiple of four. We don't need
|
||||
* s->block.compressed for anything else
|
||||
* anymore, so we use it here to test the size
|
||||
* of the Block Padding field.
|
||||
*/
|
||||
for s.block.compressed&3 != 0 {
|
||||
if b.inPos == len(b.in) {
|
||||
return xzOK
|
||||
}
|
||||
if b.in[b.inPos] != 0 {
|
||||
return xzDataError
|
||||
}
|
||||
b.inPos++
|
||||
s.block.compressed++
|
||||
}
|
||||
s.sequence = seqBlockCheck
|
||||
fallthrough
|
||||
case seqBlockCheck:
|
||||
switch s.CheckType {
|
||||
case CheckCRC32, CheckCRC64, CheckSHA256:
|
||||
ret = checkValidate(s, b)
|
||||
if ret != xzStreamEnd {
|
||||
return ret
|
||||
}
|
||||
default:
|
||||
if !checkSkip(s, b) {
|
||||
return xzOK
|
||||
}
|
||||
}
|
||||
s.sequence = seqBlockStart
|
||||
case seqIndex:
|
||||
ret = decIndex(s, b)
|
||||
if ret != xzStreamEnd {
|
||||
return ret
|
||||
}
|
||||
s.sequence = seqIndexPadding
|
||||
fallthrough
|
||||
case seqIndexPadding:
|
||||
for (s.index.size+vliType(b.inPos-s.inStart))&3 != 0 {
|
||||
if b.inPos == len(b.in) {
|
||||
indexUpdate(s, b)
|
||||
return xzOK
|
||||
}
|
||||
if b.in[b.inPos] != 0 {
|
||||
return xzDataError
|
||||
}
|
||||
b.inPos++
|
||||
}
|
||||
/* Finish the CRC32 value and Index size. */
|
||||
indexUpdate(s, b)
|
||||
/* Compare the hashes to validate the Index field. */
|
||||
if !bytes.Equal(
|
||||
s.block.hash.sha256.Sum(nil), s.index.hash.sha256.Sum(nil)) {
|
||||
return xzDataError
|
||||
}
|
||||
s.sequence = seqIndexCRC32
|
||||
fallthrough
|
||||
case seqIndexCRC32:
|
||||
ret = crcValidate(s, b)
|
||||
if ret != xzStreamEnd {
|
||||
return ret
|
||||
}
|
||||
s.temp.buf = s.temp.bufArray[:streamHeaderSize]
|
||||
s.sequence = seqStreamFooter
|
||||
fallthrough
|
||||
case seqStreamFooter:
|
||||
if !fillTemp(s, b) {
|
||||
return xzOK
|
||||
}
|
||||
return decStreamFooter(s)
|
||||
}
|
||||
}
|
||||
/* Never reached */
|
||||
}
|
||||
|
||||
/**
|
||||
* xzDecRun - Run the XZ decoder
|
||||
* @s: Decoder state allocated using xzDecInit
|
||||
* @b: Input and output buffers
|
||||
*
|
||||
* See xzRet for details of return values.
|
||||
*
|
||||
* xzDecRun is a wrapper for decMain to handle some special cases.
|
||||
*
|
||||
* We must return xzBufError when it seems clear that we are not
|
||||
* going to make any progress anymore. This is to prevent the caller
|
||||
* from calling us infinitely when the input file is truncated or
|
||||
* otherwise corrupt. Since zlib-style API allows that the caller
|
||||
* fills the input buffer only when the decoder doesn't produce any
|
||||
* new output, we have to be careful to avoid returning xzBufError
|
||||
* too easily: xzBufError is returned only after the second
|
||||
* consecutive call to xzDecRun that makes no progress.
|
||||
*/
|
||||
func xzDecRun(s *xzDec, b *xzBuf) xzRet {
|
||||
inStart := b.inPos
|
||||
outStart := b.outPos
|
||||
ret := decMain(s, b)
|
||||
if ret == xzOK && inStart == b.inPos && outStart == b.outPos {
|
||||
if s.allowBufError {
|
||||
ret = xzBufError
|
||||
}
|
||||
s.allowBufError = true
|
||||
} else {
|
||||
s.allowBufError = false
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* xzDecInit - Allocate and initialize a XZ decoder state
|
||||
* @dictMax: Maximum size of the LZMA2 dictionary (history buffer) for
|
||||
* decoding. LZMA2 dictionary is always 2^n bytes
|
||||
* or 2^n + 2^(n-1) bytes (the latter sizes are less common
|
||||
* in practice), so other values for dictMax don't make sense.
|
||||
*
|
||||
* dictMax specifies the maximum allowed dictionary size that xzDecRun
|
||||
* may allocate once it has parsed the dictionary size from the stream
|
||||
* headers. This way excessive allocations can be avoided while still
|
||||
* limiting the maximum memory usage to a sane value to prevent running the
|
||||
* system out of memory when decompressing streams from untrusted sources.
|
||||
*
|
||||
* xzDecInit returns a pointer to an xzDec, which is ready to be used with
|
||||
* xzDecRun.
|
||||
*/
|
||||
func xzDecInit(dictMax uint32, header *Header) *xzDec {
|
||||
s := new(xzDec)
|
||||
s.crc32 = crc32.NewIEEE()
|
||||
s.Header = header
|
||||
s.block.hash.sha256 = sha256.New()
|
||||
s.index.hash.sha256 = sha256.New()
|
||||
s.lzma2 = xzDecLZMA2Create(dictMax)
|
||||
xzDecReset(s)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* xzDecReset - Reset an already allocated decoder state
|
||||
* @s: Decoder state allocated using xzDecInit
|
||||
*
|
||||
* This function can be used to reset the decoder state without
|
||||
* reallocating memory with xzDecInit.
|
||||
*/
|
||||
func xzDecReset(s *xzDec) {
|
||||
s.sequence = seqStreamHeader
|
||||
s.allowBufError = false
|
||||
s.pos = 0
|
||||
s.crc32.Reset()
|
||||
s.check = nil
|
||||
s.CheckType = checkUnset
|
||||
s.block.compressed = 0
|
||||
s.block.uncompressed = 0
|
||||
s.block.count = 0
|
||||
s.block.hash.unpadded = 0
|
||||
s.block.hash.uncompressed = 0
|
||||
s.block.hash.sha256.Reset()
|
||||
s.index.sequence = seqIndexCount
|
||||
s.index.size = 0
|
||||
s.index.count = 0
|
||||
s.index.hash.unpadded = 0
|
||||
s.index.hash.uncompressed = 0
|
||||
s.index.hash.sha256.Reset()
|
||||
s.temp.pos = 0
|
||||
s.temp.buf = s.temp.bufArray[:streamHeaderSize]
|
||||
s.chain = nil
|
||||
s.bcjsUsed = 0
|
||||
s.deltasUsed = 0
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* XZ decompressor utility functions
|
||||
*
|
||||
* Author: Michael Cross <https://github.com/xi2>
|
||||
*
|
||||
* This file has been put into the public domain.
|
||||
* You can do whatever you want with this file.
|
||||
*/
|
||||
|
||||
package xz
|
||||
|
||||
func getLE32(buf []byte) uint32 {
|
||||
return uint32(buf[0]) |
|
||||
uint32(buf[1])<<8 |
|
||||
uint32(buf[2])<<16 |
|
||||
uint32(buf[3])<<24
|
||||
}
|
||||
|
||||
func getBE32(buf []byte) uint32 {
|
||||
return uint32(buf[0])<<24 |
|
||||
uint32(buf[1])<<16 |
|
||||
uint32(buf[2])<<8 |
|
||||
uint32(buf[3])
|
||||
}
|
||||
|
||||
func putLE32(val uint32, buf []byte) {
|
||||
buf[0] = byte(val)
|
||||
buf[1] = byte(val >> 8)
|
||||
buf[2] = byte(val >> 16)
|
||||
buf[3] = byte(val >> 24)
|
||||
return
|
||||
}
|
||||
|
||||
func putBE32(val uint32, buf []byte) {
|
||||
buf[0] = byte(val >> 24)
|
||||
buf[1] = byte(val >> 16)
|
||||
buf[2] = byte(val >> 8)
|
||||
buf[3] = byte(val)
|
||||
return
|
||||
}
|
||||
|
||||
func putLE64(val uint64, buf []byte) {
|
||||
buf[0] = byte(val)
|
||||
buf[1] = byte(val >> 8)
|
||||
buf[2] = byte(val >> 16)
|
||||
buf[3] = byte(val >> 24)
|
||||
buf[4] = byte(val >> 32)
|
||||
buf[5] = byte(val >> 40)
|
||||
buf[6] = byte(val >> 48)
|
||||
buf[7] = byte(val >> 56)
|
||||
return
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* XZ decompressor
|
||||
*
|
||||
* Authors: Lasse Collin <lasse.collin@tukaani.org>
|
||||
* Igor Pavlov <http://7-zip.org/>
|
||||
*
|
||||
* Translation to Go: Michael Cross <https://github.com/xi2>
|
||||
*
|
||||
* This file has been put into the public domain.
|
||||
* You can do whatever you want with this file.
|
||||
*/
|
||||
|
||||
package xz
|
||||
|
||||
/* from linux/include/linux/xz.h **************************************/
|
||||
|
||||
/**
|
||||
* xzRet - Return codes
|
||||
* @xzOK: Everything is OK so far. More input or more
|
||||
* output space is required to continue.
|
||||
* @xzStreamEnd: Operation finished successfully.
|
||||
* @xzUnSupportedCheck: Integrity check type is not supported. Decoding
|
||||
* is still possible by simply calling xzDecRun
|
||||
* again.
|
||||
* @xzMemlimitError: A bigger LZMA2 dictionary would be needed than
|
||||
* allowed by the dictMax argument given to
|
||||
* xzDecInit.
|
||||
* @xzFormatError: File format was not recognized (wrong magic
|
||||
* bytes).
|
||||
* @xzOptionsError: This implementation doesn't support the requested
|
||||
* compression options. In the decoder this means
|
||||
* that the header CRC32 matches, but the header
|
||||
* itself specifies something that we don't support.
|
||||
* @xzDataError: Compressed data is corrupt.
|
||||
* @xzBufError: Cannot make any progress.
|
||||
*
|
||||
* xzBufError is returned when two consecutive calls to XZ code cannot
|
||||
* consume any input and cannot produce any new output. This happens
|
||||
* when there is no new input available, or the output buffer is full
|
||||
* while at least one output byte is still pending. Assuming your code
|
||||
* is not buggy, you can get this error only when decoding a
|
||||
* compressed stream that is truncated or otherwise corrupt.
|
||||
*/
|
||||
type xzRet int
|
||||
|
||||
const (
|
||||
xzOK xzRet = iota
|
||||
xzStreamEnd
|
||||
xzUnsupportedCheck
|
||||
xzMemlimitError
|
||||
xzFormatError
|
||||
xzOptionsError
|
||||
xzDataError
|
||||
xzBufError
|
||||
)
|
||||
|
||||
/**
|
||||
* xzBuf - Passing input and output buffers to XZ code
|
||||
* @in: Input buffer.
|
||||
* @inPos: Current position in the input buffer. This must not exceed
|
||||
* input buffer size.
|
||||
* @out: Output buffer.
|
||||
* @outPos: Current position in the output buffer. This must not exceed
|
||||
* output buffer size.
|
||||
*
|
||||
* Only the contents of the output buffer from out[outPos] onward, and
|
||||
* the variables inPos and outPos are modified by the XZ code.
|
||||
*/
|
||||
type xzBuf struct {
|
||||
in []byte
|
||||
inPos int
|
||||
out []byte
|
||||
outPos int
|
||||
}
|
||||
|
||||
/* All XZ filter IDs */
|
||||
type xzFilterID int64
|
||||
|
||||
const (
|
||||
idDelta xzFilterID = 0x03
|
||||
idBCJX86 xzFilterID = 0x04
|
||||
idBCJPowerPC xzFilterID = 0x05
|
||||
idBCJIA64 xzFilterID = 0x06
|
||||
idBCJARM xzFilterID = 0x07
|
||||
idBCJARMThumb xzFilterID = 0x08
|
||||
idBCJSPARC xzFilterID = 0x09
|
||||
idLZMA2 xzFilterID = 0x21
|
||||
)
|
||||
|
||||
// CheckID is the type of the data integrity check in an XZ stream
|
||||
// calculated from the uncompressed data.
|
||||
type CheckID int
|
||||
|
||||
func (id CheckID) String() string {
|
||||
switch id {
|
||||
case CheckNone:
|
||||
return "None"
|
||||
case CheckCRC32:
|
||||
return "CRC32"
|
||||
case CheckCRC64:
|
||||
return "CRC64"
|
||||
case CheckSHA256:
|
||||
return "SHA256"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
CheckNone CheckID = 0x00
|
||||
CheckCRC32 CheckID = 0x01
|
||||
CheckCRC64 CheckID = 0x04
|
||||
CheckSHA256 CheckID = 0x0A
|
||||
checkMax CheckID = 0x0F
|
||||
checkUnset CheckID = -1
|
||||
)
|
||||
|
||||
// An XZ stream contains a stream header which holds information about
|
||||
// the stream. That information is exposed as fields of the
|
||||
// Reader. Currently it contains only the stream's data integrity
|
||||
// check type.
|
||||
type Header struct {
|
||||
CheckType CheckID // type of the stream's data integrity check
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
// Package xz implements XZ decompression natively in Go.
|
||||
//
|
||||
// Usage
|
||||
//
|
||||
// For ease of use, this package is designed to have a similar API to
|
||||
// compress/gzip. See the examples for further details.
|
||||
//
|
||||
// Implementation
|
||||
//
|
||||
// This package is a translation from C to Go of XZ Embedded
|
||||
// (http://tukaani.org/xz/embedded.html) with enhancements made so as
|
||||
// to implement all mandatory and optional parts of the XZ file format
|
||||
// specification v1.0.4. It supports all filters and block check
|
||||
// types, supports multiple streams, and performs index verification
|
||||
// using SHA-256 as recommended by the specification.
|
||||
//
|
||||
// Speed
|
||||
//
|
||||
// On the author's Intel Ivybridge i5, decompression speed is about
|
||||
// half that of the standard XZ Utils (tested with a recent linux
|
||||
// kernel tarball).
|
||||
//
|
||||
// Thanks
|
||||
//
|
||||
// Thanks are due to Lasse Collin and Igor Pavlov, the authors of XZ
|
||||
// Embedded, on whose code package xz is based. It would not exist
|
||||
// without their decision to allow others to modify and reuse their
|
||||
// code.
|
||||
//
|
||||
// Bug reports
|
||||
//
|
||||
// For bug reports relating to this package please contact the author
|
||||
// through https://github.com/xi2/xz/issues, and not the authors of XZ
|
||||
// Embedded.
|
||||
package xz
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
/*
|
||||
* Package xz Go Reader API
|
||||
*
|
||||
* Author: Michael Cross <https://github.com/xi2>
|
||||
*
|
||||
* This file has been put into the public domain.
|
||||
* You can do whatever you want with this file.
|
||||
*/
|
||||
|
||||
package xz
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Package specific errors.
|
||||
var (
|
||||
ErrUnsupportedCheck = errors.New("xz: integrity check type not supported")
|
||||
ErrMemlimit = errors.New("xz: LZMA2 dictionary size exceeds max")
|
||||
ErrFormat = errors.New("xz: file format not recognized")
|
||||
ErrOptions = errors.New("xz: compression options not supported")
|
||||
ErrData = errors.New("xz: data is corrupt")
|
||||
ErrBuf = errors.New("xz: data is truncated or corrupt")
|
||||
)
|
||||
|
||||
// DefaultDictMax is the default maximum dictionary size in bytes used
|
||||
// by the decoder. This value is sufficient to decompress files
|
||||
// created with XZ Utils "xz -9".
|
||||
const DefaultDictMax = 1 << 26 // 64 MiB
|
||||
|
||||
// inBufSize is the input buffer size used by the decoder.
|
||||
const inBufSize = 1 << 13 // 8 KiB
|
||||
|
||||
// A Reader is an io.Reader that can be used to retrieve uncompressed
|
||||
// data from an XZ file.
|
||||
//
|
||||
// In general, an XZ file can be a concatenation of other XZ
|
||||
// files. Reads from the Reader return the concatenation of the
|
||||
// uncompressed data of each.
|
||||
type Reader struct {
|
||||
Header
|
||||
r io.Reader // the wrapped io.Reader
|
||||
multistream bool // true if reader is in multistream mode
|
||||
rEOF bool // true after io.EOF received on r
|
||||
dEOF bool // true after decoder has completed
|
||||
padding int // bytes of stream padding read (or -1)
|
||||
in [inBufSize]byte // backing array for buf.in
|
||||
buf *xzBuf // decoder input/output buffers
|
||||
dec *xzDec // decoder state
|
||||
err error // the result of the last decoder call
|
||||
}
|
||||
|
||||
// NewReader creates a new Reader reading from r. The decompressor
|
||||
// will use an LZMA2 dictionary size up to dictMax bytes in
|
||||
// size. Passing a value of zero sets dictMax to DefaultDictMax. If
|
||||
// an individual XZ stream requires a dictionary size greater than
|
||||
// dictMax in order to decompress, Read will return ErrMemlimit.
|
||||
//
|
||||
// If NewReader is passed a value of nil for r then a Reader is
|
||||
// created such that all read attempts will return io.EOF. This is
|
||||
// useful if you just want to allocate memory for a Reader which will
|
||||
// later be initialized with Reset.
|
||||
//
|
||||
// Due to internal buffering, the Reader may read more data than
|
||||
// necessary from r.
|
||||
func NewReader(r io.Reader, dictMax uint32) (*Reader, error) {
|
||||
if dictMax == 0 {
|
||||
dictMax = DefaultDictMax
|
||||
}
|
||||
z := &Reader{
|
||||
r: r,
|
||||
multistream: true,
|
||||
padding: -1,
|
||||
buf: &xzBuf{},
|
||||
}
|
||||
if r == nil {
|
||||
z.rEOF, z.dEOF = true, true
|
||||
}
|
||||
z.dec = xzDecInit(dictMax, &z.Header)
|
||||
var err error
|
||||
if r != nil {
|
||||
_, err = z.Read(nil) // read stream header
|
||||
}
|
||||
return z, err
|
||||
}
|
||||
|
||||
// decode is a wrapper around xzDecRun that additionally handles
|
||||
// stream padding. It treats the padding as a kind of stream that
|
||||
// decodes to nothing.
|
||||
//
|
||||
// When decoding padding, z.padding >= 0
|
||||
// When decoding a real stream, z.padding == -1
|
||||
func (z *Reader) decode() (ret xzRet) {
|
||||
if z.padding >= 0 {
|
||||
// read all padding in input buffer
|
||||
for z.buf.inPos < len(z.buf.in) &&
|
||||
z.buf.in[z.buf.inPos] == 0 {
|
||||
z.buf.inPos++
|
||||
z.padding++
|
||||
}
|
||||
switch {
|
||||
case z.buf.inPos == len(z.buf.in) && z.rEOF:
|
||||
// case: out of padding. no more input data available
|
||||
if z.padding%4 != 0 {
|
||||
ret = xzDataError
|
||||
} else {
|
||||
ret = xzStreamEnd
|
||||
}
|
||||
case z.buf.inPos == len(z.buf.in):
|
||||
// case: read more padding next loop iteration
|
||||
ret = xzOK
|
||||
default:
|
||||
// case: out of padding. more input data available
|
||||
if z.padding%4 != 0 {
|
||||
ret = xzDataError
|
||||
} else {
|
||||
xzDecReset(z.dec)
|
||||
ret = xzStreamEnd
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ret = xzDecRun(z.dec, z.buf)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (z *Reader) Read(p []byte) (n int, err error) {
|
||||
// restore err
|
||||
err = z.err
|
||||
// set decoder output buffer to p
|
||||
z.buf.out = p
|
||||
z.buf.outPos = 0
|
||||
for {
|
||||
// update n
|
||||
n = z.buf.outPos
|
||||
// if last call to decoder ended with an error, return that error
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
// if decoder has finished, return with err == io.EOF
|
||||
if z.dEOF {
|
||||
err = io.EOF
|
||||
break
|
||||
}
|
||||
// if p full, return with err == nil, unless we have not yet
|
||||
// read the stream header with Read(nil)
|
||||
if n == len(p) && z.CheckType != checkUnset {
|
||||
break
|
||||
}
|
||||
// if needed, read more data from z.r
|
||||
if z.buf.inPos == len(z.buf.in) && !z.rEOF {
|
||||
rn, e := z.r.Read(z.in[:])
|
||||
if e != nil && e != io.EOF {
|
||||
// read error
|
||||
err = e
|
||||
break
|
||||
}
|
||||
if e == io.EOF {
|
||||
z.rEOF = true
|
||||
}
|
||||
// set new input buffer in z.buf
|
||||
z.buf.in = z.in[:rn]
|
||||
z.buf.inPos = 0
|
||||
}
|
||||
// decode more data
|
||||
ret := z.decode()
|
||||
switch ret {
|
||||
case xzOK:
|
||||
// no action needed
|
||||
case xzStreamEnd:
|
||||
if z.padding >= 0 {
|
||||
z.padding = -1
|
||||
if !z.multistream || z.rEOF {
|
||||
z.dEOF = true
|
||||
}
|
||||
} else {
|
||||
z.padding = 0
|
||||
}
|
||||
case xzUnsupportedCheck:
|
||||
err = ErrUnsupportedCheck
|
||||
case xzMemlimitError:
|
||||
err = ErrMemlimit
|
||||
case xzFormatError:
|
||||
err = ErrFormat
|
||||
case xzOptionsError:
|
||||
err = ErrOptions
|
||||
case xzDataError:
|
||||
err = ErrData
|
||||
case xzBufError:
|
||||
err = ErrBuf
|
||||
}
|
||||
// save err
|
||||
z.err = err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Multistream controls whether the reader is operating in multistream
|
||||
// mode.
|
||||
//
|
||||
// If enabled (the default), the Reader expects the input to be a
|
||||
// sequence of XZ streams, possibly interspersed with stream padding,
|
||||
// which it reads one after another. The effect is that the
|
||||
// concatenation of a sequence of XZ streams or XZ files is
|
||||
// treated as equivalent to the compressed result of the concatenation
|
||||
// of the sequence. This is standard behaviour for XZ readers.
|
||||
//
|
||||
// Calling Multistream(false) disables this behaviour; disabling the
|
||||
// behaviour can be useful when reading file formats that distinguish
|
||||
// individual XZ streams. In this mode, when the Reader reaches the
|
||||
// end of the stream, Read returns io.EOF. To start the next stream,
|
||||
// call z.Reset(nil) followed by z.Multistream(false). If there is no
|
||||
// next stream, z.Reset(nil) will return io.EOF.
|
||||
func (z *Reader) Multistream(ok bool) {
|
||||
z.multistream = ok
|
||||
}
|
||||
|
||||
// Reset, for non-nil values of io.Reader r, discards the Reader z's
|
||||
// state and makes it equivalent to the result of its original state
|
||||
// from NewReader, but reading from r instead. This permits reusing a
|
||||
// Reader rather than allocating a new one.
|
||||
//
|
||||
// If you wish to leave r unchanged use z.Reset(nil). This keeps r
|
||||
// unchanged and ensures internal buffering is preserved. If the
|
||||
// Reader was at the end of a stream it is then ready to read any
|
||||
// follow on streams. If there are no follow on streams z.Reset(nil)
|
||||
// returns io.EOF. If the Reader was not at the end of a stream then
|
||||
// z.Reset(nil) does nothing.
|
||||
func (z *Reader) Reset(r io.Reader) error {
|
||||
switch {
|
||||
case r == nil:
|
||||
z.multistream = true
|
||||
if !z.dEOF {
|
||||
return nil
|
||||
}
|
||||
if z.rEOF {
|
||||
return io.EOF
|
||||
}
|
||||
z.dEOF = false
|
||||
_, err := z.Read(nil) // read stream header
|
||||
return err
|
||||
default:
|
||||
z.r = r
|
||||
z.multistream = true
|
||||
z.rEOF = false
|
||||
z.dEOF = false
|
||||
z.padding = -1
|
||||
z.buf.in = nil
|
||||
z.buf.inPos = 0
|
||||
xzDecReset(z.dec)
|
||||
z.err = nil
|
||||
_, err := z.Read(nil) // read stream header
|
||||
return err
|
||||
}
|
||||
}
|
||||
Vendored
+2
-5
@@ -78,14 +78,11 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012222214-7a41e120635b
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/rootsearch
|
||||
github.com/novox/mesh-host/validate
|
||||
# github.com/therootcompany/xz v1.0.1
|
||||
## explicit; go 1.15
|
||||
github.com/therootcompany/xz
|
||||
# golang.org/x/crypto v0.57.0
|
||||
## explicit; go 1.26.0
|
||||
golang.org/x/crypto/acme
|
||||
@@ -138,4 +135,4 @@ golang.org/x/text/width
|
||||
# golang.org/x/time v0.15.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/time/rate
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012222214-7a41e120635b
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e
|
||||
|
||||
Reference in New Issue
Block a user