Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6500139a2c | ||
|
|
1c117d1189 | ||
|
|
09c6f92b4a | ||
|
|
2b15cb57f0 | ||
|
|
63602264ef | ||
|
|
f10aedac58 | ||
|
|
875ebc093e | ||
|
|
1e28342ede | ||
|
|
bc623bc1f9 | ||
|
|
ce9dc8e157 | ||
|
|
c672b3df6d | ||
|
|
25deb499bf | ||
|
|
83a9cf6e3a | ||
|
|
7b050abdcb | ||
|
|
c3b4f187e1 | ||
|
|
edd33f21d2 | ||
|
|
3cfc121670 | ||
|
|
db7e4869ae | ||
|
|
9eddf36503 | ||
|
|
d4de4543cc | ||
|
|
a371de37fe | ||
|
|
77acb40092 | ||
|
|
3e10ad1535 | ||
|
|
74bd90df7e | ||
|
|
30599c1858 | ||
|
|
10d4712f82 |
@@ -6,3 +6,5 @@
|
||||
/mesh-controller
|
||||
/postgres-provisioner
|
||||
/redis-provisioner
|
||||
# And where `go build` in cmd/mesh-controller leaves it (novox/hq issue 233).
|
||||
/cmd/mesh-controller/mesh-controller
|
||||
|
||||
@@ -256,7 +256,13 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
||||
v, err = builder.Check(building, builder.Command, checkSpecOf(request), workspace, registry, forgeFrom(), say)
|
||||
if err == nil {
|
||||
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
|
||||
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo)}
|
||||
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
|
||||
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside}
|
||||
// What each of a group's heads was judged as (novox/hq ADR 0323 §6).
|
||||
for _, m := range v.Members {
|
||||
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
|
||||
Commit: m.Head, Trunk: m.Trunk})
|
||||
}
|
||||
}
|
||||
} else if err == nil {
|
||||
// The package-registry credential is a build input, so it is resolved before the clone: a
|
||||
@@ -354,7 +360,7 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
|
||||
}
|
||||
for _, m := range c.Members {
|
||||
spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository,
|
||||
Ref: m.Ref, Paths: m.Paths})
|
||||
Ref: m.Ref, Paths: m.Paths, Base: m.Base})
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// kindApplyFailing is S21's kind: a machine whose declarations have been refused, or applied only in part,
|
||||
// for longer than one apply interval (novox/hq issue 233).
|
||||
const kindApplyFailing = "apply-failing"
|
||||
|
||||
// applyEvery is how often a node-engine applies what it was last told when nothing new arrives: its
|
||||
// ReconcileEvery. A failure still there after one more of them is not one the next apply clears.
|
||||
const applyEvery = 5 * time.Minute
|
||||
|
||||
// packageManagerKey is how a node-engine names a package manager that does not answer among what failed
|
||||
// (mesh-host's probe.go): "the package manager (pacman)".
|
||||
const packageManagerKey = "the package manager ("
|
||||
|
||||
// watchApplyFailing is S21: **a machine that does not apply what it was sent is said by the mesh, not only
|
||||
// to someone who asks for `status`** (novox/hq issue 233, issue 187's family). A workstation refused every
|
||||
// declaration for an hour and a half because its package manager did not answer, and the mesh's only word
|
||||
// on it was a line in `status`. Raised once the machine's first report of this failure is older than one apply
|
||||
// interval **while the machine is heard** — judged by time, not by how many reports said it: a node-engine
|
||||
// says a re-apply only when how it went changed, so the same failure is one report however long it lasts.
|
||||
// Names the refusal's or the failure's words; cleared by the clean apply the node-engine then says. Not while
|
||||
// the machine is silent, asleep or not known: S1 says silent, and asleep is not lost.
|
||||
func watchApplyFailing(f *signalFacts) []conditions.Observation {
|
||||
heard := map[string]bool{}
|
||||
for _, m := range f.machines {
|
||||
if !m.asleep() && !m.lastHeard.IsZero() && f.now.Sub(m.lastHeard) <= heartbeatBound(m.every) {
|
||||
heard[m.name] = true
|
||||
}
|
||||
}
|
||||
var out []conditions.Observation
|
||||
for _, d := range f.doing {
|
||||
if !d.Wrong() || d.Since == nil || f.now.Sub(*d.Since) <= applyEvery || !heard[d.Node] {
|
||||
continue
|
||||
}
|
||||
out = append(out, applyFailing(d, f.now))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyFailing is one machine's S21 condition.
|
||||
func applyFailing(d inventory.Doing, now time.Time) conditions.Observation {
|
||||
m := d.Node
|
||||
since := d.Since.UTC().Format("2006-01-02 15:04 MST")
|
||||
o := conditions.Observation{Scope: conditions.ScopeMachine, ID: m, Kind: kindApplyFailing, Machine: m,
|
||||
Severity: conditions.Warning, Said: doingWords(d),
|
||||
Resolved: m + " applies what it is sent again"}
|
||||
how := "refused its declaration whole"
|
||||
if d.Outcome != inventory.OutcomeRefused {
|
||||
how = "applied its declaration only in part"
|
||||
}
|
||||
o.Summary = fmt.Sprintf("%s has %s since %s (bound %s): %s", m, how, since, ago(applyEvery), failedNames(d))
|
||||
o.Headline = m + " is not applying its update"
|
||||
o.Explanation = fmt.Sprintf("%s has %s for %s, and trying again has not helped. The details say what fails.",
|
||||
m, plainHow(d), humanDuration(now.Sub(*d.Since)))
|
||||
if tool := unansweringTool(d); tool != "" {
|
||||
o.Summary = fmt.Sprintf("%s: %s does not answer, so no package reaches it; everything else it was sent "+
|
||||
"is applied, since %s (bound %s)", m, tool, since, ago(applyEvery))
|
||||
o.Headline = fmt.Sprintf("%s cannot install packages", m)
|
||||
o.Explanation = fmt.Sprintf("The package manager on %s (%s) has not answered for %s, so no package reaches "+
|
||||
"it. Everything else is still applied, so an update that repairs it can land; if none does, it needs a "+
|
||||
"look on the machine.", m, tool, humanDuration(now.Sub(*d.Since)))
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// plainHow is how a machine failed, as the operator reads it.
|
||||
func plainHow(d inventory.Doing) string {
|
||||
if d.Outcome == inventory.OutcomeRefused {
|
||||
return "refused every update"
|
||||
}
|
||||
return "applied its updates only in part"
|
||||
}
|
||||
|
||||
// unansweringTool is the package manager a machine said does not answer, or "".
|
||||
func unansweringTool(d inventory.Doing) string {
|
||||
for _, r := range d.Failed {
|
||||
if rest, ok := strings.CutPrefix(r.ID, packageManagerKey); ok {
|
||||
return strings.TrimSuffix(rest, ")")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// failedNames is what failed, by name: the refusal, or the failed resources' ids.
|
||||
func failedNames(d inventory.Doing) string {
|
||||
if d.Outcome == inventory.OutcomeRefused {
|
||||
return "refused"
|
||||
}
|
||||
ids := make([]string, 0, len(d.Failed))
|
||||
for _, r := range d.Failed {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return "failed " + strings.Join(ids, ", ")
|
||||
}
|
||||
|
||||
// doingWords is the machine's own words on what failed: the evidence, kept inside the mesh.
|
||||
func doingWords(d inventory.Doing) string {
|
||||
if d.Outcome == inventory.OutcomeRefused {
|
||||
return "refused: " + oneLine(d.Refused)
|
||||
}
|
||||
var parts []string
|
||||
for _, r := range d.Failed {
|
||||
parts = append(parts, r.ID+": "+oneLine(r.Error))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// unansweringPackageManager is anchor's last report as the workstation's read on 2026-10-04, once its
|
||||
// node-engine applies the rest (novox/hq issue 233): the package failed and the package manager did not answer,
|
||||
// the same failure on times reports since for before now.
|
||||
func unansweringPackageManager(now time.Time, forHowLong time.Duration, times int) inventory.Doing {
|
||||
since := now.Add(-forHowLong)
|
||||
return inventory.Doing{Node: "anchor", Outcome: inventory.OutcomeFailed, At: now.Add(-time.Second),
|
||||
Since: &since, Times: times, Applied: 41, Failed: []inventory.FailedResource{
|
||||
{ID: "apply", Error: `applying "pacman.base": nothing can be said about "base": this is the arch host and ` +
|
||||
"pacman does not answer here. Either this machine is not Arch, or its package database is broken: " +
|
||||
"pacman exited 1: error: config file /etc/pacman.conf could not be read: No such file or directory"},
|
||||
{ID: "the package manager (pacman)", Error: "pacman does not answer here, so every package in this " +
|
||||
"declaration fails and the rest of it is applied (novox/hq issue 233): pacman exited 1: error: config " +
|
||||
"file /etc/pacman.conf could not be read: No such file or directory"}}}
|
||||
}
|
||||
|
||||
// **A machine whose package manager does not answer is raised by the mesh itself, naming the tool and the
|
||||
// probe's words** (novox/hq issue 233): the words in the evidence, the path kept out of the summary.
|
||||
func TestAPackageManagerThatDoesNotAnswerIsRaisedNamingItsWords(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
f := calm(now)
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, 90*time.Minute, 18)}
|
||||
got := watchApplyFailing(f)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("raised %d conditions, want one: %+v", len(got), got)
|
||||
}
|
||||
o := got[0]
|
||||
if o.Key() != "machine.anchor.apply-failing" || o.Machine != "anchor" {
|
||||
t.Errorf("the condition is not the machine's: %s", o.Key())
|
||||
}
|
||||
if !strings.Contains(o.Summary, "pacman does not answer") || !strings.Contains(o.Summary, "since 2026-10-04 12:30") {
|
||||
t.Errorf("the summary does not name the tool and since when: %s", o.Summary)
|
||||
}
|
||||
if strings.Contains(o.Summary, "/etc/") {
|
||||
t.Errorf("the summary carries a path, which belongs in the evidence: %s", o.Summary)
|
||||
}
|
||||
if !strings.Contains(o.Said, "could not be read") {
|
||||
t.Errorf("the evidence does not carry the probe's words: %s", o.Said)
|
||||
}
|
||||
if !strings.Contains(o.Headline, "cannot install packages") || o.Explanation == "" || o.Resolved == "" {
|
||||
t.Errorf("the plain words do not say it: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// **A whole refusal is raised too**, with the node-engine's words: what the workstation's report said before the
|
||||
// node-engine applied the rest.
|
||||
func TestARefusalThatGoesOnIsRaised(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
since := now.Add(-time.Hour)
|
||||
f := calm(now)
|
||||
f.doing = []inventory.Doing{{Node: "anchor", Outcome: inventory.OutcomeRefused, Since: &since, Times: 12,
|
||||
Refused: "this is the arch host and pacman does not answer here", At: now.Add(-time.Minute)}}
|
||||
got := watchApplyFailing(f)
|
||||
if len(got) != 1 || !strings.Contains(got[0].Summary, "refused its declaration whole") ||
|
||||
!strings.Contains(got[0].Said, "pacman does not answer") {
|
||||
t.Fatalf("a refusal that went on for an hour was not raised with its words: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Judged by time while the machine is heard, not by how many reports said it** (review of novox/hq issue 233):
|
||||
// a node-engine says the same failure once, so one report an apply interval old is a fault that goes on; one
|
||||
// younger is not yet; and a silent or sleeping machine is S1's.
|
||||
func TestOneReportOlderThanAnApplyIsRaisedAndAQuietMachineIsNot(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
f := calm(now)
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, 6*time.Minute, 1)}
|
||||
if got := watchApplyFailing(f); len(got) != 1 {
|
||||
t.Errorf("one report of a failure six minutes old was not raised: %+v", got)
|
||||
}
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, 4*time.Minute, 1)}
|
||||
if got := watchApplyFailing(f); len(got) != 0 {
|
||||
t.Errorf("a failure four minutes old was raised before one apply could clear it: %+v", got)
|
||||
}
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)}
|
||||
f.machines[0].lastHeard = now.Add(-time.Hour)
|
||||
if got := watchApplyFailing(f); len(got) != 0 {
|
||||
t.Errorf("a silent machine was raised for its last report: %+v", got)
|
||||
}
|
||||
f = calm(now)
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(now, time.Hour, 1)}
|
||||
f.doing[0].Node = "a-machine-the-mesh-does-not-know"
|
||||
if got := watchApplyFailing(f); len(got) != 0 {
|
||||
t.Errorf("a machine not heard of was raised: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A failed re-apply followed by a clean one clears S21** (review of novox/hq issue 233): the node-engine says
|
||||
// the clean re-apply, the store keeps it as applied with no failure since, and the condition the failure raised
|
||||
// is cleared through the keeper on the next tick.
|
||||
func TestACleanReapplyAfterAFailedOneClearsTheCondition(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
store := conditions.NewInMemory()
|
||||
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store,
|
||||
Teller: &conditions.Told{}, Now: func() time.Time { return now }})
|
||||
defer k.Close(context.Background())
|
||||
w := &watchdogs{keeper: k, started: now.Add(-time.Hour)}
|
||||
|
||||
failing := calm(now)
|
||||
failing.doing = []inventory.Doing{unansweringPackageManager(now, 20*time.Minute, 1)}
|
||||
w.see(t.Context(), failing)
|
||||
open, err := k.Open(t.Context())
|
||||
if err != nil || len(open) != 1 || open[0].Kind != kindApplyFailing {
|
||||
t.Fatalf("the failed re-apply raised %+v (%v), want apply-failing", open, err)
|
||||
}
|
||||
|
||||
clean := calm(now.Add(5 * time.Minute))
|
||||
clean.doing = nil // the clean re-apply recorded: NotDoingWhatTheyWereTold no longer lists the machine
|
||||
w.see(t.Context(), clean)
|
||||
if open, _ := k.Open(t.Context()); len(open) != 0 {
|
||||
t.Fatalf("the condition stayed open after the clean re-apply: %+v", open)
|
||||
}
|
||||
}
|
||||
@@ -117,10 +117,13 @@ 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 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.
|
||||
func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil }
|
||||
func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil && r.Take == nil }
|
||||
|
||||
// partKey is an ask's place among what is asked: its condition and its part.
|
||||
func partKey(condition, part string) string { return condition + "#" + part }
|
||||
@@ -192,6 +195,11 @@ 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 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.
|
||||
record func(ctx context.Context, act link.HandAct) error
|
||||
// routerRecord reads the router's record of an ask for a warrant missed; nil reads nothing.
|
||||
@@ -554,6 +562,10 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 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 folders waiting to be kept: %v", err)
|
||||
}
|
||||
why := "the router refused the ask"
|
||||
if len(refusedWords) > 0 {
|
||||
why += ": " + refusedWords[0]
|
||||
@@ -818,7 +830,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stillOpen := r.Rehearsal || r.Proposal != nil // a rehearsal and a proposal are about no condition
|
||||
stillOpen := r.Rehearsal || r.Proposal != nil || r.Take != nil // a rehearsal, a proposal and a take are about no condition
|
||||
for _, c := range open {
|
||||
stillOpen = stillOpen || c.Key == r.Condition
|
||||
}
|
||||
@@ -876,6 +888,15 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
if acted == nil && outcome != "" && !strings.HasPrefix(outcome, "nothing") {
|
||||
outcome = "done: " + outcome
|
||||
}
|
||||
case r.Take != nil && act.Verb == takeVerb:
|
||||
// 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 {
|
||||
outcome = "done: " + outcome
|
||||
}
|
||||
case r.Take != nil:
|
||||
acted = fmt.Errorf("the take %s offers no act %s", r.ID, act.Verb)
|
||||
case act.Arguments["silence"] != "":
|
||||
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
|
||||
default:
|
||||
|
||||
@@ -572,6 +572,27 @@ func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper
|
||||
call: callAction(conn),
|
||||
// A proposed settings layer is set by this controller itself on the warrant (novox/hq ADR 0277).
|
||||
setLayer: setLayerIn(open),
|
||||
// A shared folder waiting to be kept is asked about once, and taken on the warrant (novox/hq ADR 0325 §8).
|
||||
folders: foldersIn(open),
|
||||
take: takeFolderIn(open, func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
ident, err := open.Identity(ctx)
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, fmt.Errorf("the mesh's signing key cannot be read, so nothing was asked of "+
|
||||
"%s: %w", act.Node, err)
|
||||
}
|
||||
return link.AskTake(ctx, conn, ident, act, link.TakeWithin)
|
||||
}),
|
||||
machines: func(ctx context.Context) ([]string, error) {
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
names = append(names, n.Name)
|
||||
}
|
||||
return names, nil
|
||||
},
|
||||
record: func(ctx context.Context, act link.HandAct) error {
|
||||
_, err := link.RecordHandAct(ctx, conn, act)
|
||||
return err
|
||||
|
||||
@@ -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,6 +152,16 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// **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.IdentityWarnings(shelf[name]) {
|
||||
fmt.Fprintf(out, "warning: %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
// **A file that asks for a setting and does not say whether it is trusted counts as trusted** (novox/hq issue
|
||||
// 339): listed and counted, never refused, so an author can opt out a file nothing trusts.
|
||||
unsaid := 0
|
||||
|
||||
@@ -161,9 +161,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
return u, err == nil
|
||||
})
|
||||
fmt.Print(planText(plan))
|
||||
direct := link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit,
|
||||
ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched,
|
||||
Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan}
|
||||
direct := directVerdict(p, plan)
|
||||
switch {
|
||||
case !scope.gated() && !scope.Mesh:
|
||||
fmt.Printf("%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
|
||||
@@ -201,6 +199,24 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// directVerdict is the verdict of a change no build judges — it touches no module, and either the repository is not
|
||||
// the mesh's or the head holds no merge-check.sh. **It names the trunk commit it holds for** (novox/hq ADR 0323 §3):
|
||||
// the base branch's head the forge announced with the head, so the merge tool can weigh it against the trunk's head
|
||||
// and does not refuse it for ever (a verdict naming no trunk is never the trunk's head's). Nothing was built to
|
||||
// judge: the trunk commit is the one the forge said the change was announced against.
|
||||
func directVerdict(p link.PullUpdated, plan link.ChangePlan) link.Checked {
|
||||
trunk := p.BaseSHA
|
||||
if len(trunk) > 12 {
|
||||
trunk = trunk[:12]
|
||||
}
|
||||
if trunk != "" {
|
||||
plan.Trunk = trunk
|
||||
}
|
||||
return link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit,
|
||||
ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched,
|
||||
Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan, Trunk: trunk}
|
||||
}
|
||||
|
||||
// checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head,
|
||||
// and what is read beside it.
|
||||
func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scope checkScope,
|
||||
@@ -363,6 +379,9 @@ func checkedOf(result link.BuildResult) link.Checked {
|
||||
case result.Check != nil:
|
||||
c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report
|
||||
c.Gate, c.RepoCheck = result.Check.Gate, result.Check.RepoCheck
|
||||
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the
|
||||
// repositories read beside it.
|
||||
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
|
||||
if c.Gate == nil {
|
||||
// A build seat from before the layers: its verdict is the gate's.
|
||||
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
||||
@@ -388,14 +407,20 @@ func checkedOf(result link.BuildResult) link.Checked {
|
||||
}
|
||||
if result.Checked != nil {
|
||||
c.Plan = result.Checked.Plan
|
||||
if c.Plan != nil && c.Trunk != "" {
|
||||
// The kept delivery plan records the trunk commit with its other inputs (ADR 0323 §3).
|
||||
plan := *c.Plan
|
||||
plan.Trunk = c.Trunk
|
||||
c.Plan = &plan
|
||||
}
|
||||
// A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first.
|
||||
if g := result.Checked; g.Group != "" {
|
||||
c.Group = g.Group
|
||||
c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number,
|
||||
Commit: result.Ref})
|
||||
Commit: result.Ref, Trunk: c.Trunk})
|
||||
for _, m := range g.Members {
|
||||
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
|
||||
Commit: m.Ref})
|
||||
Commit: m.Ref, Trunk: memberTrunk(result.Check, m)})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,6 +435,20 @@ func checkedOf(result link.BuildResult) link.Checked {
|
||||
return c
|
||||
}
|
||||
|
||||
// memberTrunk is the trunk commit the build seat merged one of a group's heads into (novox/hq ADR 0323 §6), empty
|
||||
// when it said none.
|
||||
func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string {
|
||||
if outcome == nil {
|
||||
return ""
|
||||
}
|
||||
for _, j := range outcome.Members {
|
||||
if strings.EqualFold(j.Owner, m.Owner) && strings.EqualFold(j.Repo, m.Repo) && strings.HasPrefix(m.Ref, j.Commit) {
|
||||
return j.Trunk
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func prefixedAll(prefix string, items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, i := range items {
|
||||
@@ -424,8 +463,12 @@ func sayChecked(ctx context.Context, c link.Checked) {
|
||||
if c.RepoCheck != nil {
|
||||
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
|
||||
}
|
||||
fmt.Printf("%s: %s/%s#%d at %.8s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
||||
c.Commit, orSomewhere(c.On), strings.ToUpper(c.Verdict), c.Summary, repo)
|
||||
judged := ""
|
||||
if c.Trunk != "" {
|
||||
judged = " merged into trunk " + c.Trunk
|
||||
}
|
||||
fmt.Printf("%s: %s/%s#%d at %.8s%s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
||||
c.Commit, judged, orSomewhere(c.On), strings.ToUpper(c.Verdict), c.Summary, repo)
|
||||
if checkEvents == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -210,3 +210,81 @@ func TestABuildOffTheTrunkIsNeverPublished(t *testing.T) {
|
||||
t.Errorf("a build seat that said nothing was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A verdict names the trunk commit it judged** (novox/hq ADR 0323 §3, issue 341): the controller's `checked`
|
||||
// carries the head and the trunk commit the build seat merged it into, the commits of the repositories read beside
|
||||
// it, how long it took, and the kept delivery plan records the trunk commit with its other inputs. A group's
|
||||
// composed check names, per member, the trunk commit that head was merged into (§6).
|
||||
func TestAVerdictNamesTheTrunkCommitItJudged(t *testing.T) {
|
||||
plan := &link.ChangePlan{Repository: "novox/mesh-host", Base: "main", Head: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
Summary: "builds mesh-host"}
|
||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 66, Base: "main", Plan: plan}
|
||||
c := checkedOf(link.BuildResult{ID: "b", Ref: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Checked: asked,
|
||||
Check: &link.CheckOutcome{Verdict: "pass", Took: "4m12s", Head: "dfb1415aaaaa", Trunk: "d28ca95bbbbb",
|
||||
Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "pass"},
|
||||
Beside: map[string]string{"mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210"}}})
|
||||
if c.Trunk != "d28ca95bbbbb" || c.Commit != "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
|
||||
t.Fatalf("the verdict names head %s and trunk %q", c.Commit, c.Trunk)
|
||||
}
|
||||
if c.Beside["mesh-catalog"] != "0123456789ab" || c.Beside["mesh-lab"] != "ba9876543210" {
|
||||
t.Fatalf("the verdict names the repositories read beside it as %v", c.Beside)
|
||||
}
|
||||
if c.Took != "4m12s" {
|
||||
t.Fatalf("the verdict says it took %q", c.Took)
|
||||
}
|
||||
if c.Plan == nil || c.Plan.Trunk != "d28ca95bbbbb" || c.Plan.Head != plan.Head {
|
||||
t.Fatalf("the kept plan records %+v", c.Plan)
|
||||
}
|
||||
if plan.Trunk != "" {
|
||||
t.Fatal("the plan the controller asked with was changed: the verdict's is a copy")
|
||||
}
|
||||
|
||||
group := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 7, Base: "main", Group: "feat/x",
|
||||
Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-host", Number: 8, Ref: "cccccccccccc0000", Base: "main"}}}
|
||||
c = checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{
|
||||
Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"},
|
||||
Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "cccccccccccc", Trunk: "222222222222"}}}})
|
||||
if len(c.Members) != 2 || c.Members[0].Trunk != "111111111111" || c.Members[1].Trunk != "222222222222" ||
|
||||
c.Members[1].Commit != "cccccccccccc0000" {
|
||||
t.Fatalf("the composed check names its members %+v", c.Members)
|
||||
}
|
||||
}
|
||||
|
||||
// **The merge made for a check is never registered** (novox/hq ADR 0323 §2): a build of it — a commit on no branch
|
||||
// of the forge — is refused, naming the trunk. The rule of ADR 0238 §1 stands unchanged; this holds it for the
|
||||
// merge commit a check makes.
|
||||
func TestABuildOfACheckMergeIsRefusedNamingTheTrunk(t *testing.T) {
|
||||
merge := link.BuildResult{ID: "b", Commit: "4b5c6d7e8f90aabbccddeeff0011223344556677", Trunk: "main"}
|
||||
err := publishable(merge, "")
|
||||
if !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "not on main") {
|
||||
t.Fatalf("a build of a check's merge was answered %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A verdict no build judges names its trunk commit too** (novox/hq ADR 0323 §3): a change touching no module of a
|
||||
// repository with no merge-check.sh (novox/mesh-tools-go) is answered by the controller itself. Naming no trunk, the
|
||||
// merge tool refused it, asked again, got the same verdict, and refused for ever.
|
||||
func TestADirectVerdictNamesTheTrunkTheForgeAnnounced(t *testing.T) {
|
||||
p := link.PullUpdated{Owner: "novox", Repo: "mesh-tools-go", Number: 3, Base: "main", Commit: "1a2b3c4d5e6f7a8b9c0d",
|
||||
BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"}
|
||||
c := directVerdict(p, link.ChangePlan{Repository: "novox/mesh-tools-go", Base: "main", Head: p.Commit})
|
||||
if c.Trunk != "7a8b9c0d1e2f" || c.Plan == nil || c.Plan.Trunk != "7a8b9c0d1e2f" {
|
||||
t.Fatalf("the direct verdict names trunk %q, its plan %+v", c.Trunk, c.Plan)
|
||||
}
|
||||
if c.Verdict != "pass" || c.Commit != p.Commit {
|
||||
t.Fatalf("the direct verdict is %+v", c)
|
||||
}
|
||||
if c := directVerdict(link.PullUpdated{Owner: "novox", Repo: "x", Commit: "abc"}, link.ChangePlan{}); c.Trunk != "" {
|
||||
t.Fatalf("a forge that said no base head gave the trunk %q", c.Trunk)
|
||||
}
|
||||
}
|
||||
|
||||
// A head checked again through mesh-delivery (`delivery check --members`, a recheck) reaches the same direct verdict:
|
||||
// it names the base head the forge last announced, kept on the member, or the merge tool would refuse it for ever.
|
||||
func TestARecheckedHeadsDirectVerdictNamesItsTrunk(t *testing.T) {
|
||||
m := orderMember{ID: "novox/mesh-tools-go@1a2b3c4d5e6f", Repository: "novox/mesh-tools-go", Base: "main",
|
||||
Head: "1a2b3c4d5e6f7a8b9c0d", Number: 3, BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"}
|
||||
if c := directVerdict(m.pull(), link.ChangePlan{}); c.Trunk != "7a8b9c0d1e2f" {
|
||||
t.Fatalf("a rechecked head's direct verdict names trunk %q", c.Trunk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +580,9 @@ type orderMember struct {
|
||||
CloneURL string `json:"clone_url,omitempty"`
|
||||
// After are the repositories its pull request says it goes after (`after: <repository>`).
|
||||
After []string `json:"after,omitempty"`
|
||||
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
|
||||
// a verdict no build judges names when the head is checked again through mesh-delivery.
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
}
|
||||
|
||||
// pull is the member as the forge announced it.
|
||||
@@ -589,7 +592,7 @@ func (m orderMember) pull() link.PullUpdated {
|
||||
if base == "" {
|
||||
base = "main"
|
||||
}
|
||||
return link.PullUpdated{Owner: owner, Repo: repo, Number: m.Number, Base: base, Commit: m.Head,
|
||||
return link.PullUpdated{Owner: owner, Repo: repo, Number: m.Number, Base: base, Commit: m.Head, BaseSHA: m.BaseSHA,
|
||||
CloneURL: m.CloneURL, Paths: m.Paths, PathsTruncated: m.PathsTruncated, Removed: m.Removed,
|
||||
ModuleDirs: m.ModuleDirs, ModuleDirsSaid: m.ModuleDirsSaid}
|
||||
}
|
||||
@@ -1185,7 +1188,7 @@ func askGroupCheck(ctx context.Context, open *stores, group string, members []or
|
||||
return "", fmt.Errorf("%s cannot be cloned for the group's check: %w", m.ID, err)
|
||||
}
|
||||
request.Check.Members = append(request.Check.Members, link.GroupMember{Owner: mp.Owner, Repo: mp.Repo,
|
||||
Number: mp.Number, Repository: url, Ref: mp.Commit, Paths: mp.Paths})
|
||||
Number: mp.Number, Repository: url, Ref: mp.Commit, Paths: mp.Paths, Base: mp.Base})
|
||||
}
|
||||
seat := buildSeatHeld(ctx)
|
||||
ask, err := askOverOn(seat)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,10 @@ type gateFacts struct {
|
||||
// groupsAdded is, per module, whether the move judged puts an account in a group its previous build did
|
||||
// not (issue 318 review): the only move whose wait for a new login is excused.
|
||||
groupsAdded map[string]bool
|
||||
// accessesChanged is, per module, whether the move judged changes its accesses (novox/hq ADR 0325): an access the
|
||||
// engine cannot judge this look (unknown), or entries it left while a disk was away, of a module whose move leaves
|
||||
// its accesses as they were is no fault of the move. A module absent here is taken to change them: judged as before.
|
||||
accessesChanged map[string]bool
|
||||
// waits is what the controller holds to check a module's wait for the operator (novox/hq ADR 0283): the
|
||||
// manifest of each module's build judged, and the secrets given on each machine.
|
||||
waits operatorWaitFacts
|
||||
@@ -528,6 +532,7 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
|
||||
return "", err
|
||||
}
|
||||
facts.groupsAdded = movesAddingGroups(ctx, open.inventory, g, pairs, shelf)
|
||||
facts.accessesChanged = movesChangingAccesses(ctx, open.inventory, g, pairs, shelf)
|
||||
facts.waits = gateWaitFacts(ctx, open.inventory, g, pairs, shelf, facts.health)
|
||||
facts.sent = g.Sent
|
||||
facts.commits, facts.sentBuilds = judgedCommits(g, pairs), map[string]map[string]string{}
|
||||
@@ -783,6 +788,50 @@ func joinSaid(a, b string) string {
|
||||
return a + "; " + b
|
||||
}
|
||||
|
||||
// movesChangingAccesses is, per module a gate judges, whether its move changes its accesses — one added, dropped,
|
||||
// or given another use — read from the builds' manifests (novox/hq ADR 0325). A move not known changes them: an
|
||||
// access it cannot judge then holds the gate to its bound, and never passes unseen.
|
||||
func movesChangingAccesses(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged,
|
||||
shelf map[string]catalogue.Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, j := range pairs {
|
||||
if _, done := out[j.module]; done {
|
||||
continue
|
||||
}
|
||||
from, to := g.From, g.To
|
||||
for _, c := range g.Carried {
|
||||
if c.Module == j.module {
|
||||
from, to = c.From, c.To
|
||||
break
|
||||
}
|
||||
}
|
||||
target, found, err := inv.ManifestAt(ctx, j.module, to)
|
||||
if err != nil || !found {
|
||||
target = shelf[j.module]
|
||||
}
|
||||
before, had, err := inv.ManifestAt(ctx, j.module, from)
|
||||
if from == "" || err != nil || !had {
|
||||
out[j.module] = true
|
||||
continue
|
||||
}
|
||||
out[j.module] = !sameAccesses(before.Accesses, target.Accesses)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameAccesses says two manifests declare the same accesses, at the same use, in any order.
|
||||
func sameAccesses(a, b []catalogue.Access) bool {
|
||||
key := func(as []catalogue.Access) string {
|
||||
var out []string
|
||||
for _, x := range as {
|
||||
out = append(out, x.ID+"\x00"+x.Path+"\x00"+x.At())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, "\x01")
|
||||
}
|
||||
return key(a) == key(b)
|
||||
}
|
||||
|
||||
// movesAddingGroups is, per module a gate judges, whether its move puts an account in a group the build it
|
||||
// moved from did not: read from the builds' manifests. A module whose move is not known adds none.
|
||||
func movesAddingGroups(ctx context.Context, inv *inventory.Inventory, g *inventory.PlanGate, pairs []judged,
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
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")
|
||||
}
|
||||
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,496 @@
|
||||
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), stated before the send, do not hold a delivery
|
||||
// that did not bring them; stated since, they do — and they may name an access no longer declared.
|
||||
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},
|
||||
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 != healthNotYet {
|
||||
t.Fatalf("entries left by this send 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)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,16 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
|
||||
for _, w := range r.Waits {
|
||||
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What})
|
||||
}
|
||||
// An access's waits (novox/hq ADR 0325 §7), kept beside the others as one more thing the operator gives: the
|
||||
// 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, 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 wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose
|
||||
@@ -644,7 +654,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
if waits && !f.groupsAdded[module] {
|
||||
waits = false
|
||||
}
|
||||
var found, onWaiting []string
|
||||
var found, onWaiting, leftover []string
|
||||
var forOperator []inventory.Wait
|
||||
for _, r := range resources {
|
||||
if r.Module != module {
|
||||
@@ -667,6 +677,23 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
found = append(found, r.Resource)
|
||||
continue
|
||||
}
|
||||
// **The mesh's entries left on a folder whose disk was away are a condition this build found** when its move
|
||||
// leaves the module's accesses as they were, or they were stated before this send (novox/hq ADR 0325 §8): they
|
||||
// may name an access no longer declared, they wait for the disk, and a delivery that did not bring them is not
|
||||
// held for them — said in the verdict, as a directory used as found is. Otherwise they are judged unhealthy.
|
||||
changed, known := f.accessesChanged[module]
|
||||
changes := !known || changed
|
||||
if r.Kind == link.KindKeptEntries && r.State == link.StateUnhealthy && (!changes || r.Since.Before(since)) {
|
||||
leftover = append(leftover, r.Resource)
|
||||
continue
|
||||
}
|
||||
// **An access the engine could not judge this look** (its filesystem could not be named — a hung mount) carries
|
||||
// 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.
|
||||
if r.Kind == link.KindAccess && r.State == link.StateUnknown && !changes {
|
||||
continue
|
||||
}
|
||||
switch r.State {
|
||||
case link.StateHealthy:
|
||||
case link.StateStarting:
|
||||
@@ -690,7 +717,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
reasonAfter(r.Reason))
|
||||
}
|
||||
}
|
||||
if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 {
|
||||
if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 || len(leftover) > 0 {
|
||||
said := onWaiting
|
||||
if waits {
|
||||
said = append(said, wait)
|
||||
@@ -702,6 +729,10 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+
|
||||
"(`nox node hand-over %s <directory>` on the control-node)", machine, module, strings.Join(found, ", "), machine))
|
||||
}
|
||||
if len(leftover) > 0 {
|
||||
said = append(said, fmt.Sprintf("on %s, the mesh's entries %s could not be taken out while their disk was "+
|
||||
"away, stated before this send: they are taken out when it is back", machine, strings.Join(leftover, ", ")))
|
||||
}
|
||||
return healthPerson, strings.Join(said, "; ")
|
||||
}
|
||||
return healthGood, ""
|
||||
@@ -742,6 +773,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,6 +44,12 @@ func needsOperatorKey(module, node string) string {
|
||||
type operatorWaitFacts struct {
|
||||
manifests map[string]catalogue.Manifest
|
||||
given map[string]map[string]time.Time
|
||||
// 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.
|
||||
@@ -52,9 +58,17 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
||||
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)
|
||||
}
|
||||
named := 0
|
||||
for _, n := range []string{w.Secret, w.Setting, w.Access} {
|
||||
if n != "" {
|
||||
named++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "":
|
||||
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w))
|
||||
case named != 1:
|
||||
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting, or one access", waitNames(w))
|
||||
case w.Access != "":
|
||||
return checkAccessWait(module, machine, m, w, f)
|
||||
case w.Setting != "":
|
||||
if _, declared := m.Settings[w.Setting]; !declared {
|
||||
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
|
||||
@@ -80,6 +94,49 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
declared := false
|
||||
for _, a := range m.Accesses {
|
||||
declared = declared || a.ID == w.Access
|
||||
}
|
||||
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)
|
||||
}
|
||||
switch {
|
||||
case w.Reason == link.WaitDisk || (w.Reason == "" && w.Disk):
|
||||
return nil
|
||||
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, 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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"
|
||||
|
||||
@@ -88,6 +145,10 @@ func waitNames(w inventory.Wait) string {
|
||||
switch {
|
||||
case w.Secret != "" && w.Setting != "":
|
||||
return "the secret " + w.Secret + " and the setting " + w.Setting
|
||||
case w.Access != "" && (w.Secret != "" || w.Setting != ""):
|
||||
return "the access " + w.Access + " and a secret or a setting"
|
||||
case w.Access != "":
|
||||
return "the access " + w.Access
|
||||
case w.Secret != "":
|
||||
return "the secret " + w.Secret
|
||||
case w.Setting != "":
|
||||
@@ -147,6 +208,14 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string {
|
||||
var parts []string
|
||||
for _, w := range waits {
|
||||
part := fmt.Sprintf("%s (%s", w.What, waitNames(w))
|
||||
// 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", w.Access, waitNames(w))
|
||||
case 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 != "" {
|
||||
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret)
|
||||
}
|
||||
@@ -180,6 +249,11 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
|
||||
act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true
|
||||
case w.Setting != "":
|
||||
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", w.Access, node)
|
||||
case 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
|
||||
@@ -217,6 +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
|
||||
}
|
||||
@@ -238,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
|
||||
}
|
||||
@@ -245,6 +339,15 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
||||
f.given[module+"@"+machine] = given
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitingModules is every module with a waiting resource in a statement.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228). Seen on
|
||||
// 2026-10-10: the send made a random value for the mounts module's smb-credentials and placed it, and the module read
|
||||
// it as a credential given and wrong. The send now makes none, places none, and places a value only once a person
|
||||
// gave one — and a value the mesh made for it before is not placed either.
|
||||
|
||||
// anAssistant is a module with a secret the mesh makes and one only an outside party issues.
|
||||
func anAssistant() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "assistant", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{
|
||||
"password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart},
|
||||
"vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
|
||||
},
|
||||
Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"},
|
||||
}}
|
||||
}
|
||||
|
||||
// sentTo composes what a push sends to a machine, on the send path.
|
||||
func sentTo(t *testing.T, open *stores, node string) []map[string]any {
|
||||
t.Helper()
|
||||
plan, settings, err := planFor(t.Context(), open, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared, err := declarationFor(t.Context(), open, node, plan, settings)
|
||||
if err != nil {
|
||||
t.Fatalf("the send to %s did not compose: %v", node, err)
|
||||
}
|
||||
return declared.Resources
|
||||
}
|
||||
|
||||
func placedAt(resources []map[string]any, path string) map[string]any {
|
||||
for _, r := range resources {
|
||||
if r["path"] == path && r["sealed"] != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTheSendMakesNoValueForAnOutsideSecretNobodyGave(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, anAssistant())
|
||||
if _, err := assign(ctx, open, "laptop", "assistant"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sent := sentTo(t, open, "laptop")
|
||||
if placedAt(sent, "/var/lib/mesh/assistant/password") == nil {
|
||||
t.Fatal("the secret the mesh makes was not placed")
|
||||
}
|
||||
if got := placedAt(sent, "/var/lib/mesh/assistant/vendor-key"); got != nil {
|
||||
t.Fatalf("a secret nobody gave was placed: %v", got)
|
||||
}
|
||||
if _, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "assistant", "vendor-key"); 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 before this rule is not one a person gave: it is not placed.
|
||||
if _, err := open.inventory.SecretForModule(ctx, "laptop", "assistant", "vendor-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := placedAt(sentTo(t, open, "laptop"), "/var/lib/mesh/assistant/vendor-key"); got != nil {
|
||||
t.Fatalf("a value the mesh made for a secret issued outside was placed: %v", got)
|
||||
}
|
||||
|
||||
// Given, it is placed.
|
||||
if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "assistant", "vendor-key", "the vendor's key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given, ok, err := open.inventory.GivenOwnSecret(ctx, "laptop", "assistant", "vendor-key")
|
||||
if err != nil || !ok || !given.Current {
|
||||
t.Fatalf("the value given is not read as given: %+v %v %v", given, ok, err)
|
||||
}
|
||||
placed := placedAt(sentTo(t, open, "laptop"), "/var/lib/mesh/assistant/vendor-key")
|
||||
if placed == nil || placed["sealed"] != given.Sealed {
|
||||
t.Fatalf("the value given is placed as %v", placed)
|
||||
}
|
||||
}
|
||||
|
||||
// The read paths answer as the send does: a question about the machine composes without the secret nobody gave, and
|
||||
// asking writes nothing.
|
||||
func TestAQuestionComposesWithoutAnOutsideSecretNobodyGave(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, anAssistant())
|
||||
if _, err := assign(ctx, open, "laptop", "assistant"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pushedBy(t, open, "laptop") // the mesh's own secret is made by the send
|
||||
plan, settings, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, choosing := range []Choosing{Reading, Foreseeing} {
|
||||
got, err := declarationWith(ctx, open, "laptop", plan, settings, gens, choosing)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", choosing, err)
|
||||
}
|
||||
if placedAt(got.Resources, "/var/lib/mesh/assistant/vendor-key") != nil || len(got.foreseen) != 0 {
|
||||
t.Fatalf("%v: a secret nobody gave is composed (foreseen %v)", choosing, got.foreseen)
|
||||
}
|
||||
}
|
||||
if _, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "assistant", "vendor-key"); err != nil || held {
|
||||
t.Fatalf("a question made a value (held %v, %v)", held, err)
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,13 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
"It usually catches up by itself.", m, m),
|
||||
Resolved: m + " applied the change"}
|
||||
}),
|
||||
kindApplyFailing: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: m + " is not applying its update",
|
||||
Explanation: fmt.Sprintf("%s has refused or only partly applied what the mesh sent it, again and again. "+
|
||||
"The details say what fails.", conditions.Capital(m)),
|
||||
Resolved: m + " applies what it is sent again"}
|
||||
}),
|
||||
"tools-silent": worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: "Tools on " + m + " are not answering",
|
||||
|
||||
+108
-6
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -447,7 +448,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
}
|
||||
out := sendable{Resources: composed.Resources, Adoption: adoption,
|
||||
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,
|
||||
@@ -590,6 +591,13 @@ func busCredentialIssued(ctx context.Context, inv *inventory.Inventory, node, mo
|
||||
return nil
|
||||
}
|
||||
|
||||
// givenUnderAnOldKey is the refusal for a value a person gave — a family's member, or an own secret issued outside
|
||||
// the mesh — sealed to a key the machine no longer holds: the mesh cannot make another, so it is given again.
|
||||
func givenUnderAnOldKey(module, node, name string) error {
|
||||
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
||||
"since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node)
|
||||
}
|
||||
|
||||
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
||||
func renderingFor(ctx context.Context, open *stores, node string,
|
||||
plan catalogue.Resolution, settings catalogue.SettingsBy,
|
||||
@@ -708,10 +716,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
}
|
||||
if !g.Current {
|
||||
if choosing == Allocating {
|
||||
return catalogue.Rendering{}, inventory.Node{}, fmt.Errorf(
|
||||
"%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
||||
"since generated a new sealing key. The mesh cannot make another; give it again",
|
||||
m.Module, node, g.Name, node)
|
||||
return catalogue.Rendering{}, inventory.Node{}, givenUnderAnOldKey(m.Module, node, g.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -721,7 +726,31 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
needed[m.Module][g.Name] = g.Sealed
|
||||
}
|
||||
}
|
||||
for name := range m.OwnSecrets.Plain() {
|
||||
for name, own := range m.OwnSecrets.Plain() {
|
||||
// **An own secret issued outside the mesh is never made** (novox/hq issue 401, ADR 0228; ADR 0283 for
|
||||
// a family): only a value a person gave is placed. One not given is nothing — no file, and an empty
|
||||
// value where a file of the module names it — so the module can tell it was not given, where a value
|
||||
// the mesh made looked given and wrong. A value the mesh made for it before is not one either.
|
||||
if own.IssuedBy == catalogue.IssuedOutside {
|
||||
g, given, err := inv.GivenOwnSecret(ctx, node, m.Module, name)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
if !given {
|
||||
continue
|
||||
}
|
||||
if !g.Current {
|
||||
if choosing == Allocating {
|
||||
return catalogue.Rendering{}, inventory.Node{}, givenUnderAnOldKey(m.Module, node, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if needed[m.Module] == nil {
|
||||
needed[m.Module] = map[string]string{}
|
||||
}
|
||||
needed[m.Module][name] = g.Sealed
|
||||
continue
|
||||
}
|
||||
// Minted on the send path and only read on every other. Making one is an insert, and
|
||||
// a question that writes is a question that can block against the machine it is about.
|
||||
var sealed string
|
||||
@@ -947,9 +976,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
// Each module's identity and the folders taken here (novox/hq ADR 0325 §3, §8), to an engine that keeps folders.
|
||||
keepsFolders, identities, keptFolders, err := folderFacts(ctx, inv, node, plan, choosing)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
KeepsFolders: keepsFolders,
|
||||
Identities: identities,
|
||||
KeptFolders: keptFolders,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
@@ -982,6 +1019,71 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
|
||||
return had && stated.Contract >= link.RootContract, nil
|
||||
}
|
||||
|
||||
// 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.IdentityContract, nil
|
||||
}
|
||||
|
||||
// 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, error) {
|
||||
keeps, err := engineKeepsFolders(ctx, inv, node)
|
||||
if err != nil || !keeps {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
identities := map[string]catalogue.ModuleIdentity{}
|
||||
for _, m := range plan.Modules {
|
||||
if !catalogue.DeclaresIdentity(m) {
|
||||
continue
|
||||
}
|
||||
var id catalogue.ModuleIdentity
|
||||
had := true
|
||||
if choosing == Allocating {
|
||||
id, err = inv.AllocateIdentity(ctx, m.Module)
|
||||
} else {
|
||||
id, had, err = inv.IdentityOf(ctx, m.Module)
|
||||
}
|
||||
if err != nil {
|
||||
return false, nil, nil, err
|
||||
}
|
||||
if had {
|
||||
identities[m.Module] = id
|
||||
}
|
||||
}
|
||||
taken, err := inv.KeptFolders(ctx, node)
|
||||
if err != nil {
|
||||
return false, nil, 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.
|
||||
if stated, had, err := inv.HealthOf(ctx, node); err != nil {
|
||||
return false, nil, nil, err
|
||||
} else if had {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, identities, kept, nil
|
||||
}
|
||||
|
||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||
// answering listen is published on there.
|
||||
|
||||
@@ -18,6 +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
|
||||
// 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.
|
||||
@@ -110,6 +113,9 @@ func (s sendable) Body() ([]byte, error) {
|
||||
if s.Generation > 0 {
|
||||
envelope["generation"] = s.Generation
|
||||
}
|
||||
if len(s.Identities) > 0 {
|
||||
envelope["identities"] = s.Identities
|
||||
}
|
||||
// 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".
|
||||
|
||||
@@ -254,6 +254,16 @@ var signalsTable = []signalRow{
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.bus.waits, func(w busWaitFacts) time.Time { return w.since })
|
||||
}},
|
||||
{Row: "S21", Signal: "a clean apply", Emitter: "node-engine", Trigger: "each report of a declaration refused or applied only in part (novox/hq issue 233)",
|
||||
Bound: "one apply interval (5 min) past the first report of the same failure, judged by time while the machine " +
|
||||
"is heard — the node-engine says a re-apply only when how it went changed; not while it is silent or " +
|
||||
"asleep; naming the refusal, or the package manager that does not answer with its words; cleared by the " +
|
||||
"clean apply it then says",
|
||||
Kind: kindApplyFailing, Severity: conditions.Warning, Phase: 2,
|
||||
needs: func(f *signalFacts) error { return f.doingErr }, watch: watchApplyFailing,
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.doing, func(d inventory.Doing) time.Time { return d.At })
|
||||
}},
|
||||
}
|
||||
|
||||
// watchFacts is S14: the snapshot a merge check is fed is older than its bound, or none was kept since
|
||||
|
||||
@@ -186,6 +186,14 @@ var suppressions = map[string]suppression{
|
||||
},
|
||||
past: func(f *signalFacts) { f.refusedSends = []inventory.Send{refusedSend(f.now.Add(-time.Second))} },
|
||||
},
|
||||
// A machine whose package manager does not answer (novox/hq issue 233): said once its first report of the
|
||||
// failure is older than one apply interval while it is heard. Inside: exactly five minutes old.
|
||||
"S21": {
|
||||
inside: func(f *signalFacts) { f.doing = []inventory.Doing{unansweringPackageManager(f.now, 5*time.Minute, 1)} },
|
||||
past: func(f *signalFacts) {
|
||||
f.doing = []inventory.Doing{unansweringPackageManager(f.now, 5*time.Minute+time.Second, 1)}
|
||||
},
|
||||
},
|
||||
// Twice by hand within a fortnight is a healer wanted; once, or the first of two a day too old, is not.
|
||||
"S15": {
|
||||
inside: func(f *signalFacts) {
|
||||
|
||||
@@ -344,3 +344,58 @@ func TestAnyKeyOfAModuleWithATrustedMergeableFileIsRefusedThroughAVerb(t *testin
|
||||
t.Fatalf("a file marked untrusted through the verb: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Who owns a module's directory is said at the terminal alone (novox/hq issue 279): a directory's owner may name
|
||||
// the settings the module runs as, root chowns the folder to them at the next push, and through the settings verb
|
||||
// any caller could have set puid to 0 or to its own uid. The key is refused through every verb route even where a
|
||||
// file nothing trusts also asks for it, and taken at the terminal; the layer a refused call meets is unchanged.
|
||||
func TestADirectoryOwnersKeyIsRefusedThroughAVerb(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "sonarr", Version: "1",
|
||||
Resources: []map[string]any{
|
||||
{"id": "recycle-bin", "type": "directory", "mode": "0755", "owner": "${setting:puid}:${setting:pgid}"},
|
||||
{"id": "identity", "type": "file", "path": "/etc/sonarr.env", "mode": "0644", "trusted": false,
|
||||
"content": "PUID=${setting:puid}\nPGID=${setting:pgid}\nX=${setting:x}\n"}}})
|
||||
if err := atTheTerminal(t, "settings", "set", "sonarr", `{"puid":1001,"pgid":2000,"x":0}`,
|
||||
"--node", "anchor"); err != nil {
|
||||
t.Fatalf("the owner at the terminal: %v", err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "sonarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer := func() string {
|
||||
t.Helper()
|
||||
values, _, err := open.inventory.Layer(ctx, "anchor", "sonarr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
return string(raw)
|
||||
}
|
||||
kept := layer()
|
||||
refused := func(what string, err error) {
|
||||
t.Helper()
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal") {
|
||||
t.Fatalf("%s: %v", what, err)
|
||||
}
|
||||
}
|
||||
refused("puid 0 through the verb", throughVerb(t, "settings", map[string]any{"module": "sonarr",
|
||||
"node": "anchor", "values": `{"puid":0,"pgid":2000,"x":0}`}))
|
||||
refused("pgid through the verb, mesh-wide", throughVerb(t, "settings", map[string]any{"module": "sonarr",
|
||||
"values": `{"pgid":0}`}))
|
||||
refused("cleared through the verb", throughVerb(t, "settings", map[string]any{"module": "sonarr",
|
||||
"node": "anchor", "clear": "true"}))
|
||||
if err := throughVerb(t, "command", map[string]any{
|
||||
"command": `settings set sonarr '{"puid":0,"pgid":2000,"x":0}' --node anchor`}); err == nil {
|
||||
t.Fatal("the command verb set the owner")
|
||||
}
|
||||
if got := layer(); got != kept {
|
||||
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
|
||||
}
|
||||
// Another key, the owner kept, is still the verb's.
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "sonarr", "node": "anchor",
|
||||
"values": `{"puid":1001,"pgid":2000,"x":1}`}); err != nil {
|
||||
t.Fatalf("another key through the verb, the owner kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ type signalFacts struct {
|
||||
// from, each naming its sender (S20, novox/hq issue 234).
|
||||
refusedSends []inventory.Send
|
||||
refusedSendsErr error
|
||||
// doing is every machine whose last report was not a clean apply, with since when it has said the same
|
||||
// failure (S21, novox/hq issue 233).
|
||||
doing []inventory.Doing
|
||||
doingErr error
|
||||
|
||||
loop loopFacts
|
||||
loopErr error
|
||||
@@ -369,6 +373,7 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
|
||||
}
|
||||
f.handActs, f.handActsErr = w.gatherHandActs(ctx, now)
|
||||
f.refusedSends, f.refusedSendsErr = inv.RefusedSendsSince(ctx, now.Add(-generationRefusalsSaid))
|
||||
f.doing, f.doingErr = inv.NotDoingWhatTheyWereTold(ctx)
|
||||
f.facts.taken, _, f.facts.began, f.facts.err = exportedFacts.last()
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -35,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-20261011092230-20d5af9b2d5f
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175109-5c8e768ef520
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012175109-5c8e768ef520 h1:e6rbPYADZmKr7StQOrBozzVoI+Qu47Q22yRFDXGU5cU=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012175109-5c8e768ef520/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=
|
||||
|
||||
@@ -343,6 +343,11 @@ func AskJudgeDirectoriesSubject(node string) string {
|
||||
return "mesh.node." + node + ".ask.judge-directories"
|
||||
}
|
||||
|
||||
// AskAccessActSubject is where the controller asks one machine's node-engine to take a shared folder on the
|
||||
// operator's warrant (novox/hq ADR 0325 §8; mesh-host internal/link AskAccessActSubject): a request answered once,
|
||||
// signed as a hand-over is (link.AccessActContext), for the same reason.
|
||||
func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" }
|
||||
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
@@ -623,7 +628,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
|
||||
AskSetuidSearchSubject(p.Node),
|
||||
// And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way.
|
||||
AskJudgeDirectoriesSubject(p.Node)}
|
||||
AskJudgeDirectoriesSubject(p.Node),
|
||||
// And asking it to take a shared folder on the operator's warrant (novox/hq ADR 0325 §8), answered the same way.
|
||||
AskAccessActSubject(p.Node)}
|
||||
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
|
||||
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
|
||||
// machine runs the controller, reads the lease's one key — read, never written.
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ accounts {
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.access-act", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
|
||||
|
||||
@@ -101,6 +101,11 @@ var WritersTable = []WriterRow{
|
||||
{State: "a judgement of a machine's directories asked of it", Writer: "controller",
|
||||
KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController},
|
||||
// The take of a shared folder asked of a machine's engine on the operator's warrant (novox/hq ADR 0325 §8):
|
||||
// signed as a hand-over is, under a signing context of its own.
|
||||
{State: "the take of a shared folder asked of a machine", Writer: "controller, on the operator's warrant",
|
||||
KeptIn: "the machine, which records the act", Others: "the engine verifies the mesh's signature and takes it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.access-act"}, Writes: isController},
|
||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
||||
|
||||
@@ -18,6 +18,7 @@ var designRows = []string{
|
||||
"a hand-over asked of a machine",
|
||||
"a fresh setuid search asked of a machine",
|
||||
"a judgement of a machine's directories asked of it",
|
||||
"the take of a shared folder asked of a machine",
|
||||
"a machine's applied state and its report",
|
||||
"the controller lease",
|
||||
"plans and their tiers",
|
||||
|
||||
@@ -49,8 +49,10 @@ import (
|
||||
//
|
||||
// One check:
|
||||
//
|
||||
// 1. clones the repository at the pull request's head, and beside it the repositories its check
|
||||
// reads — the controller the mesh runs, the catalogue, the host, the lab — each at the ref asked;
|
||||
// 1. clones the repository at the pull request's head and **makes the merge of that head into the trunk's
|
||||
// head**, the check's trunk commit, in its own clone (novox/hq ADR 0323, issue 341; check_merge.go): what
|
||||
// is judged is what merging makes, never pushed. Beside it the repositories its check reads — the
|
||||
// controller the mesh runs, the catalogue, the host, the lab — each at the ref asked;
|
||||
// 2. reads the facts snapshot the controller keeps, and with it **the versions the mesh runs**: the
|
||||
// store a check's tests stand on is the store's own release, and the bus the bus's;
|
||||
// 3. raises a throwaway PostgreSQL and a throwaway bus of those versions, labelled with the ask so a
|
||||
@@ -79,7 +81,8 @@ type CheckSpec struct {
|
||||
Modules []string
|
||||
New []string
|
||||
Manifests []string
|
||||
// Base is the branch the pull request merges into: what the gate compares the change against.
|
||||
// Base is the branch the pull request merges into: what the gate compares the change against, and the trunk
|
||||
// whose head the change is merged into before it is judged (novox/hq ADR 0323). Empty judges the head alone.
|
||||
Base string
|
||||
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
|
||||
// the running one with the change's validator.
|
||||
@@ -104,6 +107,9 @@ type GroupHead struct {
|
||||
Repository string
|
||||
Ref string
|
||||
Paths []string
|
||||
// Base is the branch it merges into: the head is merged into that trunk's head before it is composed
|
||||
// (novox/hq ADR 0323 §6). Empty composes the head alone, as a controller from before it asks.
|
||||
Base string
|
||||
}
|
||||
|
||||
// Gated is whether the change touches the mesh's graph, and so whether the gate runs.
|
||||
@@ -139,6 +145,16 @@ type CheckVerdict struct {
|
||||
Took time.Duration
|
||||
Gate *Layer
|
||||
Repo *Layer
|
||||
// Head and Trunk are what was judged (novox/hq ADR 0323 §3): the pull request's head merged into the trunk
|
||||
// commit, each as twelve characters. Trunk is empty for a check asked with no trunk, which judged the head
|
||||
// alone. Merge is the merge commit made for the check, whole: only checked, never pushed or registered.
|
||||
Head, Trunk, Merge string
|
||||
// Beside are the commits of the repositories the check read beside its own, by the directory each was
|
||||
// cloned under, as twelve characters: named, not judged against (ADR 0323 §3, issue 568).
|
||||
Beside map[string]string
|
||||
// Members are a delivery group's other heads as the composed check judged them, each with the trunk
|
||||
// commit it was merged into (ADR 0323 §6).
|
||||
Members []JudgedHead
|
||||
}
|
||||
|
||||
// Layer is one layer of a check, judged.
|
||||
@@ -281,6 +297,34 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
tree := filepath.Join(root, name)
|
||||
// **What merging makes is judged, not the head** (novox/hq ADR 0323, issue 341): the head merged into the
|
||||
// trunk's head as this clone found it, in this clone alone. A head that does not merge cleanly fails here.
|
||||
judged := CheckVerdict{Head: twelve(spec.Ref)}
|
||||
paths := spec.Paths
|
||||
if spec.Base != "" {
|
||||
m, err := mergeIntoTrunk(ctx, run, tree, spec.Base, spec.Ref)
|
||||
if err != nil {
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
judged.Head, judged.Trunk = twelve(m.Head), twelve(m.Trunk)
|
||||
if len(m.Conflicts) > 0 {
|
||||
said := conflictWords(m)
|
||||
say("check", "%s", said)
|
||||
return CheckVerdict{Verdict: "fail", Summary: said, Report: said, Took: time.Since(began),
|
||||
Gate: &Layer{Verdict: "fail", Summary: said}, Repo: &Layer{Verdict: "fail", Summary: said},
|
||||
Head: judged.Head, Trunk: judged.Trunk}, nil
|
||||
}
|
||||
judged.Merge = m.Merge
|
||||
if changed := changedByMerge(ctx, run, tree, m); len(changed) > 0 {
|
||||
paths = changed
|
||||
}
|
||||
say("check", "head %s merged into trunk %s (%s): the merge is judged, and never pushed", judged.Head,
|
||||
judged.Trunk, spec.Base)
|
||||
}
|
||||
named := func(v CheckVerdict) CheckVerdict {
|
||||
v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members
|
||||
return v
|
||||
}
|
||||
// **The base branch's check judges the change** (novox/hq issue 310), never the one the change brings.
|
||||
judging, err := TheCheckThatJudges(ctx, tree, spec.Base)
|
||||
if err != nil {
|
||||
@@ -297,7 +341,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
v := CheckVerdict{Verdict: "pass", Summary: noModule, Took: time.Since(began),
|
||||
Gate: &Layer{Verdict: "pass", Summary: noModule}, Repo: &Layer{Verdict: "warning", Summary: noScript}}
|
||||
say("check", "%s; %s", noModule, noScript)
|
||||
return v, nil
|
||||
return named(v), nil
|
||||
}
|
||||
for dir, b := range spec.Beside {
|
||||
if dir == name || !safeName.MatchString(dir) {
|
||||
@@ -306,6 +350,13 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
if err := clone(b.Repository, b.Ref, dir); err != nil {
|
||||
return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
|
||||
}
|
||||
// The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question.
|
||||
if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" {
|
||||
if judged.Beside == nil {
|
||||
judged.Beside = map[string]string{}
|
||||
}
|
||||
judged.Beside[dir] = at
|
||||
}
|
||||
say("check", "beside it %s at %s", dir, short(b.Ref))
|
||||
}
|
||||
|
||||
@@ -322,6 +373,26 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
return CheckVerdict{}, fmt.Errorf("a head of the group, %w", err)
|
||||
}
|
||||
say("check", "with it, of its group, %s/%s at %s", g.Owner, g.Repo, short(g.Ref))
|
||||
// Each member is composed as its merge into its own trunk's head (ADR 0323 §6).
|
||||
member := JudgedHead{Owner: g.Owner, Repo: g.Repo, Head: twelve(g.Ref)}
|
||||
if g.Base != "" {
|
||||
m, err := mergeIntoTrunk(ctx, run, filepath.Join(root, dir), g.Base, g.Ref)
|
||||
if err != nil {
|
||||
return CheckVerdict{}, fmt.Errorf("a head of the group, %s/%s: %w", g.Owner, g.Repo, err)
|
||||
}
|
||||
member.Head, member.Trunk = twelve(m.Head), twelve(m.Trunk)
|
||||
if len(m.Conflicts) > 0 {
|
||||
said := fmt.Sprintf("%s/%s: %s", g.Owner, g.Repo, conflictWords(m))
|
||||
say("check", "%s", said)
|
||||
judged.Members = append(judged.Members, member)
|
||||
return named(CheckVerdict{Verdict: "fail", Summary: said, Report: said, Took: time.Since(began),
|
||||
Gate: &Layer{Verdict: "fail", Summary: said}, Repo: &Layer{Verdict: "fail", Summary: said}}), nil
|
||||
}
|
||||
if changed := changedByMerge(ctx, run, filepath.Join(root, dir), m); len(changed) > 0 {
|
||||
g.Paths = changed
|
||||
}
|
||||
}
|
||||
judged.Members = append(judged.Members, member)
|
||||
heads = append(heads, map[string]any{"repository": g.Owner + "/" + g.Repo,
|
||||
"tree": filepath.Join(root, dir), "changed": g.Paths})
|
||||
}
|
||||
@@ -433,7 +504,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
verdictFile := filepath.Join(root, "verdict.json")
|
||||
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
|
||||
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
||||
EnvChanged + "=" + strings.Join(spec.Paths, ","), EnvBeside + "=" + root,
|
||||
EnvChanged + "=" + strings.Join(paths, ","), EnvBeside + "=" + root,
|
||||
EnvModules + "=" + strings.Join(append(append([]string{}, spec.Modules...), spec.New...), ","),
|
||||
EnvGroup + "=" + groupFile,
|
||||
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")}
|
||||
@@ -501,7 +572,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
v.Report, v.Took = redact.redact(withWhatFailed(out.String(), v.Repo)), time.Since(began)
|
||||
say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary,
|
||||
strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second))
|
||||
return v, nil
|
||||
return named(v), nil
|
||||
}
|
||||
|
||||
// ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the
|
||||
@@ -868,14 +939,16 @@ func replaceGoFiles(from, into string) error {
|
||||
}
|
||||
|
||||
// problemLines are the lines of a module check's output that name a problem: not a manifest found ok, not
|
||||
// the count, not the closing note.
|
||||
// the count, not the closing note, not a warning.
|
||||
func problemLines(s string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "", strings.Contains(line, ": ok"), strings.Contains(line, "problem(s) in"),
|
||||
strings.Contains(line, "manifest(s) checked"):
|
||||
strings.Contains(line, "manifest(s) checked"),
|
||||
// A warning is said and fails nothing (novox/hq ADR 0325: what is refused once the catalogues moved).
|
||||
strings.HasPrefix(line, "warning: "):
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **A merge check judges the merge, not the head** (novox/hq ADR 0323, issue 341).
|
||||
//
|
||||
// On 2026-10-09 a node-engine pull request was checked green at its head and broke the trunk when it merged:
|
||||
// a merge an hour earlier had added a field under the same JSON key on other lines, git merged the two edits
|
||||
// without a conflict, and the result did not compile. The check had judged the head alone. So a check now
|
||||
// takes the trunk the pull request merges into, its head at the moment the check clones it as the check's
|
||||
// **trunk commit**, and makes in its own clone the merge of the head into that commit, as the forge's
|
||||
// default merge would. The gate and the repository's own check judge that merge.
|
||||
//
|
||||
// **The merge is only checked** (ADR 0323 §2, ADR 0238 §1): it is a commit off the trunk, made in the
|
||||
// check's own clone, never pushed to the forge and never registered; the clone is removed when the check
|
||||
// ends. Nothing here writes to a remote.
|
||||
//
|
||||
// The forge keeps no merge reference for a pull request, only its head, so the builder makes it itself.
|
||||
|
||||
// TrunkMerge is the merge a check made: the head and the trunk commit it was merged into, whole, and the merge
|
||||
// commit; or the files in conflict when the head does not merge cleanly.
|
||||
type TrunkMerge struct {
|
||||
Head, Trunk, Merge string
|
||||
Conflicts []string
|
||||
}
|
||||
|
||||
// JudgedHead is one head a check judged, with the trunk commit it merged that head into, each as twelve
|
||||
// characters (ADR 0323 §3, §6).
|
||||
type JudgedHead struct {
|
||||
Owner, Repo string
|
||||
Head, Trunk string
|
||||
}
|
||||
|
||||
// mergeIdentity is who a check's merge is made as: it is never pushed, so it names nobody's account. No hook of the
|
||||
// clone's or the build machine's git configuration runs, and no setting there changes the merge.
|
||||
var mergeIdentity = []string{"-c", "user.name=mesh merge check", "-c", "user.email=merge-check@mesh.invalid",
|
||||
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "-c", "merge.verifySignatures=false"}
|
||||
|
||||
// mergeIntoTrunk makes, in the clone at tree, the merge of head into the head of origin/<base> as it stands in
|
||||
// that fresh clone: the trunk commit. A head that does not merge cleanly answers the files in conflict and leaves
|
||||
// the tree at the trunk commit; an error is that the merge could not be tried at all.
|
||||
func mergeIntoTrunk(ctx context.Context, run Runner, tree, base, head string) (TrunkMerge, error) {
|
||||
var m TrunkMerge
|
||||
revParse := func(ref string) (string, error) {
|
||||
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", ref+"^{commit}")
|
||||
return strings.TrimSpace(out), err
|
||||
}
|
||||
var err error
|
||||
if m.Head, err = revParse(head); err != nil || m.Head == "" {
|
||||
return m, fmt.Errorf("the head %s cannot be read in the check's clone: %v", head, err)
|
||||
}
|
||||
if m.Trunk, err = revParse("refs/remotes/origin/" + base); err != nil || m.Trunk == "" {
|
||||
return m, fmt.Errorf("the trunk %s cannot be read, and a check judges the head merged into it "+
|
||||
"(novox/hq ADR 0323): %v", base, err)
|
||||
}
|
||||
if _, err := run(ctx, tree, "git", "checkout", "--quiet", "--detach", m.Trunk); err != nil {
|
||||
return m, fmt.Errorf("the trunk commit %s cannot be checked out: %w", twelve(m.Trunk), err)
|
||||
}
|
||||
message := fmt.Sprintf("Check the merge of %s into %s at %s (made for a merge check, never pushed)",
|
||||
twelve(m.Head), base, twelve(m.Trunk))
|
||||
args := append(append([]string{}, mergeIdentity...), "merge", "--no-ff", "--no-edit", "--no-verify", "--quiet", "-m", message, m.Head)
|
||||
if _, err := run(ctx, tree, "git", args...); err != nil {
|
||||
out, _ := run(ctx, tree, "git", "diff", "--name-only", "--diff-filter=U")
|
||||
m.Conflicts = strings.Fields(out)
|
||||
_, _ = run(ctx, tree, "git", "merge", "--abort")
|
||||
if len(m.Conflicts) == 0 {
|
||||
return m, fmt.Errorf("the head %s could not be merged into trunk %s: %w", twelve(m.Head), twelve(m.Trunk), err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.Merge, err = revParse("HEAD"); err != nil {
|
||||
return m, fmt.Errorf("the merge made for the check cannot be read: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// changedByMerge is every path the merge changes against the trunk commit: what the gate's delivery plan is
|
||||
// computed from (ADR 0323 §1). Nil when it cannot be read; the caller keeps the pull request's own list.
|
||||
func changedByMerge(ctx context.Context, run Runner, tree string, m TrunkMerge) []string {
|
||||
if m.Merge == "" || m.Trunk == "" {
|
||||
return nil
|
||||
}
|
||||
out, err := run(ctx, tree, "git", "diff", "--name-only", "--no-renames", m.Trunk, m.Merge)
|
||||
if err != nil {
|
||||
return nil // empty-on-error: the caller keeps the pull request's own list of changed files, the safe one
|
||||
}
|
||||
return strings.Fields(out)
|
||||
}
|
||||
|
||||
// conflictWords is a head that does not merge cleanly, said as its check's failure.
|
||||
func conflictWords(m TrunkMerge) string {
|
||||
return fmt.Sprintf("head %s does not merge cleanly into trunk %s: in conflict %s", twelve(m.Head), twelve(m.Trunk),
|
||||
strings.Join(m.Conflicts, ", "))
|
||||
}
|
||||
|
||||
// headAt is the commit a clone is checked out at, as twelve characters; empty when it cannot be read.
|
||||
func headAt(ctx context.Context, run Runner, tree string) string {
|
||||
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return twelve(strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
// twelve is a commit as a verdict names it: its first twelve characters.
|
||||
func twelve(commit string) string {
|
||||
if len(commit) > 12 {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A merge check judges the merge, not the head** (novox/hq ADR 0323, issue 341): the head merged into the trunk
|
||||
// commit in the check's own clone, never pushed; a head that does not merge cleanly fails, naming the files; the
|
||||
// verdict names the head, the trunk commit and the commits of the repositories read beside it.
|
||||
|
||||
// aTrunkAndABranch is a repository whose main moved after the branch was cut: main holds trunkFiles over the
|
||||
// common files, the branch headFiles. It answers the repository, the branch's head and main's head.
|
||||
func aTrunkAndABranch(t *testing.T, common, trunkFiles, headFiles map[string]string) (string, string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
write := func(files map[string]string) {
|
||||
for name, body := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
git("add", "-A")
|
||||
}
|
||||
git("init", "--quiet", "-b", "main")
|
||||
write(common)
|
||||
git("commit", "--quiet", "-m", "common")
|
||||
git("checkout", "--quiet", "-b", "change")
|
||||
write(headFiles)
|
||||
git("commit", "--quiet", "-m", "the change")
|
||||
head := git("rev-parse", "HEAD")
|
||||
git("checkout", "--quiet", "main")
|
||||
write(trunkFiles)
|
||||
git("commit", "--quiet", "-m", "the trunk moved")
|
||||
return dir, head, git("rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
func TestACheckJudgesTheHeadMergedIntoTheTrunk(t *testing.T) {
|
||||
// main's script passes only in a tree holding both the trunk's file and the head's: the merge.
|
||||
repo, head, trunk := aTrunkAndABranch(t,
|
||||
map[string]string{CheckScript: "test -f trunk-only && test -f head-only || { echo 'FAIL: not the merge'; exit 1; }\n"},
|
||||
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
|
||||
beside, besideHead := aCheckedRepository(t, map[string]string{"README": "the catalogue"})
|
||||
registry := aFactsRegistry(t)
|
||||
aRuntimeOnThisMachine(t)
|
||||
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341", Repository: repo, Ref: head, Owner: "novox",
|
||||
Repo: "mesh-lab", Number: 1, Base: "main", Toolchain: "golang",
|
||||
Beside: map[string]Beside{"mesh-catalog": {Repository: beside, Ref: besideHead}}}, t.TempDir(), registry,
|
||||
GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Repo == nil || v.Repo.Verdict != "pass" {
|
||||
t.Fatalf("the check did not judge the head merged into the trunk: %+v\n%s", v.Repo, v.Report)
|
||||
}
|
||||
if v.Head != head[:12] || v.Trunk != trunk[:12] {
|
||||
t.Fatalf("the verdict names head %q and trunk %q, not %s and %s", v.Head, v.Trunk, head[:12], trunk[:12])
|
||||
}
|
||||
if v.Beside["mesh-catalog"] != besideHead[:12] {
|
||||
t.Fatalf("the verdict names the catalogue read beside it as %q, not %s: %v", v.Beside["mesh-catalog"],
|
||||
besideHead[:12], v.Beside)
|
||||
}
|
||||
if v.Merge == "" || v.Merge == head || v.Merge == trunk {
|
||||
t.Fatalf("no merge of the head into the trunk was made for the check: %q", v.Merge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeadThatDoesNotMergeCleanlyFailsNamingTheFiles(t *testing.T) {
|
||||
repo, head, trunk := aTrunkAndABranch(t,
|
||||
map[string]string{"store.go": "package store\n\nconst Declared = 1\n", "other.go": "package store\n"},
|
||||
map[string]string{"store.go": "package store\n\nconst Declared = 2\n"},
|
||||
map[string]string{"store.go": "package store\n\nconst Declared = 3\n"})
|
||||
|
||||
// No artifact store and no container runtime: a head that does not merge is judged before either is needed.
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341-conflict", Repository: repo, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-lab", Number: 2, Base: "main", Toolchain: "golang"}, t.TempDir(), "",
|
||||
GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a head that does not merge cleanly was not judged: %v", err)
|
||||
}
|
||||
want := "does not merge cleanly into trunk " + trunk[:12]
|
||||
for name, l := range map[string]*Layer{"gate": v.Gate, "repository": v.Repo} {
|
||||
if l == nil || l.Verdict != "fail" || !strings.Contains(l.Summary, want) || !strings.Contains(l.Summary, "store.go") {
|
||||
t.Fatalf("its %s layer said %+v, not a failure saying %q and naming store.go", name, l, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(v.Summary, "other.go") {
|
||||
t.Fatalf("a file not in conflict is named: %s", v.Summary)
|
||||
}
|
||||
if v.Head != head[:12] || v.Trunk != trunk[:12] {
|
||||
t.Fatalf("the failure names head %q and trunk %q", v.Head, v.Trunk)
|
||||
}
|
||||
}
|
||||
|
||||
// **The merge made for a check is only checked** (ADR 0323 §2): the forge never receives it.
|
||||
func TestTheMergeMadeForACheckIsNeverPushed(t *testing.T) {
|
||||
repo, head, trunk := aTrunkAndABranch(t, map[string]string{CheckScript: "true\n"},
|
||||
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
|
||||
registry := aFactsRegistry(t)
|
||||
aRuntimeOnThisMachine(t)
|
||||
refsBefore := gitOut(t, repo, "for-each-ref")
|
||||
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341-push", Repository: repo, Ref: head, Owner: "novox",
|
||||
Repo: "mesh-lab", Number: 3, Base: "main", Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Merge == "" {
|
||||
t.Fatal("the check made no merge of the head into the trunk")
|
||||
}
|
||||
if err := exec.Command("git", "-C", repo, "cat-file", "-e", v.Merge+"^{commit}").Run(); err == nil {
|
||||
t.Fatalf("the merge %s made for the check reached the forge's repository", v.Merge)
|
||||
}
|
||||
if after := gitOut(t, repo, "for-each-ref"); after != refsBefore {
|
||||
t.Fatalf("the check moved a ref of the forge's repository:\nbefore %s\nafter %s", refsBefore, after)
|
||||
}
|
||||
if got := gitOut(t, repo, "rev-parse", "main"); got != trunk {
|
||||
t.Fatalf("the trunk moved to %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery group's composed check merges each member's head into its own trunk's head (ADR 0323 §6), and names
|
||||
// both for each; a member that does not merge cleanly fails the composed check, naming it.
|
||||
func TestAGroupsComposedCheckMergesEachMemberIntoItsTrunk(t *testing.T) {
|
||||
repo, head, trunk := aTrunkAndABranch(t, map[string]string{CheckScript: "true\n"},
|
||||
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
|
||||
member, memberHead, memberTrunk := aTrunkAndABranch(t, map[string]string{"a": "1"},
|
||||
map[string]string{"b": "trunk"}, map[string]string{"c": "head"})
|
||||
registry := aFactsRegistry(t)
|
||||
aRuntimeOnThisMachine(t)
|
||||
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-341-group", Repository: repo, Ref: head, Owner: "novox",
|
||||
Repo: "mesh-controller", Number: 4, Base: "main", Toolchain: "golang", Group: []GroupHead{{Owner: "novox",
|
||||
Repo: "mesh-host", Repository: member, Ref: memberHead, Base: "main"}}}, t.TempDir(), registry,
|
||||
GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Head != head[:12] || v.Trunk != trunk[:12] {
|
||||
t.Fatalf("the composed check names its first head %q into %q", v.Head, v.Trunk)
|
||||
}
|
||||
if len(v.Members) != 1 || v.Members[0].Head != memberHead[:12] || v.Members[0].Trunk != memberTrunk[:12] {
|
||||
t.Fatalf("the composed check names its members %+v, not mesh-host %s into %s", v.Members, memberHead[:12],
|
||||
memberTrunk[:12])
|
||||
}
|
||||
|
||||
conflicting, conflictHead, _ := aTrunkAndABranch(t, map[string]string{"a": "1"}, map[string]string{"a": "2"},
|
||||
map[string]string{"a": "3"})
|
||||
v, err = Check(t.Context(), Command, CheckSpec{ID: "check-341-group-conflict", Repository: repo, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-controller", Number: 4, Base: "main", Toolchain: "golang", Group: []GroupHead{{
|
||||
Owner: "novox", Repo: "mesh-host", Repository: conflicting, Ref: conflictHead, Base: "main"}}}, t.TempDir(),
|
||||
registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Verdict != "fail" || !strings.Contains(v.Summary, "novox/mesh-host") ||
|
||||
!strings.Contains(v.Summary, "does not merge cleanly") {
|
||||
t.Fatalf("a member that does not merge cleanly was answered %s: %s", v.Verdict, v.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func gitOut(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// No hook runs when a check makes its merge: not one in the clone, whatever the build machine's git configuration says.
|
||||
func TestACheckMergeRunsNoHook(t *testing.T) {
|
||||
repo, head, _ := aTrunkAndABranch(t, map[string]string{"a": "1"}, map[string]string{"b": "trunk"},
|
||||
map[string]string{"c": "head"})
|
||||
clone := filepath.Join(t.TempDir(), "clone")
|
||||
if out, err := exec.Command("git", "clone", "--quiet", repo, clone).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
ran := filepath.Join(t.TempDir(), "ran")
|
||||
for _, hook := range []string{"pre-merge-commit", "commit-msg", "prepare-commit-msg", "post-merge"} {
|
||||
if err := os.WriteFile(filepath.Join(clone, ".git", "hooks", hook),
|
||||
[]byte("#!/bin/sh\necho "+hook+" >> "+ran+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m, err := mergeIntoTrunk(t.Context(), Command, clone, "main", head)
|
||||
if err != nil || m.Merge == "" {
|
||||
t.Fatalf("the merge was not made: %+v, %v", m, err)
|
||||
}
|
||||
if said, err := os.ReadFile(ran); err == nil {
|
||||
t.Fatalf("a hook ran when the check made its merge: %s", said)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReplay341 is the replay of novox/hq issue 341, written with only what the build agent had before its fix,
|
||||
// so mesh-lab's prover can lay it over the commit before. On 2026-10-09 node-engine #64/#65 merged at 00:15 and
|
||||
// added Applied.Declared, under the JSON key `declared`, to the store record. #66, checked at 00:49 at its head,
|
||||
// had added its own Applied.Declared under the same key on other lines. Its check passed; git merged the two
|
||||
// edits without a conflict, and the trunk no longer compiled: "Declared redeclared". Before the fix the check
|
||||
// judged the head alone and passed. After it (novox/hq ADR 0323), the check judges the head merged into the
|
||||
// trunk commit that held the other field, and fails saying the trunk does not compile.
|
||||
//
|
||||
// It runs the check whole, with a container runtime that runs each container's command on this machine
|
||||
// (aRuntimeOnThisMachine): the repository's own check is `go build ./...`.
|
||||
func TestReplay341(t *testing.T) {
|
||||
origin, head := theNinthOfOctober(t)
|
||||
registry := aFactsRegistry(t)
|
||||
aRuntimeOnThisMachine(t)
|
||||
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "replay-341", Repository: origin, Ref: head, Owner: "novox",
|
||||
Repo: "mesh-host", Number: 66, Base: "main", Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("the check could not run: %v", err)
|
||||
}
|
||||
if v.Repo == nil || v.Repo.Verdict != "fail" {
|
||||
t.Fatalf("node-engine #66 at its head was answered %+v: the merge it makes into the trunk that holds #65's "+
|
||||
"field does not compile, and the check let it merge", v.Repo)
|
||||
}
|
||||
if said := v.Repo.Summary + "\n" + v.Repo.Failed + "\n" + v.Report; !strings.Contains(said, "Declared redeclared") {
|
||||
t.Fatalf("the check failed without saying the trunk does not compile:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// theNinthOfOctober is the node-engine's repository as it stood at 00:49 on 2026-10-09, in miniature: a trunk
|
||||
// holding #65's Applied.Declared (a string, key `declared`), and #66's head, branched before it, holding its own
|
||||
// Applied.Declared (a *FoundUnit, the same key) on other lines. Each compiles alone; their merge does not.
|
||||
func theNinthOfOctober(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
origin := t.TempDir()
|
||||
git := func(args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = origin
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
write := func(name, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(origin, name)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(origin, name), []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
store := func(declared65, declared66 string) string {
|
||||
return "package store\n\n// FoundUnit is a unit found on the machine.\ntype FoundUnit struct{ Name string }\n\n" +
|
||||
"// Applied is what the node-engine applied.\ntype Applied struct {\n\tGeneration int `json:\"generation\"`\n" +
|
||||
declared66 + "\tModules []string `json:\"modules\"`\n\tFiles []string `json:\"files\"`\n" +
|
||||
"\tUnits []string `json:\"units\"`\n\tAt string `json:\"at\"`\n" + declared65 + "}\n"
|
||||
}
|
||||
git("init", "--quiet", "-b", "main")
|
||||
write("go.mod", "module example.org/mesh-host\n\ngo 1.21\n")
|
||||
write("internal/store/store.go", store("", ""))
|
||||
write(CheckScript, "# mesh-check-toolchain: go\nset -e\nGOFLAGS= GOWORK=off GOTOOLCHAIN=local GOPROXY=off go build ./...\n")
|
||||
git("add", "-A")
|
||||
git("commit", "--quiet", "-m", "#63: the store record")
|
||||
git("checkout", "--quiet", "-b", "adr-0269")
|
||||
write("internal/store/store.go", store("", "\tDeclared *FoundUnit `json:\"declared\"`\n"))
|
||||
git("commit", "--quiet", "-am", "#66: the unit a declaration names")
|
||||
head := git("rev-parse", "HEAD")
|
||||
git("checkout", "--quiet", "main")
|
||||
write("internal/store/store.go", store("\tDeclared string `json:\"declared\"`\n", ""))
|
||||
git("commit", "--quiet", "-am", "#65: the declaration applied")
|
||||
return origin, head
|
||||
}
|
||||
|
||||
// aRuntimeOnThisMachine puts a container runtime on PATH that runs a container's command on this machine, in the
|
||||
// directory and with the environment the check gives it: a store and a bus that answer, a judge that is never
|
||||
// built, and the repository's own check run as written. Only for a check whose code is the test's own.
|
||||
func aRuntimeOnThisMachine(t *testing.T) {
|
||||
t.Helper()
|
||||
bus, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { bus.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := bus.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
dir := t.TempDir()
|
||||
runtime := `#!/bin/sh
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
ps|rm|exec) exit 0 ;;
|
||||
port) echo "` + bus.Addr().String() + `"; exit 0 ;;
|
||||
run)
|
||||
workdir=""; detached=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-d) detached=1; shift ;;
|
||||
--rm) shift ;;
|
||||
--workdir) workdir="$2"; shift 2 ;;
|
||||
--env) export "$2"; shift 2 ;;
|
||||
--network|--volume|--user|--label|--name|-p|-e) shift 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$detached" ]; then echo replay-container; exit 0; fi
|
||||
shift
|
||||
if [ "$1" = "sh" ] && [ -n "$workdir" ]; then cd "$workdir" && exec "$@"; fi
|
||||
echo "no such image on this machine" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
exit 1
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(runtime), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
}
|
||||
@@ -246,6 +246,17 @@ type Rendering struct {
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// KeepsFolders says this machine's node-engine gives modules their identities and keeps shared folders
|
||||
// (novox/hq ADR 0325: its statement's contract is link.IdentityContract or later). To an older, strict engine none of
|
||||
// it is sent — no `identities`, no container `identity`, no access `users` or `taken`, no owner derived from an
|
||||
// identity — and what it runs is composed as before.
|
||||
KeepsFolders bool
|
||||
// Identities is each module's allocated identity, by module (ADR 0325 §3): the same on every machine. A module
|
||||
// that declares `identity` and has none here is composed without one, as to an older engine.
|
||||
Identities map[string]ModuleIdentity
|
||||
// KeptFolders is every folder the operator let the mesh keep on this machine (ADR 0325 §8), by its clean path.
|
||||
KeptFolders map[string]bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -320,6 +331,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
|
||||
// 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
|
||||
@@ -358,7 +372,9 @@ func (r Resolution) LeftOutBecause(settings SettingsBy, adopted bool) map[string
|
||||
out[m.Module] = &UnreadManifestError{Module: m.Module, said: why}
|
||||
continue
|
||||
}
|
||||
if err := JudgeSettings(m, settings[m.Module], adopted); err != nil {
|
||||
// A placement stored ahead of the build that declares its access is passed by, not a reason to leave the
|
||||
// module out (novox/hq issue 575): it was judged against that build where it was stored.
|
||||
if err := ComposesWith(m, settings[m.Module], adopted); err != nil {
|
||||
out[m.Module] = err
|
||||
}
|
||||
}
|
||||
@@ -380,7 +396,8 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
received := map[string]map[string][]Contribution{}
|
||||
leftOut := map[string]string{}
|
||||
var foreseen, unplaced []string
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced)
|
||||
var identities []ModuleIdentity
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &identities)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -394,8 +411,12 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
})
|
||||
}
|
||||
sort.Strings(unplaced)
|
||||
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced}, nil
|
||||
composed := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced}
|
||||
if len(identities) > 0 {
|
||||
composed.Identities = identitiesOf(identities)
|
||||
}
|
||||
return composed, nil
|
||||
}
|
||||
|
||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||
@@ -406,7 +427,7 @@ 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) ([]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
|
||||
@@ -553,6 +574,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
resources := m.Resources
|
||||
|
||||
// The identity it runs as here (novox/hq ADR 0325 §3, §11): none to an older engine, nor without an allocation.
|
||||
ident, hasIdent, err := identityFor(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasIdent && identities != nil {
|
||||
*identities = append(*identities, ident)
|
||||
}
|
||||
mountedByIdentity := dirsMountedByIdentity(m)
|
||||
|
||||
// What the mesh computes for this module goes FIRST, before the module's own resources.
|
||||
//
|
||||
// **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so
|
||||
@@ -645,6 +676,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
sealed = ForeseenSealed
|
||||
*foreseen = append(*foreseen, m.Module+"/"+name)
|
||||
}
|
||||
if sealed == "" && m.OwnSecrets[name].IssuedBy == IssuedOutside {
|
||||
// **Not given, and the mesh never makes it** (novox/hq issue 401, ADR 0228): no file, as for a
|
||||
// family's member (ADR 0283), so the module reads its absence as not given.
|
||||
continue
|
||||
}
|
||||
if sealed == "" {
|
||||
// Declared and not made. Refused rather than skipped: a module whose own
|
||||
// credential is silently absent starts, fails to authenticate, and the reason is
|
||||
@@ -998,11 +1034,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, err
|
||||
}
|
||||
ownerInto(copied, placed)
|
||||
// And its identity (novox/hq ADR 0325 §2, §3): a container's resolved, or taken out where none is composed;
|
||||
// the owner of a directory its identity's containers mount. After the placement's owner: the identity wins.
|
||||
if err := identityInto(copied, m, ident, hasIdent, 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
|
||||
// has been put in — filling secrets first would look at content that is not yet what
|
||||
// the machine receives.
|
||||
if err := intoFile(copied, sealed, m.Module); err != nil {
|
||||
if err := intoFile(copied, sealed, outsideNotGiven(m, with.Needed[m.Module]), m.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **Substituted here, not on the machine.** A bound value is not secret — the mesh
|
||||
@@ -1115,6 +1156,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
|
||||
@@ -2431,6 +2478,9 @@ func secretsReadBy(resource map[string]any, m Manifest, given map[string]string)
|
||||
}
|
||||
paths := map[string]string{}
|
||||
for name, own := range m.OwnSecrets.Plain() {
|
||||
if own.IssuedBy == IssuedOutside && given[name] == "" {
|
||||
continue // not given, so not placed: nothing to restart on (novox/hq issue 401)
|
||||
}
|
||||
paths[name] = own.Path
|
||||
}
|
||||
for name, sealed := range given {
|
||||
|
||||
@@ -1859,6 +1859,9 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, invokeProblems(m)...)
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
// How each container's image is told who to run as, and what a module that says so is held to (novox/hq ADR 0325).
|
||||
problems = append(problems, identityProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var containers []map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == m.Module {
|
||||
if _, declares := res[IdentityField]; declares && 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,333 @@
|
||||
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))
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An own secret issued outside the mesh that nobody gave (novox/hq issue 401, ADR 0228): the mesh makes no value for it,
|
||||
// so the composition places no file, a file of the module that names it says empty there, and nothing restarts on it.
|
||||
// Given, it is placed and filled like any own secret.
|
||||
|
||||
func outsideManifest(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(`{"module":"assistant","version":"1",
|
||||
"own-secrets":{
|
||||
"password":{"path":"/var/lib/assistant/password.secret","taken":"at-start"},
|
||||
"vendor-key":{"path":"/var/lib/assistant/vendor-key.secret","taken":"at-start","issued-by":"outside"}},
|
||||
"resources":[
|
||||
{"id":"env","type":"file","path":"/var/lib/assistant/env","mode":"0600",
|
||||
"content":"PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n"},
|
||||
{"id":"server","type":"container","name":"assistant","image":"assistant:1",
|
||||
"env-file":["/var/lib/assistant/env"],
|
||||
"volumes":["/var/lib/assistant/vendor-key.secret:/run/vendor-key:ro"],
|
||||
"secrets-in-environment":"reads its settings from the environment only"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func resourceAt(out []map[string]any, path string) map[string]any {
|
||||
for _, r := range out {
|
||||
if r["path"] == path {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceNamed(out []map[string]any, name string) map[string]any {
|
||||
for _, r := range out {
|
||||
if r["name"] == name {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAnOutsideSecretNotGivenIsNoFileAndAFileNamingItSaysEmpty(t *testing.T) {
|
||||
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
|
||||
out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {"password": "sealed-password"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("a module whose outside secret nobody gave did not compose: %v", err)
|
||||
}
|
||||
if got := resourceAt(out, "/var/lib/assistant/vendor-key.secret"); got != nil {
|
||||
t.Fatalf("a secret nobody gave was placed: %v", got)
|
||||
}
|
||||
env := resourceAt(out, "/var/lib/assistant/env")
|
||||
if env == nil {
|
||||
t.Fatal("the module's own file is not composed")
|
||||
}
|
||||
if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=\n" {
|
||||
t.Fatalf("the file names the secret nobody gave as %q", content)
|
||||
}
|
||||
secrets, _ := env["secrets"].(map[string]any)
|
||||
if len(secrets) != 1 || secrets["password"] != "sealed-password" {
|
||||
t.Fatalf("the file is filled from %v", secrets)
|
||||
}
|
||||
server := resourceNamed(out, "assistant")
|
||||
if on := strings.Join(stringsIn(server["restart-on"]), " "); strings.Contains(on, "vendor-key") {
|
||||
t.Fatalf("the container restarts on a secret nobody gave: %q", on)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOutsideSecretGivenIsPlacedAndFilled(t *testing.T) {
|
||||
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
|
||||
out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {
|
||||
"password": "sealed-password", "vendor-key": "sealed-vendor-key"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placed := resourceAt(out, "/var/lib/assistant/vendor-key.secret")
|
||||
if placed == nil || placed["sealed"] != "sealed-vendor-key" {
|
||||
t.Fatalf("the secret given is placed as %v", placed)
|
||||
}
|
||||
env := resourceAt(out, "/var/lib/assistant/env")
|
||||
if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n" {
|
||||
t.Fatalf("the file was changed: %q", content)
|
||||
}
|
||||
if secrets, _ := env["secrets"].(map[string]any); secrets["vendor-key"] != "sealed-vendor-key" {
|
||||
t.Fatalf("the file is filled from %v", secrets)
|
||||
}
|
||||
server := resourceNamed(out, "assistant")
|
||||
if on := strings.Join(stringsIn(server["restart-on"]), " "); !strings.Contains(on, "assistant."+NeedID("vendor-key")) {
|
||||
t.Fatalf("the container does not restart on the secret given: %q", on)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret the mesh makes is still refused when it was not made: only an outside one is left out.
|
||||
func TestAnOwnSecretTheMeshMakesIsStillRefusedWhenNotMade(t *testing.T) {
|
||||
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
|
||||
_, err := r.Declaration(Rendering{})
|
||||
if nm, ok := err.(*NotMadeError); !ok || nm.Name != "password" {
|
||||
t.Fatalf("a secret the mesh makes, not made: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -270,6 +271,31 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
|
||||
// It refuses an id the module declares no access under, and a path that is not absolute. An
|
||||
// access declared by path alone cannot be placed — it has no name to place it by.
|
||||
func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
|
||||
return accessPlaces(m, layers, true)
|
||||
}
|
||||
|
||||
// UndeclaredAccessError is a layer placing an access the definition does not declare (novox/hq issue 575): refused
|
||||
// where a setting is stored, unless a newer build of the module declares it; passed by where a machine is composed.
|
||||
type UndeclaredAccessError struct {
|
||||
Module, ID string
|
||||
said string
|
||||
}
|
||||
|
||||
func (e *UndeclaredAccessError) Error() string { return e.said }
|
||||
|
||||
// UndeclaredAccessPlaces refuses a layer that places an access the definition does not declare.
|
||||
func UndeclaredAccessPlaces(m Manifest, layers []Layer) error {
|
||||
_, err := accessPlaces(m, layers, true)
|
||||
var undeclared *UndeclaredAccessError
|
||||
if errors.As(err, &undeclared) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// accessPlaces is AccessPlaces; where strict is false an access the definition does not declare is passed by
|
||||
// (novox/hq issue 575), as a placement stored ahead of the build that declares it.
|
||||
func accessPlaces(m Manifest, layers []Layer, strict bool) (map[string]string, error) {
|
||||
declared := map[string]bool{}
|
||||
for _, a := range m.Accesses {
|
||||
if a.ID != "" {
|
||||
@@ -289,9 +315,12 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
|
||||
}
|
||||
for id, body := range blocks {
|
||||
if !declared[id] {
|
||||
return nil, fmt.Errorf(
|
||||
if !strict {
|
||||
continue
|
||||
}
|
||||
return nil, &UndeclaredAccessError{Module: m.Module, ID: id, said: fmt.Sprintf(
|
||||
"%s places the access %q, which it does not declare — the setting reaches "+
|
||||
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))
|
||||
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))}
|
||||
}
|
||||
path, _ := body.(string)
|
||||
path = strings.TrimSpace(path)
|
||||
@@ -324,7 +353,7 @@ type placedAccess struct {
|
||||
// placed one, the definition's where it carries a default, and refused where neither says — an
|
||||
// access that resolves to nowhere would reach the machine as a mount of nothing.
|
||||
func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) {
|
||||
placed, err := AccessPlaces(m, layers)
|
||||
placed, err := accessPlaces(m, layers, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -156,7 +156,11 @@ func secretFilesOf(resources []map[string]any) map[string]bool {
|
||||
// A name the module never declared is refused here rather than on the machine. The host would
|
||||
// refuse it too — but it would do so having already been handed a declaration, which reads as the
|
||||
// mesh sending something broken, and the name it could not find is a manifest's typo.
|
||||
func intoFile(resource map[string]any, sealed map[string]string, module string) error {
|
||||
//
|
||||
// **An own secret issued outside the mesh that nobody gave is filled with nothing** (novox/hq issue 401): the mesh
|
||||
// never makes one (ADR 0228), and a file is where the module reads it, so the place says empty — the module sees it
|
||||
// was not given, where a value the mesh made looked given and wrong. notGiven names those secrets.
|
||||
func intoFile(resource map[string]any, sealed map[string]string, notGiven map[string]bool, module string) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
@@ -171,6 +175,10 @@ func intoFile(resource map[string]any, sealed map[string]string, module string)
|
||||
into := map[string]any{}
|
||||
for _, name := range used {
|
||||
value := sealed[name]
|
||||
if value == "" && notGiven[name] {
|
||||
content = strings.ReplaceAll(content, "${secret:"+name+"}", "")
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf(
|
||||
"%s has a file that says ${secret:%s}, and %s has no secret of its own by that "+
|
||||
@@ -179,10 +187,25 @@ func intoFile(resource map[string]any, sealed map[string]string, module string)
|
||||
}
|
||||
into[name] = value
|
||||
}
|
||||
resource["secrets"] = into
|
||||
resource["content"] = content
|
||||
if len(into) > 0 {
|
||||
resource["secrets"] = into
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// outsideNotGiven is each own secret of a module issued outside the mesh that this composition holds no value for
|
||||
// (novox/hq issue 401): what intoFile fills with nothing, and what nothing restarts on.
|
||||
func outsideNotGiven(m Manifest, given map[string]string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for name, own := range m.OwnSecrets.Plain() {
|
||||
if own.IssuedBy == IssuedOutside && given[name] == "" {
|
||||
out[name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// namesOr says what a module could have written, because the answer to "that name is wrong" is
|
||||
// almost always one of two or three right ones.
|
||||
func namesOr(sealed map[string]string) string {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A declared directory may be owned by the account its module runs as (novox/hq issue 279): a media
|
||||
// manager runs as the puid and pgid its assignment sets per node, and a folder it must write was owned
|
||||
// by the manifest's literal, so the app refused it on a machine whose identity was not the image's.
|
||||
|
||||
func recycler() Manifest {
|
||||
return Manifest{Module: "sonarr",
|
||||
Resources: []map[string]any{
|
||||
{"id": "recycle-bin", "type": "directory", "path": "/srv/sonarr/recycle", "mode": "0755",
|
||||
"owner": "${setting:puid}:${setting:pgid}"},
|
||||
{"id": "identity", "type": "file", "path": "/srv/sonarr/identity.env", "mode": "0644",
|
||||
"content": "PUID=${setting:puid}\nPGID=${setting:pgid}\n"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func copied(r map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The node's identity reaches the directory's owner, the node's layer over the mesh's, a number given
|
||||
// as a string or as a JSON number alike.
|
||||
func TestASettingOwnsADirectory(t *testing.T) {
|
||||
m := recycler()
|
||||
layers := []Layer{
|
||||
{From: "the mesh", Values: map[string]any{"puid": "1000", "pgid": "1000"}},
|
||||
{From: "this node", Values: map[string]any{"puid": float64(1001), "pgid": "2000"}},
|
||||
}
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, layers, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["owner"] != "1001:2000" {
|
||||
t.Fatalf("owned by %q, not 1001:2000", r["owner"])
|
||||
}
|
||||
user := copied(m.Resources[0])
|
||||
user["owner"] = "${setting:puid}"
|
||||
if err := settingInto(user, layers, m.Module); err != nil || user["owner"] != "1001" {
|
||||
t.Fatalf("user alone: %v, %v", user["owner"], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Composed for a machine, the way a push writes it: the directory reaches the node-engine owned by the
|
||||
// number the node's settings name, never by the placeholder.
|
||||
func TestAComposedMachineGetsTheOwnerTheSettingNames(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, recycler())
|
||||
with := anchorRendering(false)
|
||||
with.Settings["sonarr"] = []Layer{{From: "anchor", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["sonarr"]; left {
|
||||
t.Fatalf("left out: %s", why)
|
||||
}
|
||||
got := byID(composed.Resources)["sonarr.recycle-bin"]
|
||||
if got["owner"] != "1001:2000" {
|
||||
t.Fatalf("composed owned by %v, not 1001:2000", got["owner"])
|
||||
}
|
||||
}
|
||||
|
||||
// Only a whole number owns a directory: an account's name, text, a sign, a fraction or a value past the
|
||||
// kernel's ids is refused by name, and the directory is left as it was. So is a key nothing sets.
|
||||
func TestAnOwnerSettingThatIsNoNumberIsRefused(t *testing.T) {
|
||||
m := recycler()
|
||||
for _, bad := range []any{"abc", "root", "", "-1", "+5", "1000:1000", "10 00", "1000\nx", float64(1.5),
|
||||
float64(-3), "4294967295", "99999999999", true} {
|
||||
r := copied(m.Resources[0])
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"puid": bad, "pgid": "1000"}}}, m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), `"puid"`) || !strings.Contains(err.Error(), "whole number") {
|
||||
t.Errorf("%v: %v", bad, err)
|
||||
}
|
||||
if r["owner"] != m.Resources[0]["owner"] {
|
||||
t.Errorf("%v: the owner was changed on refusal: %v", bad, r["owner"])
|
||||
}
|
||||
}
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, nil, m.Module); err == nil || !strings.Contains(err.Error(), "${setting:puid}") {
|
||||
t.Errorf("nothing set: %v", err)
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "ace", Values: map[string]any{"puid": "media", "pgid": "1000"}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "whole number") {
|
||||
t.Errorf("a stored name is not refused when judged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Where the manifest is read: an owner holding a setting is a whole placeholder or a whole number on
|
||||
// each side of the colon, and every key is one the module asks for elsewhere or declares a default for.
|
||||
func TestAnOwnerSettingIsJudgedWhereTheManifestIsRead(t *testing.T) {
|
||||
ok := []string{"${setting:puid}:${setting:pgid}", "${setting:puid}", "${setting:puid}:1000", "1000:${setting:pgid}",
|
||||
"1000:1000", "media:media"}
|
||||
bad := []string{
|
||||
"${setting:puid}x",
|
||||
"x${setting:puid}",
|
||||
"${setting:puid}:${setting:pgid}:1",
|
||||
"${setting:puid}${setting:pgid}",
|
||||
"media:${setting:pgid}",
|
||||
"${setting:puid}:",
|
||||
"${setting:other}:${setting:pgid}",
|
||||
}
|
||||
for _, owner := range append(ok, bad...) {
|
||||
m := recycler()
|
||||
m.Resources[0] = copied(m.Resources[0])
|
||||
m.Resources[0]["owner"] = owner
|
||||
err := parsed(t, m)
|
||||
refused := err != nil && strings.Contains(err.Error(), "owner")
|
||||
want := false
|
||||
for _, b := range bad {
|
||||
want = want || b == owner
|
||||
}
|
||||
if refused != want {
|
||||
t.Errorf("%s: refused %v, want %v (%v)", owner, refused, want, err)
|
||||
}
|
||||
}
|
||||
// A key with a default is declared, though nothing else asks for it.
|
||||
m := recycler()
|
||||
m.Resources = m.Resources[:1]
|
||||
m.Resources[0] = copied(m.Resources[0])
|
||||
m.Resources[0]["owner"] = "${setting:workers}"
|
||||
m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: float64(4), Why: "a test"}}
|
||||
if err := parsed(t, m); err != nil && strings.Contains(err.Error(), "owner") {
|
||||
t.Errorf("a declared key was refused: %v", err)
|
||||
}
|
||||
// A file's owner takes no setting: the sweep says so.
|
||||
f := recycler()
|
||||
f.Resources[1] = copied(f.Resources[1])
|
||||
f.Resources[1]["owner"] = "${setting:puid}"
|
||||
if err := parsed(t, f); err == nil || !strings.Contains(err.Error(), "not filled in this field") {
|
||||
t.Errorf("a file's owner setting passed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key a directory's owner asks for is a destination, so setting it is not called stray.
|
||||
func TestASettingAnOwnerAsksForIsNotStray(t *testing.T) {
|
||||
m := recycler()
|
||||
m.Resources = m.Resources[:1]
|
||||
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}), "; ")
|
||||
if strings.Contains(stray, "puid") || strings.Contains(stray, "pgid") {
|
||||
t.Errorf("called stray: %s", stray)
|
||||
}
|
||||
}
|
||||
|
||||
// An owner is written as the number parsed: "0001" and "00" reach the node-engine as 1 and 0, never as text.
|
||||
// Uid 0 is allowed, because a key an owner asks for is the terminal's alone.
|
||||
func TestAnOwnerSettingIsWrittenAsTheNumber(t *testing.T) {
|
||||
m := recycler()
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"puid": "0001", "pgid": "00"}}}, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["owner"] != "1:0" {
|
||||
t.Fatalf("owned by %q, not 1:0", r["owner"])
|
||||
}
|
||||
}
|
||||
|
||||
// Every key a directory's owner asks for is the controller's terminal's alone (novox/hq issue 279, issue 339):
|
||||
// root chowns the folder to it, so it is terminal-only whatever else asks for it — a file nothing trusts, a
|
||||
// default, or nothing at all.
|
||||
func TestAnOwnersKeyIsTheTerminals(t *testing.T) {
|
||||
m := recycler()
|
||||
m.Resources[1] = copied(m.Resources[1])
|
||||
m.Resources[1]["trusted"] = false
|
||||
m.Settings = map[string]SettingDeclaration{"pgid": {Kind: KindPreference, Default: "1000", Why: "a test"}}
|
||||
keys := strings.Join(TerminalKeys(m), ",")
|
||||
for _, k := range []string{"puid", "pgid"} {
|
||||
if !strings.Contains(","+keys+",", ","+k+",") {
|
||||
t.Errorf("%s is not the terminal's: %s", k, keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,7 +16,8 @@ import (
|
||||
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
|
||||
// operator answering.
|
||||
//
|
||||
// `${setting:<key>}` in a file's content, and in a service's unit name, is filled from the module's settings layers — the mesh's,
|
||||
// `${setting:<key>}` in a file's content, in a service's unit name and in a directory's owner (a uid or
|
||||
// gid only, issue 279) is filled from the module's settings layers — the mesh's,
|
||||
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
|
||||
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
|
||||
// naming the key and the remedy: a definition that carried a default for a mail domain would be
|
||||
@@ -59,6 +61,9 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
|
||||
if fmt.Sprint(resource["type"]) == "service" {
|
||||
return settingIntoUnit(resource, layers, module)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
return settingIntoOwner(resource, layers, module)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
@@ -143,6 +148,107 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// A declared directory may be owned by the account its module runs as (novox/hq issue 279).
|
||||
//
|
||||
// A containerised module runs as the identity its assignment sets per node — its puid and pgid
|
||||
// settings, written into the environment file its image reads (ADR 0112, issue 153) — and a directory
|
||||
// its app must write, beyond the one its image re-owns at start, was owned by whatever the manifest
|
||||
// spelt: right on a machine whose identity matched the image's default, refused by the app on one whose
|
||||
// did not. So a directory's `owner` may name the settings that identity is: `${setting:<key>}` for the
|
||||
// user alone, `${setting:<user>}:${setting:<group>}` for both, filled from the same layers at
|
||||
// composition.
|
||||
//
|
||||
// **Whole numbers only.** An owner a setting fills is a uid or a gid — never an account's name, never
|
||||
// text — so no value can put anything into the ownership the node-engine applies but a number; one that
|
||||
// is not is refused at composition, naming the key, and the directory is left as it was. The shape is
|
||||
// judged where the manifest is read (OwnerSettingProblems): each side of the colon a whole placeholder
|
||||
// or a whole number, and every key one the module itself asks for elsewhere — in a file, a unit, a
|
||||
// contribution or a served fact — or declares a default for, so the owner follows the identity its
|
||||
// process is told it runs as, never a key that reaches nothing else.
|
||||
|
||||
// ownerSide is one side of an owner a setting may fill: a whole placeholder or a whole number.
|
||||
var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)`
|
||||
|
||||
// ownerWithSetting is the only shape an owner holding a setting may have: user, or user:group.
|
||||
var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`)
|
||||
|
||||
// wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id"
|
||||
// (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys).
|
||||
func wholeID(v string) (uint64, bool) {
|
||||
if v == "" || strings.TrimLeft(v, "0123456789") != "" {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
return n, err == nil && n < 1<<32-1
|
||||
}
|
||||
|
||||
// OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting
|
||||
// in any shape but a whole placeholder or number on each side of the colon, or names a key the module
|
||||
// asks for nowhere else and declares no default for.
|
||||
func OwnerSettingProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
asked := settingKeysAskedOutsideOwners(m)
|
||||
for k := range m.Settings {
|
||||
asked[k] = true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
owner, _ := r["owner"].(string)
|
||||
if !strings.Contains(owner, "${setting:") {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
// Said by the placeholder sweep: only a directory's owner takes a setting.
|
||||
continue
|
||||
}
|
||||
if !ownerWithSetting.MatchString(owner) {
|
||||
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+
|
||||
"the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+
|
||||
"${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)",
|
||||
m.Module, r["id"], owner))
|
||||
continue
|
||||
}
|
||||
for _, key := range settingsUsed(owner) {
|
||||
if !asked[key] {
|
||||
problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+
|
||||
"module asks for %q nowhere else and declares no default for it: an owner follows the identity "+
|
||||
"the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+
|
||||
"own (novox/hq issue 279)", m.Module, r["id"], key, key, key))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// settingIntoOwner fills ${setting:…} in a directory's owner, refusing — with the key and why, the
|
||||
// resource left as it was — a key nothing sets and a value that is not a whole number.
|
||||
func settingIntoOwner(resource map[string]any, layers []Layer, module string) error {
|
||||
owner, ok := resource["owner"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, key := range settingsUsed(owner) {
|
||||
value, set := settingValue(layers, key)
|
||||
if !set {
|
||||
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
|
||||
"%s has a directory %v whose owner says ${setting:%s}, and nothing sets %q for it — an operator's "+
|
||||
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
|
||||
"`settings set %s <file>` with {%q: …}%s",
|
||||
module, resource["id"], key, key, module, key, orNoSettings(layers))}
|
||||
}
|
||||
v := plainly(value)
|
||||
id, whole := wholeID(v)
|
||||
if !whole {
|
||||
return fmt.Errorf("%s: the setting %q is %q, which cannot own the directory %v: an owner a setting "+
|
||||
"names is a uid or a gid, a whole number, never an account's name or other text (novox/hq issue 279)",
|
||||
module, key, v, resource["id"])
|
||||
}
|
||||
// Written as the number parsed, so "0001" reaches the node-engine as 1, never as text.
|
||||
owner = strings.ReplaceAll(owner, "${setting:"+key+"}", strconv.FormatUint(id, 10))
|
||||
}
|
||||
resource["owner"] = owner
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingValue(layers []Layer, key string) (any, bool) {
|
||||
var value any
|
||||
set := false
|
||||
@@ -171,9 +277,25 @@ func orNoSettings(layers []Layer) string {
|
||||
return "; set today: " + strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// settingKeysUsedBy is every key a module's files, contributions and served facts ask for, so a
|
||||
// setting that lands in one is not called stray.
|
||||
// settingKeysUsedBy is every key a module's files, units, directory owners, contributions and served
|
||||
// facts ask for, so a setting that lands in one is not called stray.
|
||||
func settingKeysUsedBy(m Manifest) map[string]bool {
|
||||
used := settingKeysAskedOutsideOwners(m)
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
if owner, ok := r["owner"].(string); ok {
|
||||
for _, k := range settingsUsed(owner) {
|
||||
used[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// settingKeysAskedOutsideOwners is every key a module's files, units, contributions and served facts
|
||||
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
||||
func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
||||
used := map[string]bool{}
|
||||
note := func(s string) {
|
||||
for _, k := range settingsUsed(s) {
|
||||
|
||||
@@ -509,6 +509,18 @@ var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
|
||||
// and never costs a module its place.
|
||||
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
||||
if err := UndeclaredAccessPlaces(m, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
return ComposesWith(m, layers, adopted)
|
||||
}
|
||||
|
||||
// ComposesWith is JudgeSettings for a layer stored ahead of the definition that reads it (novox/hq issue 575): an
|
||||
// access placed that this definition does not declare yet is not refused, because composition passes it by — it
|
||||
// reaches nothing until the build that declares it registers, and then it places that access. Where a machine is
|
||||
// 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 {
|
||||
if err := settingsHoldOneLine(m.Module, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ import (
|
||||
// 4. **A module's whole layer, when it has a mergeable file not marked `"trusted": false`** (novox/hq issue 340,
|
||||
// TrustedMergeable). A mergeable file takes any key a layer sets, not only those its content names, so no list
|
||||
// of keys covers it: an empty runtime configuration a provider reads would take a `url` of the caller's.
|
||||
// 5. **Every setting a directory's owner asks for** (novox/hq issue 279): root chowns the directory to the number
|
||||
// at the next push, so through a verb any caller could hand itself — or uid 0 — a module's folder. Whatever
|
||||
// else asks for the key, a file marked `"trusted": false` included, the owner makes it the terminal's.
|
||||
//
|
||||
// Derived from the manifest, never listed by hand, so a provider or a trusted file added tomorrow is covered.
|
||||
|
||||
@@ -42,8 +45,8 @@ import (
|
||||
const TrustedField = "trusted"
|
||||
|
||||
// TerminalKeys are the settings keys of a module that are set at the controller's terminal alone: places and
|
||||
// accesses, every key its provisions serve and every setting a served value asks for, and every setting a file
|
||||
// asks for unless it says `"trusted": false`. Places and accesses first, then the rest sorted.
|
||||
// accesses, every key its provisions serve and every setting a served value asks for, every setting a file
|
||||
// asks for unless it says `"trusted": false`, and every setting a directory's owner asks for. Places and accesses first, then the rest sorted.
|
||||
func TerminalKeys(m Manifest) []string {
|
||||
keys := map[string]bool{}
|
||||
for _, served := range m.Serves {
|
||||
@@ -57,6 +60,13 @@ func TerminalKeys(m Manifest) []string {
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
owner, _ := r["owner"].(string)
|
||||
for _, asked := range settingsUsed(owner) {
|
||||
keys[asked] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ type filler struct {
|
||||
// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here,
|
||||
// or the sweep refuses the placeholder it would have filled.
|
||||
var fillers = []filler{
|
||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}}},
|
||||
{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"}}},
|
||||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
||||
|
||||
@@ -958,7 +958,28 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
|
||||
}
|
||||
layers = append(layers, catalogue.Layer{From: from, Values: values})
|
||||
if err := catalogue.JudgeSettings(m, layers, adopted); err != nil {
|
||||
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err)
|
||||
// **A placement for an access the module's newer build brings** (novox/hq issue 575): judged against the
|
||||
// manifest of the module's newest build on the branch it follows, where the registered one does not declare
|
||||
// the access yet — so an access can be placed before or with the delivery that adds it. It must still compose
|
||||
// with the registered manifest, which passes the placement by until the build registers.
|
||||
var undeclared *catalogue.UndeclaredAccessError
|
||||
if !errors.As(err, &undeclared) {
|
||||
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err)
|
||||
}
|
||||
ahead, has, aerr := i.NewestManifestOnBranch(ctx, module)
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
if !has || catalogue.JudgeSettings(ahead, layers, adopted) != nil || catalogue.ComposesWith(m, layers, adopted) != nil {
|
||||
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w; nor does the newest build of "+
|
||||
"%s on the branch it follows declare it", module, where, from, err, module)
|
||||
}
|
||||
m = ahead
|
||||
}
|
||||
// A module that runs as its own identity owns its directories by it: no setting names their owner (novox/hq ADR
|
||||
// 0325 §1, §3). A module that does not yet keeps its placed owner, warned of where it is set.
|
||||
if err := catalogue.PlacesOwnerProblem(m, layers); err != nil {
|
||||
return fmt.Errorf("refused: %w", err)
|
||||
}
|
||||
// And a key that reaches nothing, refused here where somebody can still fix the spelling:
|
||||
// stored, it would be a setting somebody believes they made.
|
||||
@@ -969,6 +990,29 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewestManifestOnBranch is the manifest of a module's newest successful build on the branch its record follows
|
||||
// (novox/hq issue 575): what the delivery under way brings, before it registers — or the registered one's own, when
|
||||
// nothing newer was built. False when no build of the module with a manifest is kept.
|
||||
func (i *Inventory) NewestManifestOnBranch(ctx context.Context, module string) (catalogue.Manifest, bool, error) {
|
||||
var raw []byte
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select b.manifest from build b join module m on m.name = b.module
|
||||
where b.module = $1 and b.failed = '' and b.manifest is not null and b.manifest::text <> 'null'
|
||||
and (coalesce(m.ref, '') = '' or b.ref = '' or b.ref = m.ref)
|
||||
order by b.at desc limit 1`, module).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return catalogue.Manifest{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false, err
|
||||
}
|
||||
m, err := catalogue.ParseManifest(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false, nil // a build the mesh cannot read judges nothing
|
||||
}
|
||||
return m, true, nil
|
||||
}
|
||||
|
||||
// givenIn is the machine ports a node-level settings layer gives a module, software port →
|
||||
// machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left
|
||||
// for composition to refuse in its own words.
|
||||
|
||||
@@ -36,15 +36,37 @@ 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"`
|
||||
}
|
||||
|
||||
// Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret and Setting
|
||||
// (novox/hq ADR 0283), as link.Wait carries it.
|
||||
// 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
|
||||
// (novox/hq ADR 0283, ADR 0325 §7), as link.Wait carries it.
|
||||
type Wait struct {
|
||||
Part string `json:"part"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Setting string `json:"setting,omitempty"`
|
||||
What string `json:"what"`
|
||||
// Access is an access of the module by its own id for it, from the engine's access-waits, and Disk that it
|
||||
// waits for the disk that holds its folder rather than for the operator's word (novox/hq ADR 0325 §7, §9).
|
||||
Access string `json:"access,omitempty"`
|
||||
Disk bool `json:"disk,omitempty"`
|
||||
// Placed is the filesystem the folder is on now, without its path (ADR 0325 §8): a warrant for another does not
|
||||
// answer the wait.
|
||||
Placed string `json:"placed,omitempty"`
|
||||
// Reason is the engine's reason for an access wait ("keep" or "disk"), ACL what its folder's filesystem says of
|
||||
// access entries ("needs-enabling", "unsupported", or empty when it allows them).
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
// NodeHealth is a machine's newest statement, as kept.
|
||||
|
||||
@@ -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,170 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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")
|
||||
}
|
||||
}
|
||||
+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)
|
||||
);
|
||||
@@ -325,6 +325,8 @@ func (i *Inventory) ModuleSecretIfIssued(
|
||||
return sealed, true, nil
|
||||
}
|
||||
|
||||
// SecretForModule makes what is not held, so it is never asked for an own secret issued outside the mesh: the
|
||||
// composition reads only a value a person gave for one (GivenOwnSecret; novox/hq issue 401, ADR 0228).
|
||||
func (i *Inventory) SecretForModule(ctx context.Context, node, module, name string) (string, error) {
|
||||
key, err := i.SealingKeyOf(ctx, node)
|
||||
if err != nil {
|
||||
@@ -655,6 +657,35 @@ func (i *Inventory) GivenMembers(ctx context.Context, node, module, family strin
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GivenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (novox/hq issue 401, ADR
|
||||
// 0228): its value sealed to the machine and whether that is the key the machine holds now. Not given is false: a
|
||||
// value the mesh made for it is not one a person gave, and is never read as one; a machine with no sealing key
|
||||
// holds none either.
|
||||
func (i *Inventory) GivenOwnSecret(ctx context.Context, node, module, name string) (GivenMember, bool, error) {
|
||||
key, err := i.SealingKeyOf(ctx, node)
|
||||
if err != nil || key == "" {
|
||||
return GivenMember{}, false, err
|
||||
}
|
||||
record, err := i.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return GivenMember{}, false, err
|
||||
}
|
||||
g := GivenMember{Name: name}
|
||||
var against string
|
||||
err = i.store.Pool().QueryRow(ctx,
|
||||
`select sealed, node_key from module_secret
|
||||
where node = $1 and module = $2 and name = $3 and origin = 'accepted'`,
|
||||
record.ID, module, name).Scan(&g.Sealed, &against)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GivenMember{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return GivenMember{}, false, err
|
||||
}
|
||||
g.Current = against == key
|
||||
return g, true, nil
|
||||
}
|
||||
|
||||
// GivenOwnSecrets is every own secret of a module a person gave on a machine, by name, with when (novox/hq ADR
|
||||
// 0283): what the controller checks a module's wait for a secret against. A value the mesh made is not one.
|
||||
func (i *Inventory) GivenOwnSecrets(ctx context.Context, node, module string) (map[string]time.Time, error) {
|
||||
|
||||
@@ -138,6 +138,9 @@ type GroupMember struct {
|
||||
Repository string `json:"repository"`
|
||||
Ref string `json:"ref"`
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
// Base is the branch it merges into: the build seat merges the head into that trunk's head before the
|
||||
// group is composed (novox/hq ADR 0323 §6). A build seat from before it composes the head alone.
|
||||
Base string `json:"base,omitempty"`
|
||||
}
|
||||
|
||||
// Who judges a merge check's gate.
|
||||
@@ -164,6 +167,14 @@ type CheckOutcome struct {
|
||||
// Gate and RepoCheck are its two layers; Verdict and Summary above are the gate's.
|
||||
Gate *CheckLayer `json:"gate,omitempty"`
|
||||
RepoCheck *CheckLayer `json:"repo-check,omitempty"`
|
||||
// Head and Trunk are what was judged (novox/hq ADR 0323 §3): the pull request's head merged into the trunk
|
||||
// commit, each as twelve characters. Trunk is empty from a build seat from before it, which judged the head
|
||||
// alone. Beside are the commits of the repositories read beside it, by directory, named and not judged
|
||||
// against (issue 568); Members a delivery group's other heads, each with the trunk commit it merged into.
|
||||
Head string `json:"head,omitempty"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
Members []CheckedMember `json:"members,omitempty"`
|
||||
}
|
||||
|
||||
// SourceOnSeat names a repository by the seat whose holder serves it and its path there.
|
||||
|
||||
+25
-11
@@ -221,13 +221,17 @@ type SourceMoved struct {
|
||||
// PullUpdated is what the forge announces when an open pull request's head moves — opened, or pushed
|
||||
// to (novox/hq to-be 45 §9): what the controller asks the build seat to check before it merges.
|
||||
type PullUpdated struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Base string `json:"base"`
|
||||
Head string `json:"head"`
|
||||
Commit string `json:"head_sha"`
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Base string `json:"base"`
|
||||
Head string `json:"head"`
|
||||
Commit string `json:"head_sha"`
|
||||
// BaseSHA is the base branch's head when the forge announced the head (novox/hq ADR 0323): the trunk commit a
|
||||
// verdict the controller gives without the build seat — a change that runs no build — is judged against. Empty
|
||||
// from a forge module from before it.
|
||||
BaseSHA string `json:"base_sha,omitempty"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
// Paths are the files the pull request changes; PathsTruncated says there were more.
|
||||
@@ -278,14 +282,24 @@ type Checked struct {
|
||||
// status from it — the group's verdict is its owner's, mesh-delivery, to say on each member's head.
|
||||
Group string `json:"group,omitempty"`
|
||||
Members []CheckedMember `json:"members,omitempty"`
|
||||
// Trunk is the trunk commit the head was merged into and judged as, twelve characters (novox/hq ADR 0323
|
||||
// §3): the verdict holds for that merge only, and goes out of date when the trunk moves. Empty from a build
|
||||
// seat that judged the head alone. Beside are the commits of the other repositories the check read, by
|
||||
// directory, named and not judged against (issue 568). Took is how long the check ran, for the merge tool
|
||||
// to say how long the next one may take (ADR 0323 §5).
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
Beside map[string]string `json:"beside,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
}
|
||||
|
||||
// CheckedMember is one head a group's composed check judged.
|
||||
// CheckedMember is one head a group's composed check judged, and the trunk commit it was merged into (novox/hq
|
||||
// ADR 0323 §6), twelve characters; empty from a build seat that composed the head alone.
|
||||
type CheckedMember struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}
|
||||
|
||||
// ChangePlan is what a change does to the mesh, computed from its diffset — a repository, the branch it
|
||||
@@ -311,9 +325,9 @@ 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"`
|
||||
}
|
||||
|
||||
// MachinePlan is one machine's part of a change plan.
|
||||
|
||||
@@ -8,6 +8,7 @@ package link
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/novox/mesh-host/rootsearch"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -436,6 +437,14 @@ const ReadinessContract = 2
|
||||
// parses strictly and would refuse the whole declaration for it.
|
||||
const RootContract = 3
|
||||
|
||||
// IdentityContract is the statement of an engine that gives each module its identity and keeps shared folders
|
||||
// (novox/hq ADR 0325; mesh-host internal/link IdentityContract): it reads a declaration's `identities`, a container's
|
||||
// resolved `identity`, an access's `users` and `taken`, states an access it may not keep yet as waiting
|
||||
// (ResourceHealth.AccessWaits), and hears the take act (AskAccessAct). Only to such an engine is any of it sent: an
|
||||
// older one parses strictly and would refuse the whole declaration for it. Four, the grant's number (ADR 0312,
|
||||
// superseded), is what engines stated before; the controller sends no grant to any engine (#270).
|
||||
const IdentityContract = 5
|
||||
|
||||
// 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"
|
||||
@@ -580,6 +589,57 @@ type Wait struct {
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
// WaitKindAccess is the kind of a wait on an access (novox/hq ADR 0325 §7; mesh-host internal/link).
|
||||
const WaitKindAccess = "access"
|
||||
|
||||
// AccessWait is one access waiting for the operator (novox/hq ADR 0325 §7): the access by its module's own id for
|
||||
// it, the part (its module), and what the operator gives, in words with no path — "let the mesh keep the shared
|
||||
// folder movies", or "mount the disk that holds books". Beside Waits because mesh-sdk's health.Wait names a secret or
|
||||
// a setting and nothing else. From an engine at IdentityContract or later.
|
||||
type AccessWait struct {
|
||||
Kind string `json:"kind"`
|
||||
Access string `json:"access"`
|
||||
Part string `json:"part"`
|
||||
What string `json:"what"`
|
||||
// Placed names, with no path, the filesystem the folder is on now ("fs-<12 hex>"), on a wait for the operator's
|
||||
// word: a warrant given for another filesystem (the folder moved) does not answer it, and the controller asks
|
||||
// again (mesh-host internal/link AccessWait.Placed).
|
||||
Placed string `json:"placed,omitempty"`
|
||||
// Reason is what the wait is for: WaitKeep (the operator's word to keep the folder) or WaitDisk (its disk
|
||||
// mounted). ACL is said on a keep wait whose filesystem refuses access entries now: ACLNeedsEnabling when the take
|
||||
// act will let it, ACLUnsupported when no act can. Absent when it allows them.
|
||||
Reason string `json:"reason"`
|
||||
ACL string `json:"acl,omitempty"`
|
||||
}
|
||||
|
||||
// The reasons of an access wait, and what its filesystem says of ACLs (mesh-host internal/link).
|
||||
const (
|
||||
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
|
||||
// has its right, waiting while the mesh may not keep its folder yet or its disk is not there, unhealthy otherwise —
|
||||
// and unknown when its filesystem could not be named this look (a hung mount), carrying no wait.
|
||||
const KindAccess = "access"
|
||||
|
||||
// KindKeptEntries is the mesh's entries on a shared folder that nobody wants and that could not be taken out yet
|
||||
// because its disk was away (novox/hq ADR 0325 §8; mesh-host internal/link): unhealthy, said under the access that
|
||||
// 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"`
|
||||
@@ -606,6 +666,11 @@ type ResourceHealth struct {
|
||||
// Waits is what a resource in StateWaiting waits for (novox/hq ADR 0283). Empty otherwise, and from an
|
||||
// engine older than that.
|
||||
Waits []Wait `json:"waits,omitempty"`
|
||||
// AccessWaits is what an access stated waiting waits for (novox/hq ADR 0325 §7); Act the last walk under its
|
||||
// folder, kept as the engine said it; Entries the kept folder's listing. From an engine at IdentityContract only.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// The take act: a shared folder that already holds data is taken by the mesh once, on the operator's word
|
||||
// (novox/hq ADR 0325 §8).
|
||||
//
|
||||
// The controller asks the operator once for that folder on that machine (ADR 0259, at the level approve). On the
|
||||
// warrant it asks the machine's node-engine, on the engine's own subject (broker.AskAccessActSubject), signed with
|
||||
// the mesh's key as a hand-over is (SignedHandOver) under a signing context of its own (AccessActContext), to take
|
||||
// the folder: the engine adds the entries of the users it was declared for that folder to everything under it —
|
||||
// following no link, entering no other filesystem — answers at once that it started or why it refused, and states
|
||||
// the walk on the access's verdict. **Nothing is taken without a warrant**: the ask names the warrant the operator
|
||||
// gave, and the controller sends it only from the warrant (cmd/mesh-controller decideTake).
|
||||
//
|
||||
// The engine's own shape (mesh-host internal/link AccessActAsk, AnswerAccessAct): a hand-over's fields and
|
||||
// {act: "take", access: <the access's resource id in the declaration>, warrant}.
|
||||
|
||||
// AccessActContext is prefixed to an act ask's bytes before signing: never another ask's signature, nor a
|
||||
// declaration's. The engine holds the same words.
|
||||
const AccessActContext = "novox-mesh access-act v2\n"
|
||||
|
||||
// ActTake is the one act the controller asks.
|
||||
const ActTake = "take"
|
||||
|
||||
// AccessActAsk is what the controller asks: a hand-over's fields — the node it is for, the folder's absolute path as
|
||||
// the declaration places it, who asked, when the ask stops being good and a nonce the engine takes once — and the
|
||||
// act's own: which act, the access by its resource id, and the warrant the operator gave.
|
||||
type AccessActAsk struct {
|
||||
Node string `json:"node"`
|
||||
Path string `json:"path"`
|
||||
By string `json:"by"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Nonce string `json:"nonce"`
|
||||
Act string `json:"act"`
|
||||
Access string `json:"access"`
|
||||
Warrant string `json:"warrant"`
|
||||
}
|
||||
|
||||
// MaxActField bounds an access id and a warrant, as the engine does.
|
||||
const MaxActField = 200
|
||||
|
||||
// TakeWithin is how long the controller waits for the engine's answer: it starts the walk and answers, and the
|
||||
// walk's end is stated on the access's verdict.
|
||||
const TakeWithin = 30 * time.Second
|
||||
|
||||
// SignTake fills a take ask's act, expiry and nonce and signs it with the mesh's key over AccessActContext. It refuses
|
||||
// an ask with no warrant, no path or no access — and one the engine would refuse for its shape: nothing is ever asked
|
||||
// of an engine that it would have to refuse.
|
||||
func SignTake(ctx context.Context, signer Signer, ask AccessActAsk) ([]byte, error) {
|
||||
ask.Access, ask.Warrant = strings.TrimSpace(ask.Access), strings.TrimSpace(ask.Warrant)
|
||||
switch {
|
||||
case signer == nil:
|
||||
return nil, errors.New("no signing key, so nothing can be asked of an engine")
|
||||
case ask.Warrant == "":
|
||||
return nil, errors.New("a take is asked only on the operator's warrant, and this one names none")
|
||||
case ask.Node == "" || !strings.HasPrefix(ask.Path, "/"):
|
||||
return nil, errors.New("a take names its machine and the folder's absolute path")
|
||||
case ask.Access == "":
|
||||
return nil, errors.New("a take names the access whose folder it takes")
|
||||
}
|
||||
// The warrant is one short name; the access is the resource id the declaration gave it, which may hold a path's
|
||||
// slashes (catalogue.AccessID) — the engine judges it against what it was declared.
|
||||
if len(ask.Warrant) > MaxActField || strings.ContainsAny(ask.Warrant, "\r\n\x00/") {
|
||||
return nil, fmt.Errorf("the take's warrant %q is not one short name", ask.Warrant)
|
||||
}
|
||||
if len(ask.Access) > MaxActField || strings.ContainsAny(ask.Access, "\r\n\x00") {
|
||||
return nil, fmt.Errorf("the take's access %q is not one line", ask.Access)
|
||||
}
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ask.Act, ask.Nonce, ask.Expires = ActTake, hex.EncodeToString(nonce), time.Now().UTC().Add(HandOverGood)
|
||||
raw, err := json.Marshal(ask)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signature, err := signer.Sign(ctx, append([]byte(AccessActContext), raw...))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot sign the ask: %w", err)
|
||||
}
|
||||
return json.Marshal(SignedHandOver{Ask: raw, Signature: signature})
|
||||
}
|
||||
|
||||
// AskTake asks one machine's node-engine to take a shared folder on the operator's warrant, and reads its answer. An
|
||||
// error is the ask not reaching an engine, or an answer that is not one; a refusal is the engine's and comes back in
|
||||
// the answer.
|
||||
func AskTake(ctx context.Context, conn *nats.Conn, signer Signer, ask AccessActAsk, timeout time.Duration) (HandOverAnswer, error) {
|
||||
if conn == nil {
|
||||
return HandOverAnswer{}, errors.New("this controller is not on the bus")
|
||||
}
|
||||
body, err := SignTake(ctx, signer, ask)
|
||||
if err != nil {
|
||||
return HandOverAnswer{}, err
|
||||
}
|
||||
return askSigned(ctx, conn, broker.AskAccessActSubject(ask.Node), body, ask.Node, timeout, askWords{
|
||||
what: "the take of a shared folder", nothing: "nothing was taken", issue: "ADR 0325",
|
||||
unknown: "whether it started is not known — the access's verdict on the machine says whether it is kept",
|
||||
record: "a start"})
|
||||
}
|
||||
Vendored
+1
-1
@@ -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 a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock
|
||||
mesh-host 5c8e768ef52001624abe0425e05eb356ca337584 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:
|
||||
|
||||
@@ -523,6 +523,20 @@ func refusedPlaces(resources []declaration.Resource) map[string]error {
|
||||
switch res := r.(type) {
|
||||
case *declaration.Directory:
|
||||
err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner)
|
||||
// And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a
|
||||
// link another account could make in its path refuses it, and with it what is below it, whatever
|
||||
// order they are declared in. The apply walks again at the moment of use. A directory handed over
|
||||
// keeps its own, stricter walk (issue 356), which refuses any link at all.
|
||||
if err == nil && meshDirsByDescriptor && homeAbove(res.Path) == "" &&
|
||||
!HandedOver()[handOverKey(res.ID, res.Path)] {
|
||||
if judged := judgeMeshDir(res.Path); judged != nil {
|
||||
err = judged
|
||||
var link *LinkInPathError
|
||||
if errors.As(judged, &link) {
|
||||
err = &PlacementRefusedError{Path: res.Path, Why: "it " + link.why()}
|
||||
}
|
||||
}
|
||||
}
|
||||
case *declaration.Access:
|
||||
err = refusePath(res.Path, placingAccess, moduleOfID(res.ID), "")
|
||||
default:
|
||||
|
||||
Vendored
+8
@@ -26,3 +26,11 @@
|
||||
./cmd/mesh-controller TestAnUnassignThatLeavesItsNodeUnresolvedSaysSoOnceInItsAnswer
|
||||
./cmd/mesh-controller TestAnUnassignThatLeavesItsNodeResolvedSaysNothingOfIt
|
||||
./cmd/mesh-controller TestAnAssignThatLeavesItsNodeUnresolvedSaysOnlyThePlansRefusal
|
||||
./cmd/mesh-controller TestADirectoryOwnersKeyIsRefusedThroughAVerb
|
||||
./cmd/mesh-controller TestTheSendMakesNoValueForAnOutsideSecretNobodyGave
|
||||
./cmd/mesh-controller TestAQuestionComposesWithoutAnOutsideSecretNobodyGave
|
||||
# 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
|
||||
|
||||
+208
-4
@@ -456,14 +456,56 @@ 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, reconcile or remove it. The
|
||||
// host confirms it is there and mounts it; nothing else.
|
||||
// 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"`
|
||||
Path string `json:"path"`
|
||||
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// 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 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).
|
||||
Grant json.RawMessage `json:"grant,omitempty"`
|
||||
}
|
||||
|
||||
// The uses an access's user has (ADR 0051 §3), and where it runs (ADR 0325 §4).
|
||||
const (
|
||||
FromContainer = "container"
|
||||
FromToolRunner = "tool-runner"
|
||||
)
|
||||
|
||||
// AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its
|
||||
// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name.
|
||||
type AccessUser struct {
|
||||
Identity string `json:"identity"`
|
||||
UID int `json:"uid,omitempty"`
|
||||
Use string `json:"use"`
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a user as strictly as a resource is read.
|
||||
func (u *AccessUser) UnmarshalJSON(raw []byte) error {
|
||||
type plain AccessUser
|
||||
var p plain
|
||||
if err := strictDecode(raw, &p); err != nil {
|
||||
return fmt.Errorf("an access's user: %w", err)
|
||||
}
|
||||
*u = AccessUser(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Access) Identity() string { return a.ID }
|
||||
@@ -482,9 +524,43 @@ func (a *Access) validate(where string, _ bool) []string {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode))
|
||||
}
|
||||
for _, u := range a.Users {
|
||||
switch {
|
||||
case u.Identity == "" || !accountName.MatchString(u.Identity):
|
||||
problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one",
|
||||
where, u.Identity))
|
||||
case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner):
|
||||
// Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a
|
||||
// number below it is no account. A tool runner's account alone may come without its number: the
|
||||
// controller cannot know the operator's account's, and the engine reads it by name
|
||||
// (apply.ResolveRunners), refusing one that is root's.
|
||||
problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+
|
||||
"root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID))
|
||||
}
|
||||
if u.Use != AccessRead && u.Use != AccessReadWrite {
|
||||
problems = append(problems, fmt.Sprintf("%s: the user %s uses it %q, and a use is %q or %q", where,
|
||||
u.Identity, u.Use, AccessRead, AccessReadWrite))
|
||||
}
|
||||
if u.Use == AccessReadWrite && a.Mode != AccessReadWrite {
|
||||
problems = append(problems, fmt.Sprintf("%s: the user %s writes an access declared %s", where,
|
||||
u.Identity, orRead(a.Mode)))
|
||||
}
|
||||
if u.From != FromContainer && u.From != FromToolRunner {
|
||||
problems = append(problems, fmt.Sprintf("%s: the user %s runs from %q, and that is %q or %q", where,
|
||||
u.Identity, u.From, FromContainer, FromToolRunner))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// orRead is an access's mode as said: absent narrows to read.
|
||||
func orRead(mode string) string {
|
||||
if mode == "" {
|
||||
return AccessRead
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func (u *User) Identity() string { return u.ID }
|
||||
func (u *User) Kind() Type { return TypeUser }
|
||||
func (u *User) Target() string { return u.Name }
|
||||
@@ -1160,6 +1236,78 @@ type Container struct {
|
||||
// Health is how this resource is ready (novox/hq ADR 0240 rule 2, Phase B): one kind and its
|
||||
// timing, judged by the node-engine beside liveness. Absent: judged alive or not, and nothing more.
|
||||
Health *Health `json:"health,omitempty"`
|
||||
|
||||
// RunAs is who the container runs as, resolved by the controller from the module's identity and the
|
||||
// image's declared interface (novox/hq ADR 0325 §2, §3): told by environment (the image reads PUID and
|
||||
// PGID, say) or by the runtime's own user option. Part of the spec, so a change recreates it. Absent: the
|
||||
// container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because
|
||||
// a resource's Identity is its id.
|
||||
RunAs *ContainerIdentity `json:"identity,omitempty"`
|
||||
}
|
||||
|
||||
// The ways an image is told who to run as (novox/hq ADR 0325 §2).
|
||||
const (
|
||||
IdentityByEnv = "env"
|
||||
IdentityByRun = "run"
|
||||
)
|
||||
|
||||
// ContainerIdentity is who a container runs as, and how its image is told.
|
||||
type ContainerIdentity struct {
|
||||
// Mode is IdentityByEnv or IdentityByRun.
|
||||
Mode string `json:"mode"`
|
||||
// UserEnv and GroupEnv are the variables the image reads the user's and the group's number from, for
|
||||
// IdentityByEnv: "PUID", "PGID".
|
||||
UserEnv string `json:"user_env,omitempty"`
|
||||
GroupEnv string `json:"group_env,omitempty"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads an identity as strictly as a resource is read.
|
||||
func (i *ContainerIdentity) UnmarshalJSON(raw []byte) error {
|
||||
type plain ContainerIdentity
|
||||
var p plain
|
||||
if err := strictDecode(raw, &p); err != nil {
|
||||
return fmt.Errorf("a container's identity: %w", err)
|
||||
}
|
||||
*i = ContainerIdentity(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// envName is a variable's name as a runtime takes it.
|
||||
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
func (i *ContainerIdentity) problems(where string, env map[string]string) []string {
|
||||
if i == nil {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
switch i.Mode {
|
||||
case IdentityByEnv:
|
||||
if !envName.MatchString(i.UserEnv) || (i.GroupEnv != "" && !envName.MatchString(i.GroupEnv)) {
|
||||
problems = append(problems, fmt.Sprintf("%s: an identity told by environment names the variables "+
|
||||
"it is told in, and %q and %q are not both names", where, i.UserEnv, i.GroupEnv))
|
||||
}
|
||||
for _, name := range []string{i.UserEnv, i.GroupEnv} {
|
||||
if _, set := env[name]; set && name != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: env sets %s, which the identity sets; one value "+
|
||||
"for one variable", where, name))
|
||||
}
|
||||
}
|
||||
case IdentityByRun:
|
||||
if i.UserEnv != "" || i.GroupEnv != "" {
|
||||
problems = append(problems, where+": an identity told by the runtime's user option names no variable")
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("%s: an identity is told by %q or %q, not %q", where,
|
||||
IdentityByEnv, IdentityByRun, i.Mode))
|
||||
}
|
||||
// Never root (novox/hq ADR 0325 §11): refused here, so nothing of the declaration is made or started.
|
||||
if i.UID <= 0 || i.GID <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s: the identity is uid %d, gid %d, and a module's identity is "+
|
||||
"never root, nor a number below it (novox/hq ADR 0325 §11)", where, i.UID, i.GID))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func (c *Container) Identity() string { return c.ID }
|
||||
@@ -1242,6 +1390,7 @@ func (c *Container) validate(where string, _ bool) []string {
|
||||
problems = append(problems, where+": networks is for a container that keeps running; a step "+
|
||||
"runs and exits, and joins nothing afterwards")
|
||||
}
|
||||
problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...)
|
||||
return append(problems, checkImage(where, c.Image)...)
|
||||
}
|
||||
|
||||
@@ -1392,6 +1541,53 @@ type Declaration struct {
|
||||
// the generation of what it puts back, which may be older than what the machine applied, and is
|
||||
// not refused for it.
|
||||
PutBack bool
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// IdentityPrefix starts every module identity's name.
|
||||
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) []string {
|
||||
var problems []string
|
||||
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) {
|
||||
problems = append(problems, fmt.Sprintf("%s: a module's identity is named %s<module>, and this one is "+
|
||||
"for the module %q", where, IdentityPrefix, id.Module))
|
||||
}
|
||||
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 0325 §11); nothing of this declaration was made", where,
|
||||
id.UID, id.GID))
|
||||
}
|
||||
if names[id.Name] {
|
||||
problems = append(problems, where+": declared twice")
|
||||
}
|
||||
if other, used := uids[id.UID]; used {
|
||||
problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other))
|
||||
}
|
||||
if other, used := gids[id.GID]; used {
|
||||
problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other))
|
||||
}
|
||||
names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the
|
||||
@@ -1561,6 +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). **An older host refuses this key**; a
|
||||
// controller sends it only to a host whose health says link.IdentityContract.
|
||||
Identities []Identity `json:"identities,omitempty"`
|
||||
}
|
||||
|
||||
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
@@ -1578,8 +1777,9 @@ 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}
|
||||
var problems []string
|
||||
Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack,
|
||||
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 "+
|
||||
@@ -1732,6 +1932,10 @@ func checkWhileStopped(resources []Resource) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// accountName is what an identity's or an access user's name may be: the portable account name the user tools
|
||||
// take, optionally ending in `$`, never starting with a dash — it is handed to them as an argument.
|
||||
var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`)
|
||||
|
||||
func strictDecode(raw []byte, into any) error {
|
||||
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
|
||||
// field the host does not know is a thing the control plane believes it asked for.
|
||||
|
||||
Vendored
+2
-2
@@ -78,7 +78,7 @@ 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-20261011092230-20d5af9b2d5f
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012175109-5c8e768ef520
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/rootsearch
|
||||
@@ -135,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-20261011092230-20d5af9b2d5f
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175109-5c8e768ef520
|
||||
|
||||
Reference in New Issue
Block a user