Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b9908bed2 | ||
|
|
1de9bbc448 | ||
|
|
96e15f8693 | ||
|
|
7ea6d36eab | ||
|
|
f634e33792 | ||
|
|
fa0a5f1c88 | ||
|
|
c0c379af2b | ||
|
|
daf52b3aeb | ||
|
|
4dbd4066ca | ||
|
|
84deb530ee | ||
|
|
b3787e4d8e | ||
|
|
a681227af3 | ||
|
|
d8f36a8fb7 | ||
|
|
f0fd7fe886 | ||
|
|
41a9ff15dd | ||
|
|
ba685b17f5 | ||
|
|
d170491256 | ||
|
|
6bb1c9b768 | ||
|
|
f8daec01d3 | ||
|
|
f3c15e00af | ||
|
|
9038d4b0d3 | ||
|
|
f10aedac58 | ||
|
|
875ebc093e | ||
|
|
1e28342ede | ||
|
|
bc623bc1f9 | ||
|
|
ce9dc8e157 | ||
|
|
c672b3df6d | ||
|
|
25deb499bf | ||
|
|
83a9cf6e3a | ||
|
|
7b050abdcb | ||
|
|
c3b4f187e1 | ||
|
|
edd33f21d2 | ||
|
|
24347ea5a0 | ||
|
|
5ad36127ee | ||
|
|
a1a6f6493d | ||
|
|
e9f060e0be | ||
|
|
3cfc121670 | ||
|
|
a3bf5da9b1 | ||
|
|
d20b52cb19 | ||
|
|
7ca5a45ecc | ||
|
|
db7e4869ae | ||
|
|
539a1c16cd | ||
|
|
9eddf36503 | ||
|
|
05eccbf22b | ||
|
|
01fc9e1d73 | ||
|
|
d4de4543cc | ||
|
|
a371de37fe | ||
|
|
77acb40092 | ||
|
|
5facd8b08e | ||
|
|
72edb458cc | ||
|
|
10f76cafc4 | ||
|
|
2843834813 | ||
|
|
3e10ad1535 | ||
|
|
27b13e4586 | ||
|
|
142a5ad71d | ||
|
|
63a99d4323 | ||
|
|
0cd8bc0bfb | ||
|
|
7385d9c092 | ||
|
|
4ead5a3df7 | ||
|
|
3be6e86f77 | ||
|
|
b0c4b849a7 | ||
|
|
b849af53dd | ||
|
|
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
|
||||
}
|
||||
|
||||
+67
-15
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -54,6 +55,9 @@ func assignWith(ctx context.Context, open *stores, node string, opts assignOptio
|
||||
if err := refusedMovingTheController(modules); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Every line of the answer comes from the one resolution after the write (novox/hq issue 237):
|
||||
// what the reads before it would say about a machine left out is not the answer's.
|
||||
ctx = leftOutSaidBy(ctx, io.Discard)
|
||||
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
|
||||
// be taken without ever having been previewed (novox/hq ADR 0100).
|
||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||
@@ -125,22 +129,28 @@ func assignWith(ctx context.Context, open *stores, node string, opts assignOptio
|
||||
for _, line := range settled {
|
||||
answer += "\n " + line
|
||||
}
|
||||
// What this act changed about this node's unmet seat dependencies, and nothing else (novox/hq
|
||||
// ADR 0207): a dependency of a module just assigned, or one this assignment met. The rest of the
|
||||
// node's list, and every other node's, is `status`'s.
|
||||
for _, line := range unheldChange(shelf, node, before, append(append([]string(nil), before...), added...)) {
|
||||
answer += "\n " + line
|
||||
}
|
||||
// Its bus credential, in the same act (novox/hq issue 203): an assignment pushed before its
|
||||
// credential exists delivers a process that cannot authenticate and crash-loops until somebody
|
||||
// runs a second verb and a second push. Issued here when the module speaks on the bus and has
|
||||
// no credential yet; kept when it has one, so re-assigning rotates nothing.
|
||||
var issued []string
|
||||
for _, module := range added {
|
||||
if line := issueOnAssign(ctx, open, node, module); line != "" {
|
||||
answer += "\n " + line
|
||||
issued = append(issued, line)
|
||||
}
|
||||
}
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
// **The one resolution the rest of the answer is said from** (novox/hq issue 237), taken after
|
||||
// the write: what this act changed about this node's unmet seat dependencies, and nothing else
|
||||
// (novox/hq ADR 0207) — a dependency of a module just assigned, or one this assignment met; the
|
||||
// rest of the node's list, and every other node's, is `status`'s — and whether the node resolves.
|
||||
after := resolvedAfter(ctx, open, shelf, node)
|
||||
for _, line := range unheldChangeTo(shelf, node, before, after.modules, after.unheld) {
|
||||
answer += "\n " + line
|
||||
}
|
||||
for _, line := range issued {
|
||||
answer += "\n " + line
|
||||
}
|
||||
plan, err := after.plan, after.err
|
||||
if err != nil {
|
||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||
// worth reporting: this machine's refusal is rarely the only consequence.
|
||||
@@ -218,6 +228,8 @@ func unassign(ctx context.Context, open *stores, node string, modules ...string)
|
||||
if len(modules) == 0 {
|
||||
return "", fmt.Errorf("unassign %s names no module", node)
|
||||
}
|
||||
// One resolution after the write, as assign's answer (novox/hq issue 237).
|
||||
ctx = leftOutSaidBy(ctx, io.Discard)
|
||||
ctx, release, err := holdNodes(ctx, open, []string{node})
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -262,15 +274,22 @@ func unassign(ctx context.Context, open *stores, node string, modules ...string)
|
||||
}
|
||||
answer := fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
|
||||
node, strings.Join(modules, ", "), node)
|
||||
var left []string
|
||||
for _, a := range assigned {
|
||||
if !slices.Contains(modules, a) {
|
||||
left = append(left, a)
|
||||
}
|
||||
}
|
||||
for _, line := range unheldChange(shelf, node, assigned, left) {
|
||||
after := resolvedAfter(ctx, open, shelf, node)
|
||||
for _, line := range unheldChangeTo(shelf, node, assigned, after.modules, after.unheld) {
|
||||
answer += "\n " + line
|
||||
}
|
||||
// Whether the node itself still resolves, from the same resolution: before, only the rest of the
|
||||
// mesh's reads said it, on standard error and once per other machine.
|
||||
if after.err != nil {
|
||||
if unresolvable(after.err) {
|
||||
answer += fmt.Sprintf("\n but %s does not resolve now, so nothing will be sent to it:", node)
|
||||
} else {
|
||||
answer += fmt.Sprintf("\n but whether %s still resolves could not be read:", node)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimRight(after.err.Error(), "\n"), "\n") {
|
||||
answer += "\n " + strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
// What it leaves behind that is irreplaceable is kept and retired, never removed (novox/hq ADR 0233).
|
||||
for _, line := range keptOnUnassign(ctx, open.inventory, node, modules) {
|
||||
answer += "\n " + line
|
||||
@@ -278,6 +297,39 @@ func unassign(ctx context.Context, open *stores, node string, modules ...string)
|
||||
return answer + blockedElsewhere(ctx, open, node), nil
|
||||
}
|
||||
|
||||
// afterAct is the one resolution of a node an act's answer is said from, taken after its write
|
||||
// (novox/hq issue 237): the plan or why there is none, the modules it has on the node, and its unmet
|
||||
// seat dependencies.
|
||||
type afterAct struct {
|
||||
plan catalogue.Resolution
|
||||
err error
|
||||
modules []string
|
||||
unheld []catalogue.Unheld
|
||||
}
|
||||
|
||||
// resolvedAfter resolves the node as `plan` does, once, after the act's write. When the node resolves,
|
||||
// its modules and unmet seat dependencies are the resolution's — the closure, so a holder pulled in by
|
||||
// a requirement counts as `plan` counts it. When it does not, they are judged over the assignments read
|
||||
// after the write, which can only find more unmet than the closure would: a seat said held there is
|
||||
// held in the closure too.
|
||||
func resolvedAfter(ctx context.Context, open *stores, shelf map[string]catalogue.Manifest, node string) afterAct {
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err == nil {
|
||||
out := afterAct{plan: plan, unheld: plan.Unheld}
|
||||
for _, m := range plan.Modules {
|
||||
out.modules = append(out.modules, m.Module)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := afterAct{err: err}
|
||||
assigned, readErr := open.inventory.Assigned(ctx, node)
|
||||
if readErr != nil {
|
||||
return out
|
||||
}
|
||||
out.modules, out.unheld = assigned, unheldOver(shelf, node, assigned)
|
||||
return out
|
||||
}
|
||||
|
||||
// splitModules is a surface's one `module` field as the modules it names: several, comma-separated,
|
||||
// are one act (novox/hq ADR 0207), so the holders that depend on each other go on together from the
|
||||
// command API and the controller seat's verbs as they do from the command line.
|
||||
|
||||
@@ -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 library the operator is asked to let the mesh take (kept_libraries.go, novox/hq ADR 0328 §7): about no
|
||||
// condition, taken on Approve by the serving controller itself, asked once per library.
|
||||
Take *libraryTake `json:"take,omitempty"`
|
||||
}
|
||||
|
||||
// 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 library waiting to be taken (novox/hq ADR 0328 §7), and take what takes one on the operator's
|
||||
// warrant; nil asks about none. machines are the mesh's machine names, allowed in an ask's words.
|
||||
folders func(ctx context.Context) ([]libraryTake, error)
|
||||
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 library waiting to be taken, once per library (novox/hq ADR 0328 §7).
|
||||
if err := a.askFolders(ctx); err != nil {
|
||||
a.logf("the operator could not be asked about the libraries waiting to be taken: %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 library taken on the operator's warrant (novox/hq ADR 0328 §7): the act is composed again from the
|
||||
// record and held to what the option bound before the engine is asked.
|
||||
outcome, acted = a.decideTake(ctx, *r, act, w)
|
||||
if acted == nil && !strings.HasPrefix(outcome, "nothing") {
|
||||
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,6 +1109,16 @@ 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 {
|
||||
@@ -1117,7 +1127,7 @@ func classOf(walk inventory.Plan, entries []inventory.Entry) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
for m := range walk.Modules {
|
||||
for _, m := range modules {
|
||||
if _, own := onTheControllersPath[m]; own || resolvers[m] {
|
||||
return inventory.ClassCore
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -105,9 +106,32 @@ 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,14 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// **A module that uses several libraries is warned** (novox/hq ADR 0328 §4): an image that drops added groups when
|
||||
// it changes user reaches only the first. Printed, never counted.
|
||||
for _, name := range names {
|
||||
for _, w := range catalogue.LibraryWarnings(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// **No send but a person's `push <node>` and a gate's own put-back moves the mesh's own core to a build older
|
||||
// than the one its machine was last sent** (novox/hq issue 201). Every send made through sendToEach keeps it — a
|
||||
// plan's, a release's, a healer's, a rotation's, and the bus step too, which is a person's act but composes under
|
||||
// sendKeeps like the rest. On 2026-10-02 two merges a minute apart made two plans;
|
||||
// the controller's plan rolled the new controller, which widened its seat's row, and the node-engine's plan then
|
||||
// sent the control node the controller build before it. The older binary read the row it could not serve and the
|
||||
// mesh lost its voice for ten minutes — the thing that would have corrected it was the thing that was down. ADR
|
||||
// 0185 took the outage out of it (a controller behind its row serves what it can); the race is closed here,
|
||||
// without needing its trigger: whatever made the mesh's record older, the send keeps the build.
|
||||
//
|
||||
// **Kept, not refused.** The send composes the controller or the node-engine at the build the machine was last
|
||||
// sent, as a recorded module is kept (ADR 0245), and carries everything else: no other module, secret, healer or
|
||||
// walk waits on it, and nothing is retried. The kept move is no move for a gate either: ungatedIn and gatedSend
|
||||
// leave it out (withoutKept), so a gate judges the other moves of its send. A plan whose own build is the older one finds its gate superseded by
|
||||
// the build kept there (issue 352) and ends. The mesh's record stays older, and a warning condition says so until
|
||||
// a send carries the record — a person's `push <node>` accepts the older build — or the record moves forward: a
|
||||
// newer merge, or `rebuild mesh-controller`.
|
||||
//
|
||||
// Older is by the builds' registration order — when each was asked (novox/hq issue 219) — never by the commits'
|
||||
// dates. So a retry or a rebuild of an old commit, asked anew, counts as newer: it is registered as the module's
|
||||
// current build by the same order, and the guard does not stop that move. A build that failed its gate is never
|
||||
// ahead: the machine was sent it and the record was put back before it (gateFailed), and if the put-back's own
|
||||
// send was refused, the next send completes it rather than keeping the failed build there. A gate's own put-back
|
||||
// carries its modules back whatever the order (sendScope.puttingBack); the controller's is held to the store's
|
||||
// schema already (issue 352).
|
||||
|
||||
// kindCoreDowngrade is a core module kept at the build its machine was last sent, over an older record.
|
||||
const kindCoreDowngrade = "core-downgrade-kept"
|
||||
|
||||
// coreDowngradeSource is the condition's source: the sends, which raise and clear it.
|
||||
const coreDowngradeSource = "sends"
|
||||
|
||||
// downgradeGuarded is the core components a send may not move backwards: the controller, which reads the
|
||||
// store and its seat's row, and the node-engine, which reads what the controller declares.
|
||||
var downgradeGuarded = []string{lease.ComponentController, lease.ComponentEngine}
|
||||
|
||||
// askedBuild is what the order needs of one build of a module: when it was asked, and whether it failed its gate.
|
||||
type askedBuild struct {
|
||||
asked time.Time
|
||||
failedGate bool
|
||||
}
|
||||
|
||||
// buildAsked is the newest build of a module from a commit that worked — when it was asked (or recorded, when
|
||||
// the request time is not known), and whether it failed its gate — and false when the records hold none. A
|
||||
// variable, so a test reads its own order.
|
||||
var buildAsked = func(ctx context.Context, inv *inventory.Inventory, module, commit string) (askedBuild, bool, error) {
|
||||
builds, err := inv.Builds(ctx, module, askedLookBack)
|
||||
if err != nil {
|
||||
return askedBuild{}, false, err
|
||||
}
|
||||
for _, b := range builds {
|
||||
if b.Worked() && sameCommit(b.Commit, commit) {
|
||||
failed, err := inv.GateFailed(ctx, b.ID)
|
||||
if err != nil {
|
||||
return askedBuild{}, false, err
|
||||
}
|
||||
return askedBuild{asked: b.AskedOrAt(), failedGate: failed}, true, nil
|
||||
}
|
||||
}
|
||||
return askedBuild{}, false, nil
|
||||
}
|
||||
|
||||
// askedLookBack is how many of a module's newest builds are read for when a build was asked.
|
||||
const askedLookBack = 50
|
||||
|
||||
// coreDowngrade is one core module a machine was last sent a build of that is newer than the mesh's record.
|
||||
type coreDowngrade struct {
|
||||
module, node, component string
|
||||
// sent is the build the machine was last sent, record the one the mesh holds.
|
||||
sent, record string
|
||||
sentAsked, recordAsked time.Time
|
||||
}
|
||||
|
||||
func (d coreDowngrade) String() string {
|
||||
return fmt.Sprintf("%s was last sent %s %s, and the mesh holds the older %s (asked %s, before %s): a send there "+
|
||||
"keeps %s; `push %s` sends the older build, and a newer merge or `rebuild %s` moves the record forward "+
|
||||
"(novox/hq issue 201)", d.node, d.module, short(d.sent), short(d.record),
|
||||
d.recordAsked.UTC().Format(time.RFC3339), d.sentAsked.UTC().Format(time.RFC3339), short(d.sent), d.node,
|
||||
d.module)
|
||||
}
|
||||
|
||||
// coreDowngrades is, for one machine, each guarded core module whose record is older than the build the machine
|
||||
// was last sent, leaving out what a gate's own put-back puts back and a sent build that failed its gate. A move
|
||||
// whose either build the records do not hold is not proved older and is left to the gate that judges it. Pure
|
||||
// but for asked.
|
||||
func coreDowngrades(node string, sent map[string]string, record map[string]string, scope sendScope,
|
||||
asked func(module, commit string) (askedBuild, bool, error)) ([]coreDowngrade, error) {
|
||||
var out []coreDowngrade
|
||||
for module, was := range sent {
|
||||
component := coreComponent(module)
|
||||
now := record[module]
|
||||
if !guarded(component) || was == "" || now == "" || sameCommit(was, now) {
|
||||
continue
|
||||
}
|
||||
if scope.puttingBack && scope.modules[module] {
|
||||
continue
|
||||
}
|
||||
from, known, err := asked(module, was)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("when %s's build %s was asked cannot be read: %w", module, short(was), err)
|
||||
}
|
||||
if !known || from.failedGate {
|
||||
continue
|
||||
}
|
||||
to, known, err := asked(module, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("when %s's build %s was asked cannot be read: %w", module, short(now), err)
|
||||
}
|
||||
if known && to.asked.Before(from.asked) {
|
||||
out = append(out, coreDowngrade{module: module, node: node, component: component, sent: was, record: now,
|
||||
sentAsked: from.asked, recordAsked: to.asked})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].module < out[j].module })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func guarded(component string) bool {
|
||||
for _, c := range downgradeGuarded {
|
||||
if c == component {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// coreKept is, for a send that keeps (sendKeeps), each guarded core module of a machine to keep at the build it
|
||||
// was last sent, module → commit, each said and raised as a condition.
|
||||
func coreKept(ctx context.Context, inv *inventory.Inventory, node string, sent map[string]string,
|
||||
current map[string]inventory.CurrentBuild) (map[string]string, error) {
|
||||
record := map[string]string{}
|
||||
for m, c := range current {
|
||||
record[m] = c.Commit
|
||||
}
|
||||
found, err := coreDowngrades(node, sent, record, scopeOf(ctx), func(module, commit string) (askedBuild, bool, error) {
|
||||
return buildAsked(ctx, inv, module, commit)
|
||||
})
|
||||
if err != nil || len(found) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, d := range found {
|
||||
out[d.module] = d.sent
|
||||
sayCoreDowngrade(ctx, d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// coreDowngradeObservation is a kept core build as a condition: `core.<component>.<machine>.<kind>`.
|
||||
func coreDowngradeObservation(component, node, summary string) conditions.Observation {
|
||||
return conditions.Observation{Scope: conditions.ScopeCore, ID: component + "." + node, Kind: kindCoreDowngrade,
|
||||
Token: kindCoreDowngrade, Machine: node, Severity: conditions.Warning, Source: coreDowngradeSource,
|
||||
Summary: summary}
|
||||
}
|
||||
|
||||
func sayCoreDowngrade(ctx context.Context, d coreDowngrade) {
|
||||
fmt.Printf("KEPT: %s\n", d)
|
||||
keeper := conditionsFrom
|
||||
if keeper == nil {
|
||||
return
|
||||
}
|
||||
if _, err := keeper.Observe(ctx, coreDowngradeObservation(d.component, d.node, d.String())); err != nil {
|
||||
fmt.Printf("the condition for %s kept on %s could not be raised: %v\n", d.module, d.node, err)
|
||||
}
|
||||
}
|
||||
|
||||
// settleCoreDowngrades clears, after a send to a machine is away, the condition of each guarded core module the
|
||||
// send carried at the mesh's record — a person's push that accepted the older build, or a send after the record
|
||||
// moved forward — or did not carry at all. A send that kept the newer build leaves it open.
|
||||
func settleCoreDowngrades(ctx context.Context, inv *inventory.Inventory, node string, carried map[string]string) {
|
||||
keeper := conditionsFrom
|
||||
if keeper == nil || carried == nil {
|
||||
return
|
||||
}
|
||||
var open []string
|
||||
for _, c := range downgradeGuarded {
|
||||
if _, found, err := keeper.Get(ctx, coreDowngradeObservation(c, node, "").Key()); err == nil && found {
|
||||
open = append(open, c)
|
||||
}
|
||||
}
|
||||
if len(open) == 0 {
|
||||
return
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("whether %s still keeps a core build over the mesh's record cannot be read: %v\n", node, err)
|
||||
return
|
||||
}
|
||||
for module, now := range current {
|
||||
component := coreComponent(module)
|
||||
if !guarded(component) {
|
||||
continue
|
||||
}
|
||||
if was, has := carried[module]; has && !sameCommit(was, now.Commit) {
|
||||
continue
|
||||
}
|
||||
key := coreDowngradeObservation(component, node, "").Key()
|
||||
if _, err := keeper.Clear(ctx, key, node+" was sent the "+component+" the mesh holds"); err != nil {
|
||||
fmt.Printf("the condition %s could not be cleared: %v\n", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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,57 @@ 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] = !sameAccessUse(before, target)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameAccessUse says a move leaves a module's accesses and libraries as they were (the review of e1c2b744, novox/hq
|
||||
// ADR 0328): the same accesses at the same use, the same library entries, and whether it joins — a module that starts
|
||||
// joining runs as another user, and changes who uses its libraries.
|
||||
func sameAccessUse(a, b catalogue.Manifest) bool {
|
||||
return sameAccesses(a.Accesses, b.Accesses) && catalogue.LibraryUseShape(a) == catalogue.LibraryUseShape(b)
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -1039,7 +1095,7 @@ func gateFailed(ctx context.Context, open *stores, p *inventory.Plan, module str
|
||||
}
|
||||
return
|
||||
}
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: map[string]bool{module: true}}), open, g.Machines)
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: map[string]bool{module: true}, puttingBack: true}), open, g.Machines)
|
||||
if err != nil {
|
||||
notBack(fmt.Sprintf("its registered build is back at %s, and sending it to %s was refused: %v — `push %s` "+
|
||||
"sends it", short(previous.Commit), strings.Join(g.Machines, ", "), err, g.Machines[0]))
|
||||
@@ -1132,7 +1188,7 @@ func sendRollbacks(ctx context.Context, open *stores, p *inventory.Plan, b *roll
|
||||
}
|
||||
inv := open.inventory
|
||||
sort.Strings(b.machines)
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: b.modules}), open, b.machines)
|
||||
sent, err := sendRollout(withScope(ctx, sendScope{modules: b.modules, puttingBack: true}), open, b.machines)
|
||||
var back []string
|
||||
for _, r := range b.pending {
|
||||
if err != nil {
|
||||
|
||||
@@ -167,3 +167,54 @@ func TestASetuidSearchAsksOnlyAKnownNodeAndFailsOnARefusal(t *testing.T) {
|
||||
t.Fatalf("a start: %v, printed %q", err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The directory judgement's line asks only a known node, one name and nothing else, fails on a refusal, and says it
|
||||
// changed nothing (novox/hq issue 358).
|
||||
func TestADirectoryJudgementAsksOnlyAKnownNodeAndSaysItIsReadOnly(t *testing.T) {
|
||||
t.Setenv(link.CallerVar, "jo through mesh-cli on anchor")
|
||||
asked := 0
|
||||
ask := func(answer link.HandOverAnswer) func(node, by string) (link.HandOverAnswer, error) {
|
||||
return func(node, by string) (link.HandOverAnswer, error) {
|
||||
asked++
|
||||
if node != "novox" || by != "jo through mesh-cli on anchor" {
|
||||
t.Fatalf("asked %q %q", node, by)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
}
|
||||
known := func(string) error { return nil }
|
||||
var out bytes.Buffer
|
||||
for _, args := range [][]string{nil, {"novox", "extra"}, {"-x"}} {
|
||||
if err := judgeDirectoriesAsked(args, known, ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 {
|
||||
t.Fatalf("%v was asked: %v", args, err)
|
||||
}
|
||||
}
|
||||
if err := judgeDirectoriesAsked([]string{"novox"}, func(string) error { return errors.New("no node called novox") },
|
||||
ask(link.HandOverAnswer{Said: "x"}), &out); err == nil || asked != 0 {
|
||||
t.Fatalf("an unknown node: %v", err)
|
||||
}
|
||||
if err := judgeDirectoriesAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Refused: "no; nothing was judged"}),
|
||||
&out); err == nil || !strings.Contains(err.Error(), "novox refused") || out.Len() != 0 {
|
||||
t.Fatalf("a refusal: %v, printed %q", err, out.String())
|
||||
}
|
||||
said := "2 directories declared: 1 placed, 1 would be refused\n/var/lib/a: placed\n/var/lib/b: would be refused: …"
|
||||
if err := judgeDirectoriesAsked([]string{"novox"}, known, ask(link.HandOverAnswer{Said: said}), &out); err != nil ||
|
||||
out.String() != "novox (read-only: nothing on novox was changed)\n"+said+"\n" {
|
||||
t.Fatalf("a judgement: %v, printed %q", err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The judgement is a seat verb an agent may call — it reads, and changes nothing on the node — and runs exactly
|
||||
// `node judge-directories <node>` (novox/hq issue 358).
|
||||
func TestTheDirectoryJudgementIsAVerbThatOnlyReads(t *testing.T) {
|
||||
argv, err := argvFor("judge-directories", map[string]any{"node": "novox"})
|
||||
if err != nil || strings.Join(argv, " ") != "node judge-directories novox" {
|
||||
t.Fatalf("the verb runs %v, %v", argv, err)
|
||||
}
|
||||
if _, err := argvFor("judge-directories", map[string]any{}); err == nil {
|
||||
t.Fatal("the verb ran with no node")
|
||||
}
|
||||
if !readingVerbs["judge-directories"] {
|
||||
t.Fatal("the judgement is not counted a reading verb")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A module that joins keeps the user it ran as until it may join (novox/hq ADR 0328 §8, ADR 0334).
|
||||
//
|
||||
// **Held at the build it ran before, composed exactly as then.** A module whose registered build joins — declares
|
||||
// libraries or uses its identity — on a machine that was last sent another build of it, is held there while
|
||||
//
|
||||
// - the machine's engine is older than link.OwnGroupContract: it cannot put the tool runner in the module's own group
|
||||
// nor keep its own directories' group, so the module's tools would lose their read (the re-review of 96e15f86, 2);
|
||||
// - or a library it uses is not yet taken (§8).
|
||||
//
|
||||
// Held, it is composed at that build — the manifest the build records hold, in place of the registered one, as a
|
||||
// recorded module is kept (recorded_kept.go) — and with the settings it last ran with (SettingsAsOf the machine's last
|
||||
// send): its resources, its ports in the filter, its contributions and bindings are what they were, and nothing of the
|
||||
// new contract is sent for it. The send records that the machine still carries that build. A delivery that brings the
|
||||
// joining build does not send it while it is held: the module waits for a person, the plan says so and judges its
|
||||
// other modules, and sends it once nothing holds it (release_plan.go, heldIn). A module the machine never ran has
|
||||
// nothing to keep, and is composed as registered; a build the records no longer hold cannot be kept.
|
||||
|
||||
// heldModule is one module held on a machine: the build it keeps and why, in words with no path.
|
||||
type heldModule struct {
|
||||
commit string
|
||||
// library is the first library it uses that is not taken, empty when none.
|
||||
library string
|
||||
why string
|
||||
}
|
||||
|
||||
// holdJoining is every module held on a machine, by module; shelf, when given, has each one's manifest replaced by
|
||||
// the build it keeps.
|
||||
func holdJoining(ctx context.Context, inv *inventory.Inventory, node string,
|
||||
shelf map[string]catalogue.Manifest) (map[string]heldModule, error) {
|
||||
registered := shelf
|
||||
if registered == nil {
|
||||
var err error
|
||||
if registered, err = inv.Catalogue(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sent, known, err := inv.SentBuilds(ctx, node)
|
||||
if err != nil || !known {
|
||||
return nil, err
|
||||
}
|
||||
var joining []string
|
||||
for m := range sent {
|
||||
if man, has := registered[m]; has && catalogue.Joins(man) {
|
||||
joining = append(joining, m)
|
||||
}
|
||||
}
|
||||
if len(joining) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sort.Strings(joining)
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engineJoins := had && stated.Contract >= link.OwnGroupContract
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taken, err := librariesTaken(ctx, inv, libs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]heldModule{}
|
||||
for _, m := range joining {
|
||||
was := sent[m]
|
||||
if was == "" || sameCommit(was, current[m].Commit) {
|
||||
continue // the machine already runs the registered build: nothing before it to keep
|
||||
}
|
||||
h := heldModule{commit: was}
|
||||
for _, u := range registered[m].Libraries {
|
||||
if !taken[u.Library] {
|
||||
h.library = u.Library
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !engineJoins:
|
||||
h.why = fmt.Sprintf("%s keeps the build it runs on %s until that machine's node-engine keeps a module's own "+
|
||||
"group (novox/hq ADR 0334)", m, node)
|
||||
case h.library != "":
|
||||
h.why = fmt.Sprintf("%s waits for the operator to let the mesh keep the library %s (novox/hq ADR 0328 §8)",
|
||||
m, h.library)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
before, found, err := inv.ManifestAt(ctx, m, was)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || (engineJoins && usesLibrary(before, h.library)) {
|
||||
continue
|
||||
}
|
||||
if shelf != nil {
|
||||
shelf[m] = before
|
||||
}
|
||||
out[m] = h
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// heldIn is why a module is held on any of the machines given, or "": what a delivery's first send waits on.
|
||||
func heldIn(ctx context.Context, inv *inventory.Inventory, module string, machines []string) (string, error) {
|
||||
for _, node := range machines {
|
||||
held, err := holdJoining(ctx, inv, node, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if h, is := held[module]; is {
|
||||
return h.why, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// heldSettings puts, for each module held on a machine, the settings it last ran with in place of today's: the layers
|
||||
// as they stood at the machine's last send. Today's stand where no send is recorded.
|
||||
func heldSettings(ctx context.Context, inv *inventory.Inventory, node string, held map[string]heldModule,
|
||||
settings catalogue.SettingsBy) error {
|
||||
if len(held) == 0 {
|
||||
return nil
|
||||
}
|
||||
last, found, err := inv.LastSend(ctx, node)
|
||||
if err != nil || !found {
|
||||
return err
|
||||
}
|
||||
for m := range held {
|
||||
layers, err := inv.SettingsAsOf(ctx, node, m, last.SentAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(layers) == 0 {
|
||||
delete(settings, m)
|
||||
continue
|
||||
}
|
||||
settings[m] = layers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func usesLibrary(m catalogue.Manifest, library string) bool {
|
||||
for _, u := range m.Libraries {
|
||||
if library != "" && u.Library == library {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
const heldImage = "x@sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||
|
||||
// radarrBefore is radarr as it ran before it joined: its own port, its own data, PUID from a setting.
|
||||
func radarrBefore() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "radarr", Version: "1", Capabilities: []string{"container-runtime"},
|
||||
Listens: []catalogue.Listening{{Port: 7878, From: catalogue.FromMesh, Name: "web", Why: "its web UI"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"},
|
||||
"env": map[string]any{"PUID": "1000"}, "volumes": []any{"${dir:config}:/config"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// radarrJoined is the same module's next build, joining the media library.
|
||||
func radarrJoined() catalogue.Manifest {
|
||||
m := radarrBefore()
|
||||
m.Libraries = []catalogue.LibraryUse{{ID: "movies", Library: "media", Path: "movies", Use: catalogue.AccessReadWrite}}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "app", "type": "container", "name": "radarr", "image": heldImage, "ports": []any{"7878"},
|
||||
"env": map[string]any{"PUID": "${identity:uid}"},
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// novox/hq ADR 0328 §8 (the review of 7ea6d36e, item 1): a joining module that ran on a machine before is held until
|
||||
// its libraries are taken — composed EXACTLY as before it joined, at the build the machine runs: its resources, its
|
||||
// port in the filter, its contributions; only the new build waits. The record says the machine still carries that build.
|
||||
func TestAHeldModuleIsComposedAsBeforeItJoined(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
controller := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"}
|
||||
register(t, open, controller)
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, joined := radarrBefore(), radarrJoined()
|
||||
raw, _ := json.Marshal(before)
|
||||
b1 := inventory.Build{ID: "b1", Repository: "https://forge.invalid/media.git", Ref: "main", Module: "radarr",
|
||||
Commit: "c1c1c1c1", On: "builder", Manifest: raw}
|
||||
if err := inv.RecordBuild(ctx, b1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, before, inventory.Source{Repository: b1.Repository, Ref: "main", BuiltFrom: b1.Commit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What anchor composes for radarr before it joined.
|
||||
plan, settings, err := planFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wasRules, err := plan.Rules(catalogue.Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": b1.Commit}, 1, 1,
|
||||
inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Anchor's engine keeps libraries; the next build joins media, which is not taken.
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.LibraryContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ = json.Marshal(joined)
|
||||
b2 := b1
|
||||
b2.ID, b2.Commit, b2.Manifest = "b2", "c2c2c2c2", raw
|
||||
if err := inv.RecordBuild(ctx, b2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, joined, inventory.Source{Repository: b2.Repository, Ref: "main", BuiltFrom: b2.Commit}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan, settings, err = planFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := held.leftOutWhy["radarr"]; left {
|
||||
t.Fatalf("radarr was left out while held: %s", why)
|
||||
}
|
||||
if !reflect.DeepEqual(radarrResources(was.Resources), radarrResources(held.Resources)) {
|
||||
t.Fatalf("radarr held is composed otherwise than before it joined:\n %v\n %v", radarrResources(was.Resources),
|
||||
radarrResources(held.Resources))
|
||||
}
|
||||
rules, err := plan.Rules(catalogue.Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(rules, wasRules) || !strings.Contains(asJSON(rules), "7878") {
|
||||
t.Fatalf("radarr's port is not in the filter as before: %s", asJSON(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func radarrResources(rs []map[string]any) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, r := range rs {
|
||||
if strings.HasPrefix(r["id"].(string), "radarr.") {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
raw, _ := json.Marshal(v)
|
||||
return string(raw)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The re-review of 96e15f86, item 1 (novox/hq ADR 0328 §8): a delivery that brings a joining build of a module whose
|
||||
// library is not taken does not send it — the module waits for a person, the plan stays open saying so, and the other
|
||||
// modules of the plan are sent and judged as usual. Once the library is taken, the waiting build is sent and judged,
|
||||
// with no new merge.
|
||||
func TestADeliveryWaitsForALibraryAndJudgesTheRest(t *testing.T) {
|
||||
judgeLeavesAsTheCore(t)
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
was := doctorFrom
|
||||
doctorFrom = &doctor{open: open, keeper: keeper, teller: &conditions.Told{}}
|
||||
t.Cleanup(func() { doctorFrom = was })
|
||||
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app1 := catalogue.Manifest{Module: "app", Version: "c1"}
|
||||
app2 := catalogue.Manifest{Module: "app", Version: "c2"}
|
||||
builds := []struct {
|
||||
id, module, commit string
|
||||
m catalogue.Manifest
|
||||
at time.Time
|
||||
}{
|
||||
{"build-app-1", "app", "c1", app1, time.Now().Add(-2 * time.Hour)},
|
||||
{"build-radarr-1", "radarr", "r1", radarrBefore(), time.Now().Add(-2 * time.Hour)},
|
||||
{"build-app-2", "app", "c2", app2, time.Now().Add(-time.Minute)},
|
||||
{"build-radarr-2", "radarr", "r2", radarrJoined(), time.Now().Add(-time.Minute)},
|
||||
}
|
||||
for _, b := range builds {
|
||||
raw, _ := json.Marshal(b.m)
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: b.id, Module: b.module, Commit: b.commit,
|
||||
Repository: "novox/mesh-media-catalog", Path: "modules/" + b.module, Ref: "main", Manifest: raw,
|
||||
Asked: b.at, At: b.at}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
registerAt := func(m catalogue.Manifest, commit string, asked time.Time) {
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-media-catalog", Seat: "git",
|
||||
Path: "modules/" + m.Module, Ref: "main", BuiltFrom: commit, Head: commit, Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
registerAt(app1, "c1", time.Now().Add(-2*time.Hour))
|
||||
registerAt(radarrBefore(), "r1", time.Now().Add(-2*time.Hour))
|
||||
if _, err := inv.Assign(ctx, "anchor", "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-0", map[string]string{"app": "c1", "radarr": "r1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.OwnGroupContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registerAt(app2, "c2", time.Now().Add(-time.Minute))
|
||||
registerAt(radarrJoined(), "r2", time.Now().Add(-time.Minute))
|
||||
|
||||
// Every send records what it carries: the registered builds, a held module at the build it runs.
|
||||
var sent []string
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
current, err := open.inventory.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range names {
|
||||
held, err := holdJoining(ctx, open.inventory, node, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
carried := map[string]string{"app": current["app"].Commit, "radarr": current["radarr"].Commit}
|
||||
for m, h := range held {
|
||||
carried[m] = h.commit
|
||||
}
|
||||
sent = append(sent, node+":app="+carried["app"]+",radarr="+carried["radarr"])
|
||||
digest := fmt.Sprintf("d-%s-%d", node, len(sent))
|
||||
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
|
||||
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = wasSend })
|
||||
wasGather := gatherGateFacts
|
||||
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
||||
f := gateFacts{now: time.Now(), reports: map[string]inventory.Reported{}, engines: map[string]string{},
|
||||
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
|
||||
reports, err := open.inventory.LastReports(ctx)
|
||||
for _, r := range reports {
|
||||
f.reports[r.Node] = r
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
t.Cleanup(func() { gatherGateFacts = wasGather })
|
||||
wasSettle, wasEvery := gateSettle, gateEvery
|
||||
gateSettle, gateEvery = 0, 0
|
||||
t.Cleanup(func() { gateSettle, gateEvery = wasSettle, wasEvery })
|
||||
|
||||
built := time.Now().UTC()
|
||||
plan := inventory.Plan{ID: "plan-held", Repository: "novox/mesh-media-catalog", Branch: "main", Commit: "c2",
|
||||
Created: built, State: inventory.PlanBuilding, Tiers: [][]string{{"app", "radarr"}},
|
||||
Modules: map[string]*inventory.PlanModule{
|
||||
"app": {State: "built", BuiltAt: &built, Commit: "c2", Build: "build-app-2"},
|
||||
"radarr": {State: "built", BuiltAt: &built, Commit: "r2", Build: "build-radarr-2"}}}
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, open)
|
||||
}
|
||||
p, err := inv.PlanByID(ctx, "plan-held")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.State == inventory.PlanSuperseded || p.State == inventory.PlanDone || p.State == inventory.PlanFailed {
|
||||
t.Fatalf("the plan is %s (%s); want it open, waiting for the library", p.State, p.Note)
|
||||
}
|
||||
if g := p.Modules["app"].Gate; g == nil || g.Verdict != inventory.GatePassed {
|
||||
t.Fatalf("app was not judged and passed beside the held module: %+v (%s)", p.Modules["app"], p.Note)
|
||||
}
|
||||
if p.Modules["radarr"].FirstAt != nil || !strings.Contains(p.Note, "library media") {
|
||||
t.Fatalf("radarr was sent, or the plan does not say it waits for the library: %+v, %q", p.Modules["radarr"], p.Note)
|
||||
}
|
||||
if slices.ContainsFunc(sent, func(s string) bool { return strings.Contains(s, "radarr=r2") }) {
|
||||
t.Fatalf("radarr's joining build was sent while held: %v", sent)
|
||||
}
|
||||
|
||||
// The library is taken: the waiting build is sent, with no new merge.
|
||||
if err := inv.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: "media", Home: "anchor", Root: "/storage/media",
|
||||
By: "the operator", Ask: "c01", Warrant: "c01"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.TakeDone(ctx, "media", "c01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
advancePlans(ctx, open)
|
||||
}
|
||||
if !slices.ContainsFunc(sent, func(s string) bool { return strings.Contains(s, "radarr=r2") }) {
|
||||
t.Fatalf("radarr's joining build was not sent once its library was taken: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// The re-review of 96e15f86, item 2: a module joins only on an engine stating link.OwnGroupContract (7). On an engine
|
||||
// at 6 a module that ran there before is held at the build it ran, whatever its library — so its tools never lose
|
||||
// their read of its directories.
|
||||
func TestAJoiningModuleIsHeldOnAnEngineOlderThanOwnGroups(t *testing.T) {
|
||||
open, record := heldMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
if err := inv.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: "media", Home: "anchor", Root: "/storage/media",
|
||||
By: "the operator", Ask: "c01", Warrant: "c01"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.TakeDone(ctx, "media", "c01"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = record
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.LibraryContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := holdJoining(ctx, inv, "anchor", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if held["radarr"].commit != "c1c1c1c1" {
|
||||
t.Fatalf("on an engine at contract 6 radarr is not held at the build it ran: %+v", held)
|
||||
}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.OwnGroupContract,
|
||||
SaidAt: time.Now().Add(time.Second), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if held, err = holdJoining(ctx, inv, "anchor", nil); err != nil || len(held) > 0 {
|
||||
t.Fatalf("on an engine at contract 7 with its library taken radarr is still held: %+v (%v)", held, err)
|
||||
}
|
||||
}
|
||||
|
||||
// heldMesh is anchor running radarr's build from before it joined, the joining build registered, media defined.
|
||||
func heldMesh(t *testing.T) (*stores, inventory.Node) {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range []struct {
|
||||
id, commit string
|
||||
m catalogue.Manifest
|
||||
}{{"b1", "c1c1c1c1", radarrBefore()}, {"b2", "c2c2c2c2", radarrJoined()}} {
|
||||
raw, _ := json.Marshal(b.m)
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: b.id, Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
Module: "radarr", Commit: b.commit, On: "builder", Manifest: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, radarrBefore(), inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c1c1c1c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1c1c1c1"}, 1, 1,
|
||||
inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, radarrJoined(), inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c2c2c2c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return open, record
|
||||
}
|
||||
|
||||
// The re-review of 96e15f86, item 3: a held module is composed with the settings it last ran with — a layer changed
|
||||
// for the joining build (its puid removed) does not reach the build it still runs.
|
||||
func TestAHeldModuleKeepsTheSettingsItRanWith(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := radarrBefore()
|
||||
before.Resources = append(before.Resources, map[string]any{"id": "identity", "type": "file",
|
||||
"path": "${dir:config}/identity.env", "mode": "0644", "content": "PUID=${setting:puid}\n"})
|
||||
joined := radarrJoined()
|
||||
for _, b := range []struct {
|
||||
id, commit string
|
||||
m catalogue.Manifest
|
||||
}{{"b1", "c1c1c1c1", before}, {"b2", "c2c2c2c2", joined}} {
|
||||
raw, _ := json.Marshal(b.m)
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: b.id, Repository: "https://forge.invalid/media.git", Ref: "main",
|
||||
Module: "radarr", Commit: b.commit, On: "builder", Manifest: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, before, inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c1c1c1c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SetSettings(ctx, "anchor", "radarr", map[string]any{"puid": "1000"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1c1c1c1"}, 1, 1,
|
||||
inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := inv.RegisterModule(ctx, joined, inventory.Source{Repository: "https://forge.invalid/media.git",
|
||||
Ref: "main", BuiltFrom: "c2c2c2c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, "anchor", "radarr"); err != nil { // the joining build reads no puid
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.RecordHealth(ctx, inventory.NodeHealth{Node: "anchor", Contract: link.OwnGroupContract,
|
||||
SaidAt: time.Now(), HeardAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, settings, err := planFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := declarationWith(ctx, open, "anchor", plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := held.leftOutWhy["radarr"]; left {
|
||||
t.Fatalf("radarr held was left out: %s", why)
|
||||
}
|
||||
for _, r := range held.Resources {
|
||||
if r["id"] == "radarr.identity" {
|
||||
if r["content"] != "PUID=1000\n" {
|
||||
t.Fatalf("radarr held reads %q; want the puid it ran with", r["content"])
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("radarr held has no identity file")
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
// novox/hq issue 201: on 2026-10-02 a plan sent the control node a controller older than the one running there,
|
||||
// whose seat row the newer one had already widened; the older binary refused the row and the mesh went quiet for
|
||||
// ten minutes. No send but a person's push and a gate's own put-back moves the controller or the node-engine back
|
||||
// behind the build its machine was last sent: the send keeps it there, and carries the rest.
|
||||
|
||||
// askedIn answers a build from a fixed table: module@commit → when it was asked; failed names those that failed
|
||||
// their gate.
|
||||
func askedIn(table map[string]time.Time, failed ...string) func(module, commit string) (askedBuild, bool, error) {
|
||||
return func(module, commit string) (askedBuild, bool, error) {
|
||||
at, known := table[module+"@"+commit]
|
||||
b := askedBuild{asked: at}
|
||||
for _, f := range failed {
|
||||
if f == module+"@"+commit {
|
||||
b.failedGate = true
|
||||
}
|
||||
}
|
||||
return b, known, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestACoreModuleIsKeptOverAnOlderRecord(t *testing.T) {
|
||||
old, now := t0, t0.Add(time.Minute)
|
||||
table := map[string]time.Time{
|
||||
"mesh-controller@c1": old, "mesh-controller@c2": now,
|
||||
"mesh-host@h1": old, "mesh-host@h2": now,
|
||||
"app@a1": old, "app@a2": now,
|
||||
}
|
||||
sent := map[string]string{"mesh-controller": "c2", "mesh-host": "h2", "app": "a2"}
|
||||
record := map[string]string{"mesh-controller": "c1", "mesh-host": "h1", "app": "a1"}
|
||||
found, err := coreDowngrades("anchor", sent, record, sendScope{judged: map[string]bool{"anchor": true}}, askedIn(table))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 2 || found[0].module != "mesh-controller" || found[0].component != lease.ComponentController ||
|
||||
found[0].sent != "c2" || found[1].module != "mesh-host" || found[1].component != lease.ComponentEngine {
|
||||
t.Fatalf("the controller and the node-engine over an older record are kept, and nothing else: %+v", found)
|
||||
}
|
||||
if said := found[0].String(); !strings.Contains(said, "anchor was last sent mesh-controller c2") ||
|
||||
!strings.Contains(said, "`push anchor`") || !strings.Contains(said, "`rebuild mesh-controller`") {
|
||||
t.Fatalf("it says the machine, the build kept and the two ways out: %s", said)
|
||||
}
|
||||
|
||||
// The record forward, the same build, or a build the records do not hold: nothing kept.
|
||||
for _, c := range [][2]string{{"c1", "c2"}, {"c2", "c2"}, {"c2", "c9"}, {"c9", "c1"}} {
|
||||
found, err := coreDowngrades("anchor", map[string]string{"mesh-controller": c[0]},
|
||||
map[string]string{"mesh-controller": c[1]}, sendScope{}, askedIn(table))
|
||||
if err != nil || len(found) != 0 {
|
||||
t.Fatalf("sent %s, record %s: no downgrade proved, nothing kept: %+v %v", c[0], c[1], found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A sent build that failed its gate is never ahead: the record put back before it is sent.
|
||||
if found, _ := coreDowngrades("anchor", sent, record, sendScope{}, askedIn(table, "mesh-controller@c2")); len(found) != 1 ||
|
||||
found[0].module != "mesh-host" {
|
||||
t.Fatalf("a controller that failed its gate was kept over the build put back: %+v", found)
|
||||
}
|
||||
|
||||
// A gate's own put-back carries its own modules back, and nothing else.
|
||||
putBack := sendScope{modules: map[string]bool{"mesh-controller": true}, puttingBack: true}
|
||||
if found, _ := coreDowngrades("anchor", sent, record, putBack, askedIn(table)); len(found) != 1 || found[0].module != "mesh-host" {
|
||||
t.Fatalf("a put-back of the controller carries it back, and not the node-engine: %+v", found)
|
||||
}
|
||||
if found, _ := coreDowngrades("anchor", sent, record, sendScope{modules: map[string]bool{"mesh-controller": true}},
|
||||
askedIn(table)); len(found) != 2 {
|
||||
t.Fatalf("a send for the controller that is no put-back carries it back: %+v", found)
|
||||
}
|
||||
}
|
||||
|
||||
// core201 is a mesh whose control node, anchor, was sent the newer controller c2 while the mesh's record of the
|
||||
// controller is the build before it, c1, which passed its gate when it rolled out.
|
||||
func core201(t *testing.T) *stores {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
old, now := time.Now().Add(-2*time.Hour), time.Now().Add(-time.Minute)
|
||||
record := func(commit string, asked time.Time) inventory.Build {
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "mesh-controller", Version: "1"})
|
||||
b := inventory.Build{ID: "build-mesh-controller-" + commit, Module: "mesh-controller", Commit: commit,
|
||||
Repository: "novox/mesh-controller", Manifest: manifest, Asked: asked, At: asked,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:" + commit + "0000000000000000"}}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
before := record("c1aaaaaaaaaa", old)
|
||||
if err := inv.RecordGate(ctx, inventory.GateVerdict{Build: before.ID, Module: "mesh-controller", Commit: before.Commit,
|
||||
Machines: []string{"anchor"}, Verdict: inventory.GatePassed}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record("c2bbbbbbbbbb", now)
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-controller", BuiltFrom: "c2bbbbbbbbbb", Head: "c2bbbbbbbbbb", Asked: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "mesh-controller"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor", map[string]string{"mesh-controller": "c2bbbbbbbbbb"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RestoreModule(ctx, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wasMoves := machineMoves
|
||||
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
|
||||
modules, err := open.inventory.Assigned(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sent, known, err := open.inventory.SentBuilds(ctx, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.moves(node, modules, sent, known, all), nil
|
||||
}
|
||||
t.Cleanup(func() { machineMoves = wasMoves })
|
||||
return open
|
||||
}
|
||||
|
||||
// TestReplay201 replays the race on a store: the control node runs the newer controller and the mesh's record is
|
||||
// the build before it. A plan's send there — the node-engine's plan of 2026-10-02 — goes, keeps the newer
|
||||
// controller, and says so as a condition; the gate's own put-back carries it back; a send that carries the record,
|
||||
// as a person's push does, clears the condition.
|
||||
func TestReplay201(t *testing.T) {
|
||||
open := core201(t)
|
||||
ctx := t.Context()
|
||||
k, _ := withConditionsInMemory(t)
|
||||
|
||||
judged := withScope(ctx, sendScope{judged: map[string]bool{"anchor": true}})
|
||||
if kept, err := ungatedIn(judged, open, []string{"anchor"}, ""); err != nil || len(kept) != 1 {
|
||||
t.Fatalf("a plan's send to the machine is made, the rest of it with it: %v %v", kept, err)
|
||||
}
|
||||
keeps, err := recordedKept(keepingRecorded(judged), open, "anchor")
|
||||
if err != nil || keeps["mesh-controller"] != "c2bbbbbbbbbb" {
|
||||
t.Fatalf("the send keeps the newer controller there: %v %v", keeps, err)
|
||||
}
|
||||
shelf := map[string]catalogue.Manifest{"mesh-controller": {Module: "mesh-controller", Version: "old"}}
|
||||
if _, err := keepRecorded(keepingRecorded(judged), open, "anchor", shelf); err != nil || shelf["mesh-controller"].Version != "1" {
|
||||
t.Fatalf("the send composes the kept build's manifest: %+v %v", shelf, err)
|
||||
}
|
||||
key := coreDowngradeObservation(lease.ComponentController, "anchor", "").Key()
|
||||
if c, found, err := k.Get(ctx, key); err != nil || !found || !strings.Contains(c.Summary, "c2bbbbbb") {
|
||||
t.Fatalf("the condition %s is raised, saying the build kept: %+v %v %v", key, c, found, err)
|
||||
}
|
||||
|
||||
putBack := keepingRecorded(withScope(ctx, sendScope{modules: map[string]bool{"mesh-controller": true}, puttingBack: true}))
|
||||
if keeps, err := recordedKept(putBack, open, "anchor"); err != nil || len(keeps) != 0 {
|
||||
t.Fatalf("the gate's own put-back of the controller carries it back: %v %v", keeps, err)
|
||||
}
|
||||
|
||||
settleCoreDowngrades(ctx, open.inventory, "anchor", map[string]string{"mesh-controller": "c2bbbbbbbbbb"})
|
||||
if _, found, _ := k.Get(ctx, key); !found {
|
||||
t.Fatal("a send that kept the newer controller cleared the condition")
|
||||
}
|
||||
settleCoreDowngrades(ctx, open.inventory, "anchor", map[string]string{"mesh-controller": "c1aaaaaaaaaa"})
|
||||
if _, found, _ := k.Get(ctx, key); found {
|
||||
t.Fatal("a send carrying the mesh's record — a person's push — leaves the condition open")
|
||||
}
|
||||
}
|
||||
|
||||
// The review of #273: a gate failed the newer controller, put the record back, and the put-back's own send was
|
||||
// refused. The next automatic send — a healer's, which judges nothing — completes the put-back: a build that
|
||||
// failed its gate is never kept.
|
||||
func TestARollbackWhoseSendWasRefusedIsCompletedByTheNextSend(t *testing.T) {
|
||||
open := core201(t)
|
||||
ctx := t.Context()
|
||||
k, _ := withConditionsInMemory(t)
|
||||
if err := open.inventory.RecordGate(ctx, inventory.GateVerdict{Build: "build-mesh-controller-c2bbbbbbbbbb",
|
||||
Module: "mesh-controller", Commit: "c2bbbbbbbbbb", Previous: "c1aaaaaaaaaa", Machines: []string{"anchor"},
|
||||
Verdict: inventory.GateFailed, Rollback: inventory.NotRolledBack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
healer := withScope(ctx, sendScope{})
|
||||
if kept, err := ungatedIn(healer, open, []string{"anchor"}, ""); err != nil || len(kept) != 1 {
|
||||
t.Fatalf("the healer's send is made: %v %v", kept, err)
|
||||
}
|
||||
if keeps, err := recordedKept(keepingRecorded(healer), open, "anchor"); err != nil || len(keeps) != 0 {
|
||||
t.Fatalf("the failed controller was kept over the build put back: %v %v", keeps, err)
|
||||
}
|
||||
if open, _ := k.Open(ctx); len(open) != 0 {
|
||||
t.Fatalf("a completed put-back raised a condition: %+v", open)
|
||||
}
|
||||
}
|
||||
|
||||
// Review 91 of #273: a gated send to a machine whose controller is kept over an older record leaves the kept
|
||||
// controller out of what its gate judges. Judged as moving back to the record, the gate would read the send's own
|
||||
// kept controller as another build sent there after it (issue 352) and be superseded, judging nothing.
|
||||
func TestAGateJudgesTheOtherMovesBesideAKeptCore(t *testing.T) {
|
||||
open := core201(t)
|
||||
ctx := t.Context()
|
||||
withConditionsInMemory(t)
|
||||
inv := open.inventory
|
||||
old, now := time.Now().Add(-2*time.Hour), time.Now().Add(-time.Minute)
|
||||
for _, b := range []struct {
|
||||
commit string
|
||||
at time.Time
|
||||
}{{"a1aaaaaaaaaa", old}, {"a2bbbbbbbbbb", now}} {
|
||||
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-" + b.commit, Module: "app", Commit: b.commit,
|
||||
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: b.at, At: b.at,
|
||||
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-" + b.commit}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
|
||||
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: b.commit, Head: b.commit,
|
||||
Asked: b.at}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-2",
|
||||
map[string]string{"mesh-controller": "c2bbbbbbbbbb", "app": "a1aaaaaaaaaa"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The send as it is made: the controller kept at c2, the app moved to the record.
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
return names, open.inventory.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor-3",
|
||||
map[string]string{"mesh-controller": "c2bbbbbbbbbb", "app": "a2bbbbbbbbbb"})
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = wasSend })
|
||||
|
||||
moves, sent, _, err := gatedSend(ctx, open, "anchor", nil)
|
||||
if err != nil || len(sent) != 1 {
|
||||
t.Fatalf("the gated send is made: %v %v", sent, err)
|
||||
}
|
||||
if len(moves) != 1 || moves[0].Module != "app" || moves[0].To != "a2bbbbbbbbbb" {
|
||||
t.Fatalf("the gate judges the app's move and not the kept controller: %+v", moves)
|
||||
}
|
||||
builds, _, err := inv.SentBuilds(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := &inventory.PlanGate{Carried: moves}
|
||||
f := gateFacts{commits: judgedCommits(g, nil), sentBuilds: map[string]map[string]string{"anchor": builds}}
|
||||
for _, m := range []string{"app", "mesh-controller"} {
|
||||
if why, superseded := f.supersededOn(m, "anchor"); superseded {
|
||||
t.Fatalf("the gate is superseded by its own send: %s", why)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package main
|
||||
|
||||
// Existing data joins a library once, on the operator's word (novox/hq ADR 0328 §7, §8).
|
||||
//
|
||||
// **What waits.** A node-engine that keeps libraries states a library waiting, with an access wait of reason `keep`,
|
||||
// while its root on its home holds data not yet in its group; a module that uses it waits the same way. The
|
||||
// controller excuses such a wait only for a defined library that reaches the machine and is not yet taken on its home,
|
||||
// on the filesystem the wait names (checkLibraryWait), and the module is said as its needs-operator condition, in words
|
||||
// with no path.
|
||||
//
|
||||
// **One ask per library.** The serving controller's asker asks the operator once for each such library — however many
|
||||
// modules and machines wait on it — at the level approve (ADR 0259), in the clause's words: "Let the mesh keep library
|
||||
// <name> at <root> on <machine>: …". It is kept in the controller's asks beside a proposal (asked.Take), never a
|
||||
// condition's. While it is open no second is asked; one that expired unanswered is asked again while the library
|
||||
// waits; one about a library that no longer waits is taken back; one whose library moved to another filesystem is
|
||||
// asked again for the one it is on now. None is asked for a library already taken.
|
||||
//
|
||||
// **On the warrant, and only on it**, the controller asks the library's home engine to take it (link.AskTake), naming
|
||||
// "library:<name>" and the warrant — after checking the library still waits on the filesystem the operator was asked
|
||||
// about. On the engine's word that it started, the library is recorded taken (kept_library), and from the next send
|
||||
// the library says `taken`.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// libraryTake is one library the operator is asked to let the mesh take, as the controller's ask keeps it
|
||||
// (asked.Take). Every field the ask is composed from is here, so the act on the warrant is composed from the record.
|
||||
type libraryTake struct {
|
||||
Library string `json:"library"`
|
||||
// Node is the library's home, where it is taken; Root its root there.
|
||||
Node string `json:"node"`
|
||||
Root string `json:"root"`
|
||||
// Modules are the modules waiting on it, sorted.
|
||||
Modules []string `json:"modules"`
|
||||
// Placed is the filesystem the root is on, as the home's engine names it without a path: the warrant is for it.
|
||||
Placed string `json:"placed,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant.
|
||||
const takeVerb = askerName + ".take"
|
||||
|
||||
// key is the library on its home, at its root, on its filesystem: what one ask is kept for. A library that moved is
|
||||
// asked again.
|
||||
func (t libraryTake) key() string {
|
||||
return t.Library + "\x00" + t.Node + "\x00" + t.Root + "\x00" + t.Placed
|
||||
}
|
||||
|
||||
// digest binds everything the take does and was said for.
|
||||
func (t libraryTake) digest() string {
|
||||
raw, _ := json.Marshal(struct {
|
||||
Library, Node, Root, Placed string
|
||||
Modules []string
|
||||
}{t.Library, t.Node, t.Root, t.Placed, t.Modules})
|
||||
sum := sha256.Sum256(raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// actions is the take's one answer, at the level approve, binding the exact act through boundAct.
|
||||
func (t libraryTake) actions(id string) []conditions.Action {
|
||||
return []conditions.Action{{Label: "Let the mesh keep it", Verb: takeVerb, Machine: t.Node,
|
||||
Level: conditions.LevelApprove, Arguments: map[string]string{"take": id, "library": t.Library,
|
||||
"node": t.Node, "access": libraryAccess + t.Library, "digest": t.digest()}}}
|
||||
}
|
||||
|
||||
// takeWords is the clause's ask (ADR 0328 §7), with the root as shown.
|
||||
func takeWords(library, shown, machine string) string {
|
||||
return fmt.Sprintf("Let the mesh keep library %s at %s on %s: its group becomes %s, the group may read and write, "+
|
||||
"folders keep the group; owners and everyone else's rights stay as they are", library, shown, machine,
|
||||
catalogue.MeshName(library))
|
||||
}
|
||||
|
||||
// ask is the take's ask, composed from it alone. The root is said whole where the router's content rule lets it leave
|
||||
// the mesh, and otherwise as "its root".
|
||||
func (t libraryTake) ask(id string, machines []string) (asks.Ask, map[string]int) {
|
||||
shown := t.Root
|
||||
if _, ok := outward.Check(shown, machines...); !ok {
|
||||
shown = "its root"
|
||||
}
|
||||
headline := fmt.Sprintf("Let the mesh keep library %s?", t.Library)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = "Let the mesh keep a library?"
|
||||
}
|
||||
explanation := takeWords(t.Library, shown, t.Node) + "."
|
||||
if len(t.Modules) > 0 {
|
||||
explanation += " Waiting on it: " + strings.Join(t.Modules, ", ") + "."
|
||||
}
|
||||
if _, ok := outward.Check(explanation, machines...); !ok {
|
||||
explanation = sayable(explanation, machines)
|
||||
}
|
||||
q := asks.Ask{ID: id, Headline: headline, Explanation: explanation,
|
||||
Who: asks.Operator, Expires: t.At.Add(askApproveFor),
|
||||
OnExpiry: "nothing is changed, and you are asked again while the library waits", About: "take." + t.Library}
|
||||
options := map[string]int{}
|
||||
for i, act := range t.actions(id) {
|
||||
binds, _ := asks.ActDigest(boundAct(act))
|
||||
oid := optionID(act.Label)
|
||||
options[oid] = i
|
||||
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Level: asks.Level(act.Level), Binds: binds,
|
||||
Does: "the library's data joins its group, and is kept from then on without asking"})
|
||||
}
|
||||
return q, options
|
||||
}
|
||||
|
||||
// takeOnWarrant performs a take the operator approved: the engine is asked, and the library recorded taken on its
|
||||
// word. It answers what the engine said.
|
||||
type takeOnWarrant func(ctx context.Context, t libraryTake, askID, by string) (string, error)
|
||||
|
||||
// askFolders asks the operator about every library waiting to be taken that is not asked about yet, and takes back an
|
||||
// open ask about one that no longer waits. Called at the end of each reconcile, after the conditions' asks, within the
|
||||
// asks the router holds open at once.
|
||||
func (a *asker) askFolders(ctx context.Context) error {
|
||||
if a.folders == nil {
|
||||
return nil
|
||||
}
|
||||
wanted, err := a.folders(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now()
|
||||
all, err := a.store.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open, openNow := map[string]asked{}, 0
|
||||
for _, r := range all {
|
||||
if r.State != askOpen || !now.Before(r.Ask.Expires) {
|
||||
continue
|
||||
}
|
||||
openNow++
|
||||
if r.Take != nil {
|
||||
open[r.Take.key()] = r
|
||||
}
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, t := range wanted {
|
||||
want[t.key()] = true
|
||||
}
|
||||
for key, r := range open {
|
||||
if !want[key] {
|
||||
if err := a.cancel(ctx, r, "the library no longer waits to be taken there"); err != nil {
|
||||
return err
|
||||
}
|
||||
openNow--
|
||||
}
|
||||
}
|
||||
var machines []string
|
||||
if a.machines != nil {
|
||||
if machines, err = a.machines(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, t := range wanted {
|
||||
if _, asking := open[t.key()]; asking {
|
||||
continue // one ask per library (§7)
|
||||
}
|
||||
if openNow >= askMostOpen {
|
||||
return nil // asked when one of the open ones ends
|
||||
}
|
||||
t.At = now
|
||||
id := newAskID()
|
||||
q, options := t.ask(id, machines)
|
||||
if err := q.Check(now); err != nil {
|
||||
a.logf("the operator could not be asked to let the mesh keep the library %s: %v", t.Library, err)
|
||||
continue
|
||||
}
|
||||
body, err := json.Marshal(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
take := t
|
||||
if err := a.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: t.actions(id),
|
||||
Options: options, State: askOpen, Opened: now, Take: &take}); err != nil {
|
||||
return fmt.Errorf("the take could not be kept in the controller's asks, so it was not asked: %w", err)
|
||||
}
|
||||
if err := a.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil {
|
||||
_, _ = a.store.Change(ctx, id, func(x *asked) bool {
|
||||
if x.State != askOpen || x.Acted != "" {
|
||||
return false
|
||||
}
|
||||
x.State, x.Ended, x.Acted = askUnsent, a.now(), "nothing: it could not be published: "+err.Error()
|
||||
return true
|
||||
})
|
||||
a.logf("the operator could not be asked to let the mesh keep the library %s: %v", t.Library, err)
|
||||
continue
|
||||
}
|
||||
openNow++
|
||||
a.logf("asked the operator to let the mesh keep the library %s on %s (%s)", t.Library, t.Node, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decideTake is what the asker does with a warrant for a take (asker.Decided): the act only when it is the one the
|
||||
// option bound — composed again from the record — and the library still waits on the filesystem the operator was
|
||||
// asked about; then the engine is asked.
|
||||
func (a *asker) decideTake(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) {
|
||||
t := *r.Take
|
||||
recomposed := t.actions(r.ID)
|
||||
option, offered := r.Ask.Option(w.Option)
|
||||
if !offered || len(recomposed) != 1 {
|
||||
return "", fmt.Errorf("the take %s offers no answer %q: nothing is taken", r.ID, w.Option)
|
||||
}
|
||||
if err := option.Performs(boundAct(recomposed[0])); err != nil {
|
||||
return "", fmt.Errorf("the take kept for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
if err := option.Performs(boundAct(act)); err != nil {
|
||||
return "", fmt.Errorf("the act chosen for %s is not the one the operator was shown: %v", r.ID, err)
|
||||
}
|
||||
if a.take == nil {
|
||||
return "", errors.New("this controller cannot take a library on a warrant")
|
||||
}
|
||||
// **The library must still wait where the operator was asked about it** (the review of e1c2b744): a take is about
|
||||
// no condition, so nothing else rechecks it. A library that moved to another home or filesystem, or no longer
|
||||
// waits, is not taken: the ask is spent, and one for where it waits now is asked.
|
||||
if a.folders != nil {
|
||||
waiting, err := a.folders(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("whether the library %s still waits could not be read, so nothing is taken: %w",
|
||||
t.Library, err)
|
||||
}
|
||||
still := false
|
||||
for _, now := range waiting {
|
||||
still = still || now.key() == t.key()
|
||||
}
|
||||
if !still {
|
||||
return "nothing: the library no longer waits to be taken where the operator was asked about it (it " +
|
||||
"moved, or was taken meanwhile); asked again where it waits now", nil
|
||||
}
|
||||
}
|
||||
by := fmt.Sprintf("approved by %s via %s at %s (ask %s)", byWords(w), viaWords(w),
|
||||
w.At.Local().Format("2006-01-02 15:04"), r.ID)
|
||||
return a.take(ctx, t, r.ID, by)
|
||||
}
|
||||
|
||||
// takeFolderIn is takeOnWarrant on this controller's stores and bus: the home's engine is asked with the mesh's key,
|
||||
// naming the library and the ask the warrant answered; on its word that it started, the library is recorded taken. A
|
||||
// refusal records nothing.
|
||||
func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessActAsk) (link.HandOverAnswer, error)) takeOnWarrant {
|
||||
return func(ctx context.Context, t libraryTake, askID, by string) (string, error) {
|
||||
// Who asked, as one short line the engine takes (at most link.MaxHandOverBy): the ask names the rest.
|
||||
short := by
|
||||
if len(short) > 180 {
|
||||
short = "the operator, on the warrant for ask " + askID
|
||||
}
|
||||
answer, err := ask(ctx, link.AccessActAsk{Node: t.Node, Path: t.Root, By: short,
|
||||
Access: libraryAccess + t.Library, Warrant: askID})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if answer.Refused != "" {
|
||||
return "", fmt.Errorf("%s refused to take the library %s: %s", t.Node, t.Library, answer.Refused)
|
||||
}
|
||||
// Started, not taken: the library is taken when the engine states the act done (stateHealth, TakeDone).
|
||||
if err := open.inventory.RecordTakeStarted(ctx, inventory.TakenLibrary{Library: t.Library, Home: t.Node,
|
||||
Root: t.Root, Placed: t.Placed, By: by, Ask: askID, Warrant: askID, Said: answer.Said}); err != nil {
|
||||
return "", fmt.Errorf("%s started the take of %s (%s), and it could NOT be recorded: %w", t.Node, t.Library,
|
||||
answer.Said, err)
|
||||
}
|
||||
return fmt.Sprintf("%s started taking the library %s: %s; it is taken when the engine says the act is done",
|
||||
t.Node, t.Library, answer.Said), nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitingFolder is one library wait excused, as foldersWaiting gathers them.
|
||||
type waitingFolder struct {
|
||||
module, machine string
|
||||
// placed is the filesystem the library's root is on, as its home's engine names it without a path; empty from
|
||||
// another machine.
|
||||
placed string
|
||||
}
|
||||
|
||||
// foldersWaiting is every library waiting to be taken across the mesh (ADR 0328 §7, §8): from each machine's newest
|
||||
// statement, each wait of reason keep that is excused (checkWaiting), by library.
|
||||
func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[string]inventory.NodeHealth) (
|
||||
map[string][]waitingFolder, operatorWaitFacts) {
|
||||
var f operatorWaitFacts
|
||||
out := map[string][]waitingFolder{}
|
||||
machines := make([]string, 0, len(healths))
|
||||
for m := range healths {
|
||||
machines = append(machines, m)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
for _, machine := range machines {
|
||||
rs := healths[machine].Resources
|
||||
var modules []string
|
||||
for _, r := range rs {
|
||||
for _, w := range r.Waits {
|
||||
if r.State == link.StateWaiting && w.Access != "" && !w.Disk {
|
||||
modules = append(modules, r.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(modules) == 0 {
|
||||
continue
|
||||
}
|
||||
readWaitFacts(ctx, inv, machine, modules, nil, &f)
|
||||
for _, r := range checkWaiting(machine, rs, f) {
|
||||
if r.State != link.StateWaiting {
|
||||
continue
|
||||
}
|
||||
for _, w := range r.Waits {
|
||||
if w.Access == "" || w.Disk || w.Reason == link.WaitDisk {
|
||||
continue
|
||||
}
|
||||
lib, isLibrary := strings.CutPrefix(w.Access, libraryAccess)
|
||||
if !isLibrary {
|
||||
lib = catalogue.LibraryOfEntry(f.manifests[r.Module], w.Access)
|
||||
}
|
||||
if lib == "" {
|
||||
continue
|
||||
}
|
||||
placed := ""
|
||||
if l := f.libraries[lib]; l.Home == machine {
|
||||
placed = w.Placed
|
||||
}
|
||||
out[lib] = append(out[lib], waitingFolder{module: r.Module, machine: machine, placed: placed})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, f
|
||||
}
|
||||
|
||||
// foldersIn is the asker's folders on this controller's stores: every library waiting to be taken, with its home, its
|
||||
// root and the modules waiting on it.
|
||||
func foldersIn(open *stores) func(ctx context.Context) ([]libraryTake, error) {
|
||||
return func(ctx context.Context) ([]libraryTake, error) {
|
||||
healths, err := open.inventory.Healths(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
waiting, f := foldersWaiting(ctx, open.inventory, healths)
|
||||
return withoutTakesUnderWay(takesOf(waiting, f.libraries), f.taken), nil
|
||||
}
|
||||
}
|
||||
|
||||
// withoutTakesUnderWay is the takes not already under way: a take started on the same home, root and filesystem, which
|
||||
// the engine has not yet stated done or failed, is not asked about again (the review of 7ea6d36e, item 5).
|
||||
func withoutTakesUnderWay(takes []libraryTake, recorded map[string]inventory.TakenLibrary) []libraryTake {
|
||||
var out []libraryTake
|
||||
for _, t := range takes {
|
||||
r, was := recorded[t.Library]
|
||||
if was && !r.Done && r.Home == t.Node && r.Root == t.Root && (t.Placed == "" || r.Placed == "" || r.Placed == t.Placed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// takesOf is one take per library and filesystem from the waits gathered, sorted. A library no longer defined is
|
||||
// asked about nowhere.
|
||||
func takesOf(waiting map[string][]waitingFolder, libraries map[string]catalogue.Library) []libraryTake {
|
||||
var out []libraryTake
|
||||
for lib, ws := range waiting {
|
||||
l, defined := libraries[lib]
|
||||
if !defined {
|
||||
continue
|
||||
}
|
||||
// The home's word on the filesystem stands; waits from other machines name none.
|
||||
placed := map[string]bool{}
|
||||
for _, w := range ws {
|
||||
if w.placed != "" {
|
||||
placed[w.placed] = true
|
||||
}
|
||||
}
|
||||
if len(placed) == 0 {
|
||||
placed[""] = true
|
||||
}
|
||||
for p := range placed {
|
||||
t := libraryTake{Library: lib, Node: l.Home, Root: l.Root, Placed: p}
|
||||
seen := map[string]bool{}
|
||||
for _, w := range ws {
|
||||
if w.module != "" && !seen[w.module] {
|
||||
seen[w.module] = true
|
||||
t.Modules = append(t.Modules, w.module)
|
||||
}
|
||||
}
|
||||
sort.Strings(t.Modules)
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0328 §7, §8 and §9 on the controller's side: a library's wait is excused only for a defined library
|
||||
// that reaches the machine and is not yet taken, or whose disk is not there; the gate passes it as a wait for a person
|
||||
// and the operator reads it as needs-operator in words with no path; the operator is asked once per library, never
|
||||
// about one taken; and the library is taken only on the warrant, where it still waits.
|
||||
|
||||
var radarrManifest = catalogue.Manifest{Module: "radarr", Version: "1",
|
||||
Libraries: []catalogue.LibraryUse{{ID: "movies", Library: "media", Path: "movies", Use: catalogue.AccessReadWrite}}}
|
||||
|
||||
var plexManifest = catalogue.Manifest{Module: "plex", Version: "1",
|
||||
Libraries: []catalogue.LibraryUse{{ID: "films", Library: "media", Path: "movies", Use: catalogue.AccessRead}}}
|
||||
|
||||
var mediaLib = catalogue.Library{Name: "media", Home: "ace", Root: "/storage/media", Reach: []string{"novox"}}
|
||||
|
||||
func keepWait(access string) inventory.Wait {
|
||||
return inventory.Wait{Part: "radarr", Access: access, Reason: link.WaitKeep,
|
||||
What: "let the mesh keep the library media"}
|
||||
}
|
||||
|
||||
func placedWait(access, placed string) inventory.Wait {
|
||||
w := keepWait(access)
|
||||
w.Placed = placed
|
||||
return w
|
||||
}
|
||||
|
||||
func diskWait(access string) inventory.Wait {
|
||||
return inventory.Wait{Part: "radarr", Access: access, Disk: true, Reason: link.WaitDisk,
|
||||
What: "mount the disk that holds media"}
|
||||
}
|
||||
|
||||
// libraryFacts is the facts with media defined, and each library given taken on ace on fs-000000000001.
|
||||
func libraryFactsWith(taken ...string) operatorWaitFacts {
|
||||
t := map[string]inventory.TakenLibrary{}
|
||||
for _, l := range taken {
|
||||
t[l] = inventory.TakenLibrary{Library: l, Home: "ace", Root: "/storage/media", Placed: "fs-000000000001", Done: true}
|
||||
}
|
||||
return operatorWaitFacts{
|
||||
manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest},
|
||||
given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}},
|
||||
libraries: map[string]catalogue.Library{"media": mediaLib},
|
||||
taken: t,
|
||||
librariesRead: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 8 and 9: excused for a defined library reaching the machine and not yet taken — by the module's entry or the
|
||||
// library's own wait — and for one whose disk is not there, taken or not; judged unhealthy when undefined, not
|
||||
// reaching the machine, already taken on the filesystem it is on, for an access that is not a library, or for a
|
||||
// reason the controller does not know.
|
||||
func TestALibraryWaitIsExcusedOnlyForALibraryNotYetTaken(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
machine string
|
||||
w inventory.Wait
|
||||
f operatorWaitFacts
|
||||
says string
|
||||
}{
|
||||
{"the module's entry, not taken", "ace", keepWait("movies"), libraryFactsWith(), ""},
|
||||
{"the library's own wait, not taken", "ace", keepWait("library:media"), libraryFactsWith(), ""},
|
||||
{"on a machine it reaches", "novox", keepWait("movies"), libraryFactsWith(), ""},
|
||||
{"its disk not there", "ace", diskWait("movies"), libraryFactsWith(), ""},
|
||||
{"its disk not there, taken before", "ace", diskWait("movies"), libraryFactsWith("media"), ""},
|
||||
{"taken already", "ace", keepWait("movies"), libraryFactsWith("media"), "taken already"},
|
||||
{"taken on the filesystem it is on", "ace", placedWait("library:media", "fs-000000000001"),
|
||||
libraryFactsWith("media"), "taken already"},
|
||||
{"taken on another filesystem: it moved", "ace", placedWait("library:media", "fs-0000000000ff"),
|
||||
libraryFactsWith("media"), ""},
|
||||
{"taken at another root: its root changed", "ace", keepWait("movies"), func() operatorWaitFacts {
|
||||
f := libraryFactsWith("media")
|
||||
f.taken["media"] = inventory.TakenLibrary{Library: "media", Home: "ace", Root: "/storage/old-media", Done: true,
|
||||
Placed: "fs-000000000001"}
|
||||
return f
|
||||
}(), ""},
|
||||
{"a machine it does not reach", "far", keepWait("movies"), libraryFactsWith(), "does not reach far"},
|
||||
{"undefined", "ace", keepWait("library:books"), libraryFactsWith(), "not defined"},
|
||||
{"not a library", "ace", keepWait("downloads"), libraryFactsWith(), "only a library waits"},
|
||||
{"a reason not known", "ace", inventory.Wait{Part: "radarr", Access: "movies", Reason: "later", What: "w"},
|
||||
libraryFactsWith(), "take it or for its disk"},
|
||||
{"libraries unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: libraryFactsWith().manifests},
|
||||
"could not be read"},
|
||||
} {
|
||||
err := checkWait("radarr", c.machine, c.w, c.f)
|
||||
switch {
|
||||
case c.says == "" && err != nil:
|
||||
t.Errorf("%s: refused: %v", c.name, err)
|
||||
case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)):
|
||||
t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func libraryWaiting(module string, w inventory.Wait) inventory.ResourceHealth {
|
||||
return inventory.ResourceHealth{Module: module, Resource: module + ".app", Kind: "container", Target: module,
|
||||
State: link.StateWaiting, Waits: []inventory.Wait{w}}
|
||||
}
|
||||
|
||||
// Rows 8 and 9 at the gate: both excused waits pass the first-node gate as a wait for a person, and the module is said
|
||||
// as needs-operator in words with no path; a wait on a library taken already does not pass.
|
||||
func TestALibraryWaitPassesTheGateAndAsksTheOperatorWithoutAPath(t *testing.T) {
|
||||
now := time.Now()
|
||||
for _, w := range []inventory.Wait{keepWait("movies"), diskWait("movies")} {
|
||||
w.What += " at /storage/media" // an engine's words carrying a path never reach the operator's
|
||||
f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{libraryWaiting("radarr", w)}}}}
|
||||
h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f)
|
||||
if h != healthPerson || !strings.Contains(why, "waits for the operator") || strings.Contains(why, "/") {
|
||||
t.Fatalf("an excused library wait reads %v %q; want a wait for a person, with no path", h, why)
|
||||
}
|
||||
o := needsOperatorObservation("radarr", "ace", []inventory.Wait{w}, nil)
|
||||
for _, words := range []string{o.Summary, o.Headline, o.Explanation, o.Needs} {
|
||||
if strings.Contains(words, "/") {
|
||||
t.Errorf("the operator reads a path: %q", words)
|
||||
}
|
||||
}
|
||||
want := "let the mesh keep the library entry movies when you are asked"
|
||||
if w.Disk {
|
||||
want = "mount the disk that holds the library entry movies on ace"
|
||||
}
|
||||
if o.Kind != kindNeedsOperator || !strings.Contains(o.Needs, want) {
|
||||
t.Errorf("needs-operator %s asks %q; want %q", o.Kind, o.Needs, want)
|
||||
}
|
||||
}
|
||||
f := gateFacts{now: now, waits: libraryFactsWith("media"), groupsAdded: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{libraryWaiting("radarr", keepWait("movies"))}}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet || !strings.Contains(why, "taken") {
|
||||
t.Fatalf("a wait on a taken library reads %v %q", h, why)
|
||||
}
|
||||
}
|
||||
|
||||
func takeRig(t *testing.T, waiting func() map[string][]waitingFolder) (*askerRig, *[]string) {
|
||||
r := newAskerRig(t)
|
||||
var taken []string
|
||||
libs := map[string]catalogue.Library{"media": mediaLib}
|
||||
r.a.folders = func(context.Context) ([]libraryTake, error) { return takesOf(waiting(), libs), nil }
|
||||
r.a.machines = func(context.Context) ([]string, error) { return []string{"ace", "novox"}, nil }
|
||||
r.a.take = func(_ context.Context, lt libraryTake, askID, by string) (string, error) {
|
||||
taken = append(taken, lt.Library+" "+lt.Node+" "+lt.Root+" "+lt.Placed+" "+askID+" "+by)
|
||||
return "taking", nil
|
||||
}
|
||||
return r, &taken
|
||||
}
|
||||
|
||||
func takeAsks(r *askerRig) []asked {
|
||||
var out []asked
|
||||
for _, a := range r.store {
|
||||
if a.Take != nil {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func openTakes(r *askerRig) []asked {
|
||||
var out []asked
|
||||
for _, a := range takeAsks(r) {
|
||||
if a.State == askOpen {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Row 7: one ask per library however many modules and machines wait on it, in the clause's words, at approve; none
|
||||
// again while it is open; taken back when the library no longer waits.
|
||||
func TestTheOperatorIsAskedOncePerLibrary(t *testing.T) {
|
||||
current := map[string][]waitingFolder{"media": {
|
||||
{module: "radarr", machine: "ace", placed: "fs-000000000001"},
|
||||
{module: "plex", machine: "novox"},
|
||||
{module: "", machine: "ace", placed: "fs-000000000001"}}}
|
||||
r, _ := takeRig(t, func() map[string][]waitingFolder { return current })
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
kept := takeAsks(r)
|
||||
if len(kept) != 1 {
|
||||
t.Fatalf("%d asks for one library", len(kept))
|
||||
}
|
||||
q := kept[0].Ask
|
||||
if err := q.Check(r.now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Highest() != asks.Approve || len(q.Options) != 1 {
|
||||
t.Fatalf("the ask offers %+v; want one answer at approve", q.Options)
|
||||
}
|
||||
// The root may not leave the mesh (the router's content rule): it is said as "its root".
|
||||
want := "Let the mesh keep library media at its root on ace: its group becomes mesh-media, the group may read " +
|
||||
"and write, folders keep the group; owners and everyone else's rights stay as they are. Waiting on it: plex, radarr."
|
||||
if q.Explanation != want {
|
||||
t.Errorf("the ask says\n %q\nwant\n %q", q.Explanation, want)
|
||||
}
|
||||
if kept[0].Take.Node != "ace" || kept[0].Take.Placed != "fs-000000000001" {
|
||||
t.Errorf("the take is for %+v; want the home and the filesystem its home names", kept[0].Take)
|
||||
}
|
||||
current = nil
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := takeAsks(r); len(got) != 1 || got[0].State != askCancelled {
|
||||
t.Fatalf("the ask about a library that no longer waits: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 7: nothing is taken without the operator's warrant — a warrant for another asker, an ask that expired, or a
|
||||
// record changed after the ask takes nothing; the warrant takes it once, on its home, naming library:<name>.
|
||||
func TestALibraryIsTakenOnlyOnTheWarrant(t *testing.T) {
|
||||
waits := func() map[string][]waitingFolder {
|
||||
return map[string][]waitingFolder{"media": {{module: "radarr", machine: "ace", placed: "fs-000000000001"}}}
|
||||
}
|
||||
ctx := context.Background()
|
||||
r, taken := takeRig(t, waits)
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := takeAsks(r)[0]
|
||||
other := warrantOn(a, "let-the-mesh-keep-it", r.now)
|
||||
other.Asker = "someone-else"
|
||||
answerWith(t, r, other)
|
||||
changed := r.store[a.ID]
|
||||
changed.Take = &libraryTake{Library: "media", Node: "ace", Root: "/", Modules: a.Take.Modules, Placed: a.Take.Placed}
|
||||
r.store[a.ID] = changed
|
||||
answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now))
|
||||
if len(*taken) != 0 || !strings.HasPrefix(r.store[a.ID].Acted, "failed") {
|
||||
t.Fatalf("a take whose record changed after the ask: %v, %q", *taken, r.store[a.ID].Acted)
|
||||
}
|
||||
|
||||
r2, taken2 := takeRig(t, waits)
|
||||
if err := r2.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := takeAsks(r2)[0]
|
||||
answerWith(t, r2, asks.Warrant{Ask: b.ID, Asker: "mesh-controller", Outcome: asks.OutcomeExpired, At: r2.now})
|
||||
if len(*taken2) != 0 {
|
||||
t.Fatal("taken on an ask that expired")
|
||||
}
|
||||
|
||||
r3, taken3 := takeRig(t, waits)
|
||||
if err := r3.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := takeAsks(r3)[0]
|
||||
w := warrantOn(c, "let-the-mesh-keep-it", r3.now)
|
||||
answerWith(t, r3, w)
|
||||
answerWith(t, r3, w)
|
||||
if len(*taken3) != 1 || !strings.HasPrefix((*taken3)[0], "media ace /storage/media fs-000000000001 "+c.ID) ||
|
||||
!strings.Contains((*taken3)[0], "telegram") {
|
||||
t.Fatalf("the warrant took %v", *taken3)
|
||||
}
|
||||
if !strings.HasPrefix(r3.store[c.ID].Acted, "done") {
|
||||
t.Fatalf("the take was recorded %q", r3.store[c.ID].Acted)
|
||||
}
|
||||
}
|
||||
|
||||
// A warrant for a library on one filesystem is never carried out on another: when it comes and the library waits on
|
||||
// another, nothing is sent, the ask is spent, and the operator is asked again for the filesystem it is on now.
|
||||
func TestAWarrantForOneFilesystemIsNotCarriedOutOnAnother(t *testing.T) {
|
||||
placed := "fs-000000000001"
|
||||
r, taken := takeRig(t, func() map[string][]waitingFolder {
|
||||
return map[string][]waitingFolder{"media": {{module: "radarr", machine: "ace", placed: placed}}}
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := takeAsks(r)[0]
|
||||
placed = "fs-0000000000ff"
|
||||
answerWith(t, r, warrantOn(a, "let-the-mesh-keep-it", r.now))
|
||||
if len(*taken) != 0 {
|
||||
t.Fatalf("a warrant for %s was carried out on %s: %v", a.Take.Placed, placed, *taken)
|
||||
}
|
||||
if spent := r.store[a.ID]; spent.State == askOpen || !strings.HasPrefix(spent.Acted, "nothing") {
|
||||
t.Fatalf("the ask after the library moved: %s, %q", spent.State, spent.Acted)
|
||||
}
|
||||
if err := r.a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again := openTakes(r); len(again) != 1 || again[0].Take.Placed != placed {
|
||||
t.Fatalf("not asked again for the filesystem it is on now: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// The take act names library:<name> and the warrant, is signed over its own context, and is recorded only on the
|
||||
// engine's word.
|
||||
func TestATakeAskNamesTheLibraryAndItsWarrant(t *testing.T) {
|
||||
_, key, _ := ed25519.GenerateKey(nil)
|
||||
signer := keySigner{key}
|
||||
ask := link.AccessActAsk{Node: "ace", Path: "/storage/media", By: "the operator", Access: "library:media"}
|
||||
if _, err := link.SignTake(context.Background(), signer, ask); err == nil {
|
||||
t.Fatal("a take with no warrant was signed")
|
||||
}
|
||||
ask.Warrant = "c0123456789abcdef"
|
||||
body, err := link.SignTake(context.Background(), signer, ask)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var signed link.SignedHandOver
|
||||
if err := json.Unmarshal(body, &signed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ed25519.Verify(key.Public().(ed25519.PublicKey), append([]byte(link.AccessActContext), signed.Ask...), signed.Signature) {
|
||||
t.Fatal("the take is not signed over its own context")
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(signed.Ask, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["act"] != "take" || got["warrant"] != "c0123456789abcdef" || got["access"] != "library:media" ||
|
||||
got["path"] != "/storage/media" {
|
||||
t.Fatalf("the ask carries %v", got)
|
||||
}
|
||||
var asked link.AccessActAsk
|
||||
refusing := takeFolderIn(nil, func(_ context.Context, a link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
asked = a
|
||||
return link.HandOverAnswer{Refused: "the disk is not there"}, nil
|
||||
})
|
||||
if _, err := refusing(context.Background(), libraryTake{Library: "media", Node: "ace", Root: "/storage/media"},
|
||||
"c01", "the operator"); err == nil {
|
||||
t.Fatal("a refused take was answered as done")
|
||||
}
|
||||
if asked.Access != "library:media" || asked.Node != "ace" || asked.Path != "/storage/media" || asked.Warrant != "c01" {
|
||||
t.Fatalf("the engine was asked %+v", asked)
|
||||
}
|
||||
unreachable := takeFolderIn(nil, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
return link.HandOverAnswer{}, errors.New("nothing on ace answers")
|
||||
})
|
||||
if _, err := unreachable(context.Background(), libraryTake{Library: "media", Node: "ace", Root: "/storage/media"},
|
||||
"c01", "the operator"); err == nil {
|
||||
t.Fatal("an unanswered take was answered as done")
|
||||
}
|
||||
}
|
||||
|
||||
type keySigner struct{ key ed25519.PrivateKey }
|
||||
|
||||
func (s keySigner) Sign(_ context.Context, b []byte) ([]byte, error) {
|
||||
return ed25519.Sign(s.key, b), nil
|
||||
}
|
||||
|
||||
// The envelope carries groups, identities and libraries only when composed — for an engine at link.LibraryContract —
|
||||
// and an older engine's body is what it was.
|
||||
func TestTheDeclarationSaysItsLibrariesOnlyWhenComposed(t *testing.T) {
|
||||
older, err := sendable{Resources: []map[string]any{{"id": "a"}}}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"groups", "identities", "libraries"} {
|
||||
if strings.Contains(string(older), key) {
|
||||
t.Fatalf("an older engine's body names %s: %s", key, older)
|
||||
}
|
||||
}
|
||||
newer, err := sendable{Resources: []map[string]any{{"id": "a"}},
|
||||
Groups: []catalogue.GroupEntry{{Name: "mesh-media", GID: 50010}},
|
||||
Identities: []catalogue.IdentityEntry{{Module: "radarr", Name: "mesh-radarr", UID: 50020, GID: 50010}},
|
||||
Libraries: []catalogue.LibraryEntry{{Library: "media", Root: "/storage/media", Group: "mesh-media", GID: 50010,
|
||||
Home: true, Paths: []string{"movies"}, Runner: "jochen"}}}.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"groups":[{"name":"mesh-media","gid":50010}]`,
|
||||
`"identities":[{"module":"radarr","name":"mesh-radarr","uid":50020,"gid":50010}]`,
|
||||
`"libraries":[{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,` +
|
||||
`"taken":false,"paths":["movies"],"runner":"jochen"}]`} {
|
||||
if !strings.Contains(string(newer), want) {
|
||||
t.Errorf("the body lacks %s: %s", want, newer)
|
||||
}
|
||||
}
|
||||
if link.LibraryContract != 6 {
|
||||
t.Fatalf("the contract the engine states for ADR 0328 is 6, and this controller reads %d", link.LibraryContract)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate: an access or library the engine cannot judge this look, or entries left while a disk was away, of a module
|
||||
// whose move leaves its accesses and libraries as they were, are not counted; of one whose move changes them — a
|
||||
// library added, or joining — or is not known, they hold the gate to its bound.
|
||||
func TestAnUnknownLibraryIsJudgedByWhetherTheMoveChangesIt(t *testing.T) {
|
||||
now := time.Now()
|
||||
since := now.Add(-time.Minute)
|
||||
unknown := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.library-movies", Kind: link.KindAccess,
|
||||
Target: "/storage/media/movies", State: link.StateUnknown, Since: now}
|
||||
left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries,
|
||||
Target: "/storage/media", State: link.StateUnhealthy, Since: since.Add(-time.Hour)}
|
||||
facts := func(changes map[string]bool, rs ...inventory.ResourceHealth) gateFacts {
|
||||
return gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: changes, health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now,
|
||||
Resources: rs}}}
|
||||
}
|
||||
unchanged := map[string]bool{"radarr": false}
|
||||
if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, unknown)); h != healthGood {
|
||||
t.Errorf("an unknown library of a move that changes nothing reads %v %q", h, why)
|
||||
}
|
||||
if h, why := moduleHealthWord("radarr", "ace", since, facts(unchanged, left)); h != healthPerson ||
|
||||
!strings.Contains(why, "access-gone~entries") {
|
||||
t.Errorf("entries left, a move that changes nothing: %v %q", h, why)
|
||||
}
|
||||
for name, changes := range map[string]map[string]bool{"changes them": {"radarr": true}, "not known": nil} {
|
||||
for _, r := range []inventory.ResourceHealth{unknown, left} {
|
||||
if h, _ := moduleHealthWord("radarr", "ace", since, facts(changes, r)); h != healthNotYet {
|
||||
t.Errorf("%s %s of a move that %s reads %v; want held to the bound", r.Kind, r.State, name, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
plain := catalogue.Manifest{Module: "radarr"}
|
||||
if sameAccessUse(plain, radarrManifest) || !sameAccessUse(radarrManifest, radarrManifest) {
|
||||
t.Fatal("a move that adds a library, and joins, reads unchanged — or an unchanged one reads changed")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine states a library once, of no module; each module assigned to the machine that uses it is judged and said
|
||||
// by a copy of that verdict — its wait excused and passed by the gate as a wait for a person. A library unknown or
|
||||
// starting (a take walking under it) of a move that changes no library is not counted.
|
||||
func TestALibrarysVerdictIsSaidForTheModulesThatUseIt(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []catalogue.Manifest{radarrManifest, plexManifest, {Module: "plain", Version: "1"}} {
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Module == "plex" {
|
||||
continue // pulled in by a requirement, not assigned: it is in what the machine was sent (item 4)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "ace", m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
record, err := inv.NodeByName(ctx, "ace")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSentWith(ctx, record.ID, "d1", map[string]string{"radarr": "c1", "plex": "c2", "plain": "c3"},
|
||||
1, 1, inventory.Send{Sequence: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := inventory.ResourceHealth{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media",
|
||||
State: link.StateWaiting, Waits: []inventory.Wait{{Part: "media", Access: "library:media", Reason: link.WaitKeep,
|
||||
Placed: "fs-000000000001", What: "let the mesh keep the library media"}}}
|
||||
copies := libraryVerdictsFor(ctx, inv, "ace", []inventory.ResourceHealth{lib})
|
||||
var modules []string
|
||||
for _, c := range copies {
|
||||
modules = append(modules, c.Module)
|
||||
}
|
||||
if strings.Join(modules, ",") != "plex,radarr" {
|
||||
t.Fatalf("the library's verdict is said for %v; want plex and radarr, the modules using it", modules)
|
||||
}
|
||||
paths, err := inv.LibraryPaths(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(paths["media"], ",") != "movies" {
|
||||
t.Fatalf("the paths kept of media are %v", paths["media"])
|
||||
}
|
||||
now := time.Now()
|
||||
f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, Resources: copies}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthPerson {
|
||||
t.Fatalf("radarr waiting on media reads %v %q; want a wait for a person", h, why)
|
||||
}
|
||||
for _, state := range []string{link.StateUnknown, link.StateStarting} {
|
||||
c := copies[1]
|
||||
c.State, c.Waits = state, nil
|
||||
f.health["ace"] = inventory.NodeHealth{Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{c}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthGood {
|
||||
t.Errorf("media %s, a move that changes no library: %v %q", state, h, why)
|
||||
}
|
||||
f.accessesChanged = map[string]bool{"radarr": true}
|
||||
if h, _ := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet {
|
||||
t.Errorf("media %s, a move that changes a library: %v; want held to the bound", state, h)
|
||||
}
|
||||
f.accessesChanged = map[string]bool{"radarr": false}
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 7ea6d36e, item 5: the engine saying it started a take does not make the library taken. While the act
|
||||
// runs the library still waits and the operator is not asked again; it is taken when the engine states the act done
|
||||
// under that warrant.
|
||||
func TestALibraryIsTakenOnlyWhenTheEngineSaysTheActIsDone(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1"})
|
||||
if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, map[string]any{catalogue.LibrariesSetting: map[string]any{
|
||||
"media": map[string]any{"home": "anchor", "root": "/storage/media"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
take := takeFolderIn(open, func(context.Context, link.AccessActAsk) (link.HandOverAnswer, error) {
|
||||
return link.HandOverAnswer{Said: "the take of library:media started"}, nil
|
||||
})
|
||||
lt := libraryTake{Library: "media", Node: "anchor", Root: "/storage/media", Placed: "fs-000000000001"}
|
||||
if _, err := take(ctx, lt, "c01", "the operator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taken, err := librariesTaken(ctx, inv, libs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken["media"] {
|
||||
t.Fatal("a take only started made the library taken")
|
||||
}
|
||||
// While it runs: the wait is still excused, and no new ask is made for it.
|
||||
var f operatorWaitFacts
|
||||
readWaitFacts(ctx, inv, "anchor", nil, nil, &f)
|
||||
if err := checkWait("radarr", "anchor", placedWait("library:media", "fs-000000000001"), f); err != nil {
|
||||
t.Fatalf("the wait while the take runs: %v", err)
|
||||
}
|
||||
if got := withoutTakesUnderWay([]libraryTake{lt}, f.taken); len(got) != 0 {
|
||||
t.Fatalf("a library whose take runs is asked about again: %+v", got)
|
||||
}
|
||||
// The engine states the act done under that warrant.
|
||||
act, _ := json.Marshal(map[string]any{"act": "take", "access": "library:media", "warrant": "c01", "state": "done"})
|
||||
if err := stateHealth(ctx, inv, nil, "anchor", link.Health{Contract: link.LibraryContract, At: time.Now(),
|
||||
Resources: []link.ResourceHealth{{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media",
|
||||
State: link.StateHealthy, Since: time.Now(), Act: act}}}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, err = librariesTaken(ctx, inv, libs); err != nil || !taken["media"] {
|
||||
t.Fatalf("the act done did not make the library taken (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0334: the engine states the tool runner's membership as resources of kind runner — `runner:<module>` of that
|
||||
// module, `runner:library:<name>` of no module. A library's is said for the modules that use it; neither fails the
|
||||
// first-node gate of a move that leaves the module's libraries as they were, and both hold one that changes them.
|
||||
func TestARunnersVerdictIsSaidAndJudgedByWhetherTheMoveChangesIt(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
if _, err := inv.AddNode(ctx, "ace"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []catalogue.Manifest{radarrManifest, plexManifest} {
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "ace", m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
lib := inventory.ResourceHealth{Resource: "runner:library:media", Kind: link.KindRunner, Target: "jochen",
|
||||
State: link.StateUnhealthy, Reason: "the tool runner is not in mesh-media yet", Since: time.Now()}
|
||||
copies := libraryVerdictsFor(ctx, inv, "ace", []inventory.ResourceHealth{lib})
|
||||
if len(copies) != 2 || copies[0].Module != "plex" || copies[1].Module != "radarr" {
|
||||
t.Fatalf("the runner's library verdict is said for %+v; want plex and radarr", copies)
|
||||
}
|
||||
own := inventory.ResourceHealth{Module: "radarr", Resource: "runner:radarr", Kind: link.KindRunner, Target: "jochen",
|
||||
State: link.StateUnhealthy, Reason: "the tool runner is not in mesh-radarr yet", Since: time.Now()}
|
||||
now := time.Now()
|
||||
for _, r := range []inventory.ResourceHealth{copies[1], own} {
|
||||
f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false},
|
||||
accessesChanged: map[string]bool{"radarr": false},
|
||||
health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{r}}}}
|
||||
if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthGood {
|
||||
t.Errorf("%s, a move that changes no library: %v %q; want not counted", r.Resource, h, why)
|
||||
}
|
||||
f.accessesChanged = map[string]bool{"radarr": true}
|
||||
if h, _ := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet {
|
||||
t.Errorf("%s, a move that changes a library: %v; want held to the bound", r.Resource, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -40,6 +42,96 @@ const (
|
||||
moduleUnhealthyAfter = 2
|
||||
)
|
||||
|
||||
// runnerLibrary prefixes the engine's verdict on the tool runner's membership of a library's group (ADR 0334).
|
||||
const runnerLibrary = "runner:library:"
|
||||
|
||||
// libraryVerdictsFor is, for each library verdict in a statement (kind library, no module), one copy for each module
|
||||
// on the machine — assigned, or pulled in by a requirement — whose manifest uses that library: what the module is judged and said by. None when the
|
||||
// assignments or the catalogue cannot be read: the library's own verdict stands alone, and no module is excused by it.
|
||||
func libraryVerdictsFor(ctx context.Context, inv *inventory.Inventory, node string,
|
||||
rs []inventory.ResourceHealth) []inventory.ResourceHealth {
|
||||
var libraries []inventory.ResourceHealth
|
||||
for _, r := range rs {
|
||||
if r.Module == "" && ((r.Kind == link.KindLibrary && strings.HasPrefix(r.Resource, libraryAccess)) ||
|
||||
(r.Kind == link.KindRunner && strings.HasPrefix(r.Resource, runnerLibrary))) {
|
||||
libraries = append(libraries, r)
|
||||
}
|
||||
}
|
||||
if len(libraries) == 0 || inv == nil {
|
||||
return nil
|
||||
}
|
||||
assigned, err := inv.Assigned(ctx, node)
|
||||
if err != nil {
|
||||
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
|
||||
return nil
|
||||
}
|
||||
// And the modules a requirement pulled in: what the machine was last sent (the review of 7ea6d36e, item 4).
|
||||
sent, _, err := inv.SentBuilds(ctx, node)
|
||||
if err != nil {
|
||||
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
|
||||
return nil
|
||||
}
|
||||
for m := range sent {
|
||||
if !slices.Contains(assigned, m) {
|
||||
assigned = append(assigned, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(assigned)
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
// empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make
|
||||
return nil
|
||||
}
|
||||
var out []inventory.ResourceHealth
|
||||
for _, lib := range libraries {
|
||||
name := strings.TrimPrefix(strings.TrimPrefix(lib.Resource, runnerLibrary), libraryAccess)
|
||||
for _, module := range assigned {
|
||||
for _, u := range shelf[module].Libraries {
|
||||
if u.Library == name {
|
||||
copied := lib
|
||||
copied.Module = module
|
||||
out = append(out, copied)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// takesStated keeps what a statement says of each library's take act: done under a warrant, the library is taken;
|
||||
// failed, the take is forgotten and the operator asked again. Running or stopped (it goes on) changes nothing.
|
||||
func takesStated(ctx context.Context, inv *inventory.Inventory, rs []link.ResourceHealth) error {
|
||||
if inv == nil {
|
||||
return nil
|
||||
}
|
||||
for _, r := range rs {
|
||||
lib, isLibrary := strings.CutPrefix(r.Resource, libraryAccess)
|
||||
if !isLibrary || r.Kind != link.KindLibrary || len(r.Act) == 0 {
|
||||
continue
|
||||
}
|
||||
var act struct {
|
||||
Act string `json:"act"`
|
||||
Warrant string `json:"warrant"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Act, &act); err != nil || act.Act != link.ActTake || act.Warrant == "" {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
switch act.State {
|
||||
case "done":
|
||||
err = inv.TakeDone(ctx, lib, act.Warrant)
|
||||
case "failed":
|
||||
err = inv.TakeFailed(ctx, lib, act.Warrant)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthRefused counts the statements refused as older than the one kept, for the log and a test.
|
||||
var healthRefused atomic.Int64
|
||||
|
||||
@@ -77,8 +169,22 @@ 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, What: w.What})
|
||||
}
|
||||
resources = append(resources, kept)
|
||||
}
|
||||
// **A library's take is done, or failed, when its engine says so** (novox/hq ADR 0328 §7): only then is it taken,
|
||||
// or asked about again.
|
||||
if err := takesStated(ctx, inv, h.Resources); err != nil {
|
||||
return err
|
||||
}
|
||||
// **A library's verdict is said for the modules that use it** (novox/hq ADR 0328 §8): the engine states each
|
||||
// library once, as `library:<name>`, of no module; a module that uses it waits with it, and is said so.
|
||||
resources = append(resources, libraryVerdictsFor(ctx, inv, node, resources)...)
|
||||
// **A wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose
|
||||
// wait does not check out is judged unhealthy, saying why; one that does is kept beside the unhealthy ones, so
|
||||
// judgeModuleHealth can say it as needs-operator. What is stored is what the machine said.
|
||||
@@ -644,7 +750,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 +773,32 @@ 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 and their users as they were (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. A move that changes the accesses is held to them, whenever they
|
||||
// were stated (the review of e1c2b744).
|
||||
changed, known := f.accessesChanged[module]
|
||||
changes := !known || changed
|
||||
if r.Kind == link.KindKeptEntries && r.State == link.StateUnhealthy && !changes {
|
||||
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.
|
||||
// The same for a library (ADR 0328): unknown this look, or starting while a take walks under it, of a move that
|
||||
// leaves the module's libraries as they were, is not this move's.
|
||||
if (r.Kind == link.KindAccess || r.Kind == link.KindLibrary) && !changes &&
|
||||
(r.State == link.StateUnknown || (r.Kind == link.KindLibrary && r.State == link.StateStarting)) {
|
||||
continue
|
||||
}
|
||||
// The tool runner's membership (ADR 0334 §2), of a move that changes no library of the module, is not this
|
||||
// move's: it is said as the module's condition, and holds a move that changes them to the gate's bound.
|
||||
if r.Kind == link.KindRunner && !changes {
|
||||
continue
|
||||
}
|
||||
switch r.State {
|
||||
case link.StateHealthy:
|
||||
case link.StateStarting:
|
||||
@@ -690,7 +822,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 +834,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, ""
|
||||
|
||||
@@ -127,9 +127,14 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
// and whoever may call a verb includes agents.
|
||||
return nodeSetuidSearch(ctx, open, args[1:])
|
||||
|
||||
case "judge-directories":
|
||||
// Which of a node's declared directories the walk that follows no link another account could make would
|
||||
// refuse (novox/hq issue 358). Read-only on the node and here, so a verb may run it too.
|
||||
return nodeJudgeDirectories(ctx, open, args[1:])
|
||||
|
||||
default:
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over "+
|
||||
"and setuid-search", args[0])
|
||||
return fmt.Errorf("node has no %q; it has add, list, show, public-domain, account, agent-account, hand-over, "+
|
||||
"setuid-search and judge-directories", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,3 +942,57 @@ func printJSONTo(w io.Writer, v any) error {
|
||||
fmt.Fprintln(w, string(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
const judgeDirectoriesUsage = "node judge-directories <node> — read-only: which of the directories <node> is " +
|
||||
"declared the node-engine's walk that follows no link another account could make would refuse, one line each " +
|
||||
"(novox/hq issue 358). Nothing is made, chmodded or chowned"
|
||||
|
||||
// nodeJudgeDirectories asks the node's engine to judge its declared directories, signed with the mesh's key as a
|
||||
// hand-over is. Read-only: the engine changes nothing and keeps nothing.
|
||||
func nodeJudgeDirectories(ctx context.Context, open *stores, args []string) error {
|
||||
known := func(node string) error {
|
||||
_, err := open.inventory.NodeByName(ctx, node)
|
||||
return err
|
||||
}
|
||||
ask := func(node, by string) (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",
|
||||
node, err)
|
||||
}
|
||||
address, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, err
|
||||
}
|
||||
js, err := broker.Dial(address)
|
||||
if err != nil {
|
||||
return link.HandOverAnswer{}, fmt.Errorf("cannot reach the bus, so nothing was asked of %s: %w", node, err)
|
||||
}
|
||||
defer js.Close()
|
||||
return link.AskJudgeDirectories(ctx, js.Conn(), ident, node, by, link.HandOverWithin)
|
||||
}
|
||||
return judgeDirectoriesAsked(args, known, ask, os.Stdout)
|
||||
}
|
||||
|
||||
// judgeDirectoriesAsked is the line with its two acts given: whether the mesh knows the node, and the ask. The
|
||||
// engine's refusal is this command's failure.
|
||||
func judgeDirectoriesAsked(args []string, known func(node string) error,
|
||||
ask func(node, by string) (link.HandOverAnswer, error), out io.Writer) error {
|
||||
if len(args) != 1 || args[0] == "" || strings.HasPrefix(args[0], "-") {
|
||||
return errors.New(judgeDirectoriesUsage)
|
||||
}
|
||||
node := args[0]
|
||||
if err := known(node); err != nil {
|
||||
return fmt.Errorf("nothing was asked: %w", err)
|
||||
}
|
||||
answer, err := ask(node, handOverBy())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Refused != "" {
|
||||
return fmt.Errorf("%s refused: %s", node, answer.Refused)
|
||||
}
|
||||
fmt.Fprintf(out, "%s (read-only: nothing on %s was changed)\n", node, node)
|
||||
fmt.Fprintln(out, answer.Said)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// novox/hq issue 237: every line of an act's answer comes from one resolution of the node taken after
|
||||
// its write, so the answer agrees with `plan` for the node right after.
|
||||
|
||||
func aProviderAndItsConsumer(t *testing.T, open *stores) {
|
||||
t.Helper()
|
||||
register(t, open, catalogue.Manifest{Module: "db", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "rows", Scope: catalogue.ScopeNode}}})
|
||||
register(t, open, catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"rows"}})
|
||||
}
|
||||
|
||||
// twoRivalsAndANote is a node that does not resolve — two modules claim one node seat there — and a
|
||||
// module beside them that changes nothing about it.
|
||||
func twoRivalsAndANote(t *testing.T, open *stores) {
|
||||
t.Helper()
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1"})
|
||||
}
|
||||
|
||||
func TestAnUnassignThatLeavesItsNodeUnresolvedSaysSoOnceInItsAnswer(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
twoRivalsAndANote(t, open)
|
||||
// Straight into the store, as a node that came to this before the act.
|
||||
for _, m := range []string{"rival-one", "rival-two", "notes"} {
|
||||
if _, err := open.inventory.Assign(ctx, "laptop", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
said := answered(t, func() error { return assignCommand(ctx, "unassign", []string{"laptop", "notes"}) })
|
||||
|
||||
if _, _, err := planFor(ctx, open, "laptop"); err == nil || !unresolvable(err) {
|
||||
t.Fatalf("laptop resolves with two claimants of one seat: %v", err)
|
||||
}
|
||||
if n := strings.Count(said, "laptop does not resolve now"); n != 1 {
|
||||
t.Errorf("the answer says laptop does not resolve %d times, want once:\n%s", n, said)
|
||||
}
|
||||
if strings.Contains(said, "is left out of the rest of the mesh") {
|
||||
t.Errorf("the answer carries a line from a read that is not its resolution:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnassignThatLeavesItsNodeResolvedSaysNothingOfIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
aProviderAndItsConsumer(t, open)
|
||||
if _, err := assign(ctx, open, "laptop", "db", "app"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := unassign(ctx, open, "laptop", "app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := planFor(ctx, open, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(said, "does not resolve") || strings.Contains(said, "could not be read") {
|
||||
t.Errorf("a node that resolves is said not to:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAssignThatLeavesItsNodeUnresolvedSaysOnlyThePlansRefusal(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
twoRivalsAndANote(t, open)
|
||||
if _, err := assign(ctx, open, "laptop", "rival-one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Kept and not resolvable: a second claimant of the seat, which assignment keeps.
|
||||
said, err := assign(ctx, open, "laptop", "rival-two")
|
||||
if err == nil {
|
||||
t.Fatalf("two claimants of one node seat resolved:\n%s", said)
|
||||
}
|
||||
_, _, planErr := planFor(ctx, open, "laptop")
|
||||
if planErr == nil || planErr.Error() != err.Error() {
|
||||
t.Errorf("the act's refusal is not the plan's right after:\nact: %v\nplan: %v", err, planErr)
|
||||
}
|
||||
if strings.Contains(said, "now has") {
|
||||
t.Errorf("the answer says a seat is now held on a node that does not resolve:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// The other readers of the whole mesh still say a machine they leave out, on standard error (novox/hq
|
||||
// issue 188); an act sends it elsewhere.
|
||||
func TestAMachineLeftOutIsSaidWhereTheContextSays(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sayLeftOut(leftOutSaidBy(context.Background(), &buf), "laptop", errors.New("why"))
|
||||
if buf.String() != "laptop is left out of the rest of the mesh: it does not resolve: why\n" {
|
||||
t.Errorf("said %q", buf.String())
|
||||
}
|
||||
got := answered(t, func() error {
|
||||
sayLeftOut(context.Background(), "laptop", errors.New("why"))
|
||||
return nil
|
||||
})
|
||||
if !strings.Contains(got, "laptop is left out") {
|
||||
t.Errorf("without an act's writer the line did not reach standard error: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -44,17 +44,34 @@ func needsOperatorKey(module, node string) string {
|
||||
type operatorWaitFacts struct {
|
||||
manifests map[string]catalogue.Manifest
|
||||
given map[string]map[string]time.Time
|
||||
// libraries is every library the operator defined, and taken every one taken (novox/hq ADR 0328 §7, §8);
|
||||
// librariesRead says both were read — when not, no access wait is excused.
|
||||
libraries map[string]catalogue.Library
|
||||
taken map[string]inventory.TakenLibrary
|
||||
librariesRead bool
|
||||
}
|
||||
|
||||
// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure.
|
||||
func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) error {
|
||||
// A library's own wait names it, and no module's manifest (novox/hq ADR 0328 §7).
|
||||
if lib, ok := strings.CutPrefix(w.Access, libraryAccess); ok && w.Secret == "" && w.Setting == "" {
|
||||
return checkLibraryWait(machine, lib, w, f)
|
||||
}
|
||||
m, known := f.manifests[module]
|
||||
if !known {
|
||||
return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module)
|
||||
}
|
||||
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 +97,63 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// libraryAccess prefixes the access a library's own wait and take name: "library:<name>".
|
||||
const libraryAccess = "library:"
|
||||
|
||||
// checkAccessWait is nil when a module's access wait is excused (novox/hq ADR 0328 §8, §9): the access is one of its
|
||||
// library entries, and its library's wait is excused. Any other access waits for nothing the operator gives: an
|
||||
// access that is not a library keeps ADR 0051's meaning.
|
||||
func checkAccessWait(module, machine string, m catalogue.Manifest, w inventory.Wait, f operatorWaitFacts) error {
|
||||
lib := catalogue.LibraryOfEntry(m, w.Access)
|
||||
if lib == "" {
|
||||
return fmt.Errorf("says it waits for %s, which is no library entry %s declares: only a library waits for "+
|
||||
"the operator (novox/hq ADR 0328 §8)", w.Access, module)
|
||||
}
|
||||
return checkLibraryWait(machine, lib, w, f)
|
||||
}
|
||||
|
||||
// checkLibraryWait is nil when a wait on a library is excused (novox/hq ADR 0328 §8, §9): the library is defined and
|
||||
// reaches the machine, and either its disk is not there (reason disk) or it has not yet been taken on its home, on the
|
||||
// filesystem the wait names (reason keep). Any other is judged unhealthy, saying why: a module that uses a library
|
||||
// already taken is never asked about it.
|
||||
func checkLibraryWait(machine, lib string, w inventory.Wait, f operatorWaitFacts) error {
|
||||
if !f.librariesRead {
|
||||
return fmt.Errorf("says it waits for the library %s, and the libraries could not be read", lib)
|
||||
}
|
||||
l, defined := f.libraries[lib]
|
||||
switch {
|
||||
case !defined:
|
||||
return fmt.Errorf("says it waits for the library %s, which is not defined", lib)
|
||||
case !l.Reaches(machine):
|
||||
return fmt.Errorf("says it waits for the library %s, which does not reach %s", lib, machine)
|
||||
case w.Reason == link.WaitDisk || (w.Reason == "" && w.Disk):
|
||||
return nil
|
||||
case w.Reason != link.WaitKeep:
|
||||
return fmt.Errorf("says it waits for the library %s for %q, and a library waits for the operator's word to "+
|
||||
"take it or for its disk", lib, w.Reason)
|
||||
}
|
||||
// Taken on another home or filesystem (it moved) is not taken by that warrant: excused, and asked again (§7).
|
||||
// Only the home's engine names the root's filesystem; another machine's wait names its mount, and says nothing of it.
|
||||
placed := w.Placed
|
||||
if machine != l.Home {
|
||||
placed = ""
|
||||
}
|
||||
if t, was := f.taken[lib]; was && t.Done && t.Home == l.Home && t.Root == l.Root &&
|
||||
(placed == "" || t.Placed == "" || t.Placed == placed) {
|
||||
return fmt.Errorf("says it waits for the library %s, which is taken already: a module that uses a taken "+
|
||||
"library is never asked about it", lib)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// accessWords is an access a wait names, in words with no path: "the library media", or "its library entry movies".
|
||||
func accessWords(access string) string {
|
||||
if lib, ok := strings.CutPrefix(access, libraryAccess); ok {
|
||||
return "the library " + lib
|
||||
}
|
||||
return "the library entry " + access
|
||||
}
|
||||
|
||||
// waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own.
|
||||
const waitRefusedPrefix = "says it waits"
|
||||
|
||||
@@ -88,6 +162,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 +225,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", accessWords(w.Access), waitNames(w))
|
||||
case w.Access != "":
|
||||
part = fmt.Sprintf("leave to keep %s (%s, asked of you once for the library", accessWords(w.Access),
|
||||
waitNames(w))
|
||||
}
|
||||
if w.Secret != "" {
|
||||
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret)
|
||||
}
|
||||
@@ -180,6 +266,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", accessWords(w.Access), node)
|
||||
case w.Access != "":
|
||||
act = fmt.Sprintf("let the mesh keep %s when you are asked", accessWords(w.Access))
|
||||
}
|
||||
if act != "" && !seen[act] {
|
||||
seen[act] = true
|
||||
@@ -217,6 +308,7 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
||||
if f.given == nil {
|
||||
f.given = map[string]map[string]time.Time{}
|
||||
}
|
||||
|
||||
if inv == nil {
|
||||
return
|
||||
}
|
||||
@@ -245,6 +337,14 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
|
||||
f.given[module+"@"+machine] = given
|
||||
}
|
||||
}
|
||||
// The libraries and which are taken (ADR 0328 §7, §8): read once.
|
||||
if !f.librariesRead {
|
||||
libs, lerr := inv.Libraries(ctx)
|
||||
taken, terr := inv.TakenLibraries(ctx)
|
||||
if lerr == nil && terr == nil {
|
||||
f.libraries, f.taken, f.librariesRead = libs, taken, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
@@ -175,6 +182,20 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
"register it, so it was not made.", module, m),
|
||||
Resolved: "Resolved: " + module + " on " + m + " is settled"}
|
||||
}),
|
||||
// The controller or the node-engine kept at the build a machine was last sent, over an older record (novox/hq
|
||||
// issue 201).
|
||||
kindCoreDowngrade: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
component := idPart(o, 0)
|
||||
if o.Scope != conditions.ScopeCore || !guarded(component) {
|
||||
component = "core component"
|
||||
}
|
||||
return words{Headline: m + " keeps a newer " + component + " than the mesh holds",
|
||||
Needs: fmt.Sprintf("push %s to take the older build, or a newer merge or rebuild moves the record forward.", m),
|
||||
Explanation: fmt.Sprintf("The mesh's record of its own %s is older than the build %s was last sent, so "+
|
||||
"the mesh's sends keep the newer one there rather than put it back.", component, m),
|
||||
Resolved: "Resolved: " + m + " runs the " + component + " the mesh holds"}
|
||||
}),
|
||||
kindBindingKept: worded(func(o conditions.Observation) words {
|
||||
m := machineOr(o, "a machine")
|
||||
return words{Headline: "A module's data source is held on " + m,
|
||||
|
||||
+254
-26
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -133,6 +134,11 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
if _, err := keepRecorded(ctx, open, nodeName, shelf); err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
// And a module that may not join yet, at the build it ran before (novox/hq ADR 0328 §8, ADR 0334).
|
||||
held, err := holdJoining(ctx, inv, nodeName, shelf)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
|
||||
resolved, err := catalogue.Resolve(shelf, assigned,
|
||||
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
|
||||
@@ -218,6 +224,10 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
}
|
||||
settings[m.Module] = layers
|
||||
}
|
||||
// A held module with the settings it last ran with (ADR 0328 §8).
|
||||
if err := heldSettings(ctx, inv, nodeName, held, settings); err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
return resolved, settings, nil
|
||||
}
|
||||
|
||||
@@ -292,7 +302,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
||||
if err != nil {
|
||||
// Said, not skipped: a machine dropped here offers nothing and holds nothing as far
|
||||
// as every other machine's plan can tell (novox/hq issue 188).
|
||||
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
||||
sayLeftOut(ctx, o.node.Name, err)
|
||||
continue
|
||||
}
|
||||
firstHeld = append(firstHeld, got.Claims...)
|
||||
@@ -337,7 +347,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
||||
// resolved without its offers, and one that consumes them cannot resolve here by
|
||||
// design — that is not the machine being dropped, it is the view being partial.
|
||||
if exclude == "" {
|
||||
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
||||
sayLeftOut(ctx, o.node.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -347,6 +357,35 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
||||
return world, nil
|
||||
}
|
||||
|
||||
// leftOutKey carries where theRestOfTheMesh says a machine it leaves out, for an act that answers with
|
||||
// its own resolution instead (novox/hq issue 237).
|
||||
type leftOutKey struct{}
|
||||
|
||||
// leftOutSaidBy is ctx with theRestOfTheMesh's "left out" lines written to w rather than to standard
|
||||
// error.
|
||||
//
|
||||
// **An act's answer is one resolution taken after its write** (novox/hq issue 237, decided 2026-10-11).
|
||||
// `assign` read the whole mesh once before its write — to record the seats held by derivation — and that
|
||||
// read said, on standard error, that the node did not resolve for lack of the seat the act was about to
|
||||
// give it. The controller seat's verb answers with both streams, so the stale line came last, after the
|
||||
// act's own "now has it held", and named as its remedy the assignment just made. An act gives io.Discard
|
||||
// here: what the node resolves to after the write is said by its own planFor, and every other machine
|
||||
// that does not resolve by blockedElsewhere, both read after the write.
|
||||
func leftOutSaidBy(ctx context.Context, w io.Writer) context.Context {
|
||||
return context.WithValue(ctx, leftOutKey{}, w)
|
||||
}
|
||||
|
||||
// sayLeftOut says that a machine is left out of the rest of the mesh, where ctx says to: standard
|
||||
// error unless an act said otherwise. Said, not skipped: a machine dropped there offers nothing and
|
||||
// holds nothing as far as every other machine's plan can tell (novox/hq issue 188).
|
||||
func sayLeftOut(ctx context.Context, node string, err error) {
|
||||
w, ok := ctx.Value(leftOutKey{}).(io.Writer)
|
||||
if !ok {
|
||||
w = os.Stderr
|
||||
}
|
||||
fmt.Fprintf(w, "%s is left out of the rest of the mesh: it does not resolve: %v\n", node, err)
|
||||
}
|
||||
|
||||
// declarationFor is everything a node would be sent.
|
||||
//
|
||||
// One place, because there were three and one of them was written before credentials existed and
|
||||
@@ -418,7 +457,8 @@ 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, Groups: composed.Groups,
|
||||
Identities: composed.Identities, Libraries: composed.Libraries,
|
||||
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,
|
||||
@@ -442,11 +482,22 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
||||
names = append(names, m.Module)
|
||||
}
|
||||
out.Builds = carriedBuilds(names, composed.LeftOut, current, before)
|
||||
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245).
|
||||
// A recorded module kept at the build the machine runs is recorded as carrying that one (ADR 0245), and so
|
||||
// is a joining module held at the build it ran before (ADR 0328 §8).
|
||||
kept, err := recordedKept(ctx, open, node)
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
}
|
||||
held, err := holdJoining(ctx, open.inventory, node, nil)
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
}
|
||||
if kept == nil && len(held) > 0 {
|
||||
kept = map[string]string{}
|
||||
}
|
||||
for m, h := range held {
|
||||
kept[m] = h.commit
|
||||
}
|
||||
for m, was := range kept {
|
||||
if _, carried := out.Builds[m]; carried {
|
||||
out.Builds[m] = was
|
||||
@@ -561,6 +612,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,
|
||||
@@ -679,10 +737,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
|
||||
}
|
||||
@@ -692,7 +747,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
|
||||
@@ -918,9 +997,17 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
// The libraries, the numbers of the modules that join and of the libraries' groups, and which libraries are taken
|
||||
// (novox/hq ADR 0328), to an engine that keeps libraries.
|
||||
lf, err := libraryFacts(ctx, inv, node, plan, choosing)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
KeepsLibraries: lf.keeps, Identities: lf.identities, Libraries: lf.libraries, LibraryGroups: lf.groups,
|
||||
TakenLibraries: lf.taken, LibraryPaths: lf.paths, RunnerJoinsOwnGroups: lf.ownGroups,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
@@ -953,6 +1040,132 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
|
||||
return had && stated.Contract >= link.RootContract, nil
|
||||
}
|
||||
|
||||
// engineKeepsLibraries says whether a machine's node-engine makes the mesh's users and library groups, keeps
|
||||
// libraries, and keeps a joining module's own groups (novox/hq ADR 0328, ADR 0334: its statement's contract is
|
||||
// link.OwnGroupContract or later), by its own newest statement. On an older one no module joins: the libraries
|
||||
// alone are not enough, since its tools would lose their read of a joining module's directories.
|
||||
func engineKeepsLibraries(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.OwnGroupContract, nil
|
||||
}
|
||||
|
||||
// libraryRendering is what a machine's composition needs of ADR 0328.
|
||||
type libraryRendering struct {
|
||||
keeps bool
|
||||
identities map[string]catalogue.ModuleIdentity
|
||||
libraries map[string]catalogue.Library
|
||||
groups map[string]int
|
||||
taken map[string]bool
|
||||
paths map[string][]string
|
||||
ownGroups bool
|
||||
}
|
||||
|
||||
// libraryFacts reads what a machine's composition needs of ADR 0328: the libraries and their group numbers, the
|
||||
// numbers of the modules here that join, which libraries are taken, and the sub-paths every module asks of each. **A number is allocated only on the send path** (Allocating),
|
||||
// as a port is: a question about what would be sent reads what was allocated. The libraries are read for every engine,
|
||||
// so a module that joins is left out, naming why, where its engine cannot be told it does.
|
||||
func libraryFacts(ctx context.Context, inv *inventory.Inventory, node string, plan catalogue.Resolution,
|
||||
choosing Choosing) (libraryRendering, error) {
|
||||
var lf libraryRendering
|
||||
joining := false
|
||||
for _, m := range plan.Modules {
|
||||
joining = joining || catalogue.Joins(m)
|
||||
}
|
||||
libs, err := inv.Libraries(ctx)
|
||||
if err != nil {
|
||||
return lf, err
|
||||
}
|
||||
reached := false
|
||||
for _, l := range libs {
|
||||
reached = reached || l.Reaches(node)
|
||||
}
|
||||
if !joining && !reached {
|
||||
return lf, nil // nothing here joins, and no library reaches it: composed exactly as before (§14)
|
||||
}
|
||||
lf.libraries = libs
|
||||
if lf.keeps, err = engineKeepsLibraries(ctx, inv, node); err != nil || !lf.keeps {
|
||||
return lf, err
|
||||
}
|
||||
lf.ownGroups = true // a module joins only on an engine that also keeps its own groups (ADR 0334)
|
||||
lf.identities = map[string]catalogue.ModuleIdentity{}
|
||||
for _, m := range plan.Modules {
|
||||
if !catalogue.Joins(m) {
|
||||
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 lf, err
|
||||
}
|
||||
if had {
|
||||
lf.identities[m.Module] = id
|
||||
}
|
||||
}
|
||||
if lf.groups, err = inv.LibraryGroups(ctx); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
if choosing == Allocating {
|
||||
for name, l := range libs {
|
||||
if _, has := lf.groups[name]; has || !l.Reaches(node) {
|
||||
continue
|
||||
}
|
||||
if lf.groups[name], err = inv.AllocateLibraryGroup(ctx, name); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if lf.paths, err = inv.LibraryPaths(ctx); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
if lf.taken, err = librariesTaken(ctx, inv, libs); err != nil {
|
||||
return lf, err
|
||||
}
|
||||
return lf, nil
|
||||
}
|
||||
|
||||
// librariesTaken is which libraries are taken now (§7): recorded taken on the home they have, on the filesystem their
|
||||
// home's newest statement names — or stated kept by their home's engine, which keeps one it found empty without asking.
|
||||
func librariesTaken(ctx context.Context, inv *inventory.Inventory, libs map[string]catalogue.Library) (map[string]bool, error) {
|
||||
recorded, err := inv.TakenLibraries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for name, l := range libs {
|
||||
t, was := recorded[name]
|
||||
taken := was && t.Done && t.Home == l.Home && t.Root == l.Root
|
||||
stated, had, err := inv.HealthOf(ctx, l.Home)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if had {
|
||||
for _, r := range stated.Resources {
|
||||
kept := (r.Resource == libraryAccess+name || filepath.Clean(r.Target) == l.Root) &&
|
||||
(r.Kind == link.KindAccess || r.Kind == link.KindLibrary)
|
||||
if kept && r.State == link.StateHealthy {
|
||||
taken = true
|
||||
}
|
||||
for _, w := range r.Waits {
|
||||
if w.Access == libraryAccess+name && w.Reason == link.WaitKeep && w.Placed != "" && was &&
|
||||
t.Placed != "" && w.Placed != t.Placed {
|
||||
taken = false // it moved to another filesystem: not taken there until its new warrant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out[name] = taken
|
||||
}
|
||||
return out, 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.
|
||||
@@ -1751,28 +1964,43 @@ func (l *unheldLogger) log(node string, unheld []catalogue.Unheld) {
|
||||
// which includes every one of a module just assigned — and each now met that was not. Nothing about
|
||||
// any other node, and nothing that was already true before the act.
|
||||
func unheldChange(shelf map[string]catalogue.Manifest, node string, before, after []string) []string {
|
||||
judge := func(names []string) map[string]catalogue.Unheld {
|
||||
var set []catalogue.Manifest
|
||||
for _, n := range names {
|
||||
if m, known := shelf[n]; known {
|
||||
set = append(set, m)
|
||||
}
|
||||
return unheldChangeTo(shelf, node, before, after, unheldOver(shelf, node, after))
|
||||
}
|
||||
|
||||
// unheldOver is the node's unmet seat dependencies judged over a set of module names alone.
|
||||
func unheldOver(shelf map[string]catalogue.Manifest, node string, names []string) []catalogue.Unheld {
|
||||
var set []catalogue.Manifest
|
||||
for _, n := range names {
|
||||
if m, known := shelf[n]; known {
|
||||
set = append(set, m)
|
||||
}
|
||||
out := map[string]catalogue.Unheld{}
|
||||
for _, u := range catalogue.UnheldDependencies(shelf, node, set, nil) {
|
||||
out[u.Module+" "+u.Seat] = u
|
||||
}
|
||||
return out
|
||||
}
|
||||
was, now := judge(before), judge(after)
|
||||
return catalogue.UnheldDependencies(shelf, node, set, nil)
|
||||
}
|
||||
|
||||
// unheldChangeTo is unheldChange with the node's dependencies as they now stand given: `now` is what
|
||||
// the act's one resolution after its write says is unmet, and `after` the modules that resolution has
|
||||
// on the node (novox/hq issue 237). A line about how things stand after the act is never judged from
|
||||
// anything else, so it cannot disagree with `plan` for the node.
|
||||
func unheldChangeTo(shelf map[string]catalogue.Manifest, node string, before, after []string,
|
||||
now []catalogue.Unheld) []string {
|
||||
key := func(u catalogue.Unheld) string { return u.Module + " " + u.Seat }
|
||||
was := map[string]catalogue.Unheld{}
|
||||
for _, u := range unheldOver(shelf, node, before) {
|
||||
was[key(u)] = u
|
||||
}
|
||||
is := map[string]catalogue.Unheld{}
|
||||
for _, u := range now {
|
||||
is[key(u)] = u
|
||||
}
|
||||
var lines []string
|
||||
for _, k := range sortedNames(now) {
|
||||
for _, k := range sortedNames(is) {
|
||||
if _, already := was[k]; !already {
|
||||
lines = append(lines, "but "+now[k].String())
|
||||
lines = append(lines, "but "+is[k].String())
|
||||
}
|
||||
}
|
||||
for _, k := range sortedNames(was) {
|
||||
if _, still := now[k]; still {
|
||||
if _, still := is[k]; still {
|
||||
continue
|
||||
}
|
||||
u := was[k]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// planID is every id the mesh gives a plan: a merge's walk or batch (plan-<nanoseconds>) or a release
|
||||
// (release-<nanoseconds>).
|
||||
var planID = regexp.MustCompile(`^(plan|release)-[0-9]+$`)
|
||||
|
||||
// planIDArguments are the arguments of plans that name a plan. wait is among them for when it is declared
|
||||
// (novox/hq issue 229); an argument not declared is refused before this is asked.
|
||||
var planIDArguments = []string{"id", "wait", "stop", "close", "retry", "go"}
|
||||
|
||||
// plainPlanIDs refuses a plans call naming a plan by anything but a plan's id (novox/hq issue 561): checked
|
||||
// once, where the call becomes a command line, so nothing else a caller sent reaches a command or the store.
|
||||
func plainPlanIDs(given map[string]string) error {
|
||||
for _, arg := range planIDArguments {
|
||||
if id, named := given[arg]; named && !planID.MatchString(id) {
|
||||
return fmt.Errorf("plans %s: %q is not a plan's id (plan-<digits> or release-<digits>, as `plans` "+
|
||||
"lists them); nothing was done", arg, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A plan's id is checked once, where a call becomes a command line** (novox/hq issue 561): every argument of
|
||||
// plans that names a plan is refused unless it is an id the mesh gives a plan, so a quote never reaches a
|
||||
// command, nor the store behind it.
|
||||
func TestAPlanIdThatIsNoPlansIdIsRefusedBeforeACommandStarts(t *testing.T) {
|
||||
for _, arg := range []string{"id", "stop", "close", "retry", "go"} {
|
||||
for _, bad := range []string{"x' or '1'='1", "plan-1'", "plan-", "plan-1 2", "batch-1", "PLAN-1"} {
|
||||
args := map[string]any{arg: bad}
|
||||
if arg == "stop" || arg == "close" || arg == "go" {
|
||||
args["why"] = "a test"
|
||||
}
|
||||
argv, err := argvFor("plans", args)
|
||||
if err == nil {
|
||||
t.Errorf("plans %s %q was composed: %v", arg, bad, argv)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is not a plan's id") {
|
||||
t.Errorf("plans %s %q was refused for another reason: %v", arg, bad, err)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"plan-1791813626034291775", "release-1791811938267404408"} {
|
||||
args := map[string]any{arg: good}
|
||||
if arg == "stop" || arg == "close" || arg == "go" {
|
||||
args["why"] = "a test"
|
||||
}
|
||||
if _, err := argvFor("plans", args); err != nil {
|
||||
t.Errorf("plans %s %q was refused: %v", arg, good, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Following a plan (novox/hq issue 229): `plans wait <id>` holds its answer until the plan moves, reaches its
|
||||
// next tier or ends, or until its bound passes, and says which. Before it, an agent following a rollout had
|
||||
// `plans` answer once, in prose, and wrote a loop of its own round the mesh's tools to be told the plan moved.
|
||||
//
|
||||
// **No wait is longer than a call is answered within** (link.LongestAnswer, inside the console's thirty
|
||||
// seconds): a longer one would answer "still running" and leave its caller to ask `calls` — the loop again.
|
||||
// So a caller following a plan to its end asks again; each call answers the moment the plan moves.
|
||||
|
||||
// maxPlanWait is the longest bound a wait takes, and the bound when none is given: what a call is answered
|
||||
// within, less the time a verb's command takes to start and read the store.
|
||||
var maxPlanWait = link.LongestAnswer - planWaitStart
|
||||
|
||||
// planWaitStart is what the verb's window allows beyond the bound: starting the command and opening the stores.
|
||||
// A store slow enough to push the read and the answer past it degrades as any long call does: the caller is
|
||||
// answered that the call is still running, and `calls` with its id says what came of it.
|
||||
const planWaitStart = 5 * time.Second
|
||||
|
||||
// planWaitEvery is how often a wait reads the plan again.
|
||||
const planWaitEvery = time.Second
|
||||
|
||||
// The things a wait waits until.
|
||||
var planWaitUntil = []string{"moved", "tier", "done"}
|
||||
|
||||
// planWaitAsked checks what a wait is asked for: until (moved when empty) and a bound (a duration, or a number
|
||||
// of seconds; maxPlanWait when empty).
|
||||
func planWaitAsked(until, bound string) (string, time.Duration, error) {
|
||||
if until == "" {
|
||||
until = "moved"
|
||||
}
|
||||
known := false
|
||||
for _, u := range planWaitUntil {
|
||||
known = known || u == until
|
||||
}
|
||||
if !known {
|
||||
return "", 0, fmt.Errorf("a wait is until %s, not %q", strings.Join(planWaitUntil, ", "), until)
|
||||
}
|
||||
d := maxPlanWait
|
||||
if bound = strings.TrimSpace(bound); bound != "" {
|
||||
var err error
|
||||
if secs, isNumber := strconv.Atoi(bound); isNumber == nil {
|
||||
d = time.Duration(secs) * time.Second
|
||||
} else if d, err = time.ParseDuration(bound); err != nil {
|
||||
return "", 0, fmt.Errorf("the bound %q is not a duration (such as 20s) or a number of seconds", bound)
|
||||
}
|
||||
if d <= 0 || d > maxPlanWait {
|
||||
return "", 0, fmt.Errorf("the bound is at most %s, the longest a call is answered within; %s was asked "+
|
||||
"— to follow a plan longer, ask again: each call answers the moment the plan moves", maxPlanWait, d)
|
||||
}
|
||||
}
|
||||
return until, d, nil
|
||||
}
|
||||
|
||||
// planEnded is a plan that will not move again: done, failed or superseded. A batch being assembled is not one.
|
||||
func planEnded(p inventory.Plan) bool { return !p.Open() && !p.Batch() }
|
||||
|
||||
// waitOnPlan reads the plan until what it waits for happens or the bound passes, and answers the plan as last
|
||||
// read and what ended the wait: moved (any save of it), tier (its tier changed), done (it ended — which ends
|
||||
// every wait, since nothing comes after it), or bound. Moved and tier are judged against the plan as first read.
|
||||
func waitOnPlan(ctx context.Context, read func(context.Context) (inventory.Plan, error), until string,
|
||||
bound, every time.Duration) (inventory.Plan, string, error) {
|
||||
first, err := read(ctx)
|
||||
if err != nil {
|
||||
return inventory.Plan{}, "", err
|
||||
}
|
||||
if planEnded(first) {
|
||||
return first, "done", nil
|
||||
}
|
||||
deadline := time.Now().Add(bound)
|
||||
tick := time.NewTicker(every)
|
||||
defer tick.Stop()
|
||||
last := first
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return last, "", ctx.Err()
|
||||
case <-tick.C:
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return last, "bound", nil
|
||||
}
|
||||
p, err := read(ctx)
|
||||
if err != nil {
|
||||
return last, "", err
|
||||
}
|
||||
last = p
|
||||
switch {
|
||||
case planEnded(p):
|
||||
return p, "done", nil
|
||||
case until == "moved" && p.Revision != first.Revision:
|
||||
return p, "moved", nil
|
||||
case until == "tier" && p.Tier != first.Tier:
|
||||
return p, "tier", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// planWaited is what `plans wait` answers: the plan, what ended the wait, and the line a person reads (`text`,
|
||||
// which the verb answers as its output and not again in the data).
|
||||
type planWaited struct {
|
||||
// Came is what ended the wait: moved, tier, done, or bound when nothing did.
|
||||
Came string `json:"came"`
|
||||
Until string `json:"until"`
|
||||
Waited string `json:"waited"`
|
||||
Plan planStatus `json:"plan"`
|
||||
// Revision is the plan's as read: a caller asking again with the same plan sees whether it moved since.
|
||||
Revision int64 `json:"revision"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func planWaitedDoc(p inventory.Plan, st planStatus, until, came string, waited time.Duration, line string) planWaited {
|
||||
said := map[string]string{
|
||||
"moved": "moved", "tier": "reached another tier", "done": "ended",
|
||||
"bound": "did not " + map[string]string{"moved": "move", "tier": "reach another tier", "done": "end"}[until],
|
||||
}[came]
|
||||
text := fmt.Sprintf("%s %s in %s (waited until %s)\n%s — %s\n", p.ID, said, waited.Round(time.Second), until,
|
||||
p.ID, line)
|
||||
return planWaited{Came: came, Until: until, Waited: waited.Round(time.Millisecond).String(), Plan: st,
|
||||
Revision: p.Revision, Text: text}
|
||||
}
|
||||
|
||||
// planWaitCommand is `plans wait <id>`: the plan followed until it moves, or the bound passes.
|
||||
func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsked, boundAsked string) error {
|
||||
until, bound, err := planWaitAsked(untilAsked, boundAsked)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
began := time.Now()
|
||||
p, came, err := waitOnPlan(ctx, func(ctx context.Context) (inventory.Plan, error) {
|
||||
return inv.PlanByID(ctx, id)
|
||||
}, until, bound, planWaitEvery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Said as `plans <id>` says its first line, and as `status` says the plan.
|
||||
now, one := time.Now(), []inventory.Plan{p}
|
||||
bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one)
|
||||
line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}, waitsOf(ctx, inv, one))
|
||||
doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line)
|
||||
return json.NewEncoder(os.Stdout).Encode(doc)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A plan as the store would answer it at each read: the reads are counted, and each answers the next.
|
||||
type planReads struct {
|
||||
plans []inventory.Plan
|
||||
n int
|
||||
}
|
||||
|
||||
func (r *planReads) read(context.Context) (inventory.Plan, error) {
|
||||
p := r.plans[min(r.n, len(r.plans)-1)]
|
||||
r.n++
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func followedPlan(rev int64, state string, tier int) inventory.Plan {
|
||||
return inventory.Plan{ID: "plan-1", Repository: "novox/x", Commit: "abc", State: state, Tier: tier,
|
||||
Tiers: [][]string{{"a"}, {"b"}}, Revision: rev}
|
||||
}
|
||||
|
||||
// **`plans` waits for a plan, and says what ended the wait** (novox/hq issue 229): an agent following a
|
||||
// rollout is told when the plan moved, reached its next tier or ended, or that the bound passed — in one
|
||||
// call, without a loop of its own.
|
||||
func TestAWaitForAPlanEndsWhenWhatItWaitsForHappens(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
until string
|
||||
reads []inventory.Plan
|
||||
came string
|
||||
times int
|
||||
}{
|
||||
{"moved: any save", "moved", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(1, inventory.PlanBuilding, 0),
|
||||
followedPlan(2, inventory.PlanBuilding, 0)}, "moved", 3},
|
||||
{"tier: a save inside the tier is not enough", "tier", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanBuilding, 0),
|
||||
followedPlan(3, inventory.PlanRolling, 1)}, "tier", 3},
|
||||
{"tier: a plan that ended has no next tier to wait for", "tier", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanFailed, 0)}, "done", 2},
|
||||
{"done: a new tier is not the end", "done", []inventory.Plan{
|
||||
followedPlan(1, inventory.PlanBuilding, 0), followedPlan(2, inventory.PlanRolling, 1),
|
||||
followedPlan(3, inventory.PlanDone, 1)}, "done", 3},
|
||||
{"done: a plan already ended answers at once", "done", []inventory.Plan{
|
||||
followedPlan(4, inventory.PlanSuperseded, 1)}, "done", 1},
|
||||
{"the bound passes", "moved", []inventory.Plan{followedPlan(1, inventory.PlanBuilding, 0)}, "bound", -1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &planReads{plans: c.reads}
|
||||
p, came, err := waitOnPlan(context.Background(), r.read, c.until, 50*time.Millisecond, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if came != c.came {
|
||||
t.Fatalf("the wait ended as %q, want %q", came, c.came)
|
||||
}
|
||||
if c.times > 0 && r.n != c.times {
|
||||
t.Errorf("read the plan %d times, want %d: it answered before or after what it waited for", r.n, c.times)
|
||||
}
|
||||
if want := c.reads[min(r.n, len(c.reads))-1]; p.Revision != want.Revision {
|
||||
t.Errorf("answered revision %d, not the plan as it was last read (%d)", p.Revision, want.Revision)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What a wait is asked for is checked before anything is read: what it waits until, and a bound no longer than
|
||||
// a call is answered within.
|
||||
func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) {
|
||||
for _, bad := range []struct{ until, bound string }{{"landed", ""}, {"", "1h"}, {"", "-3s"}, {"", "soon"}} {
|
||||
if _, _, err := planWaitAsked(bad.until, bad.bound); err == nil {
|
||||
t.Errorf("until %q, bound %q was taken", bad.until, bad.bound)
|
||||
}
|
||||
}
|
||||
until, bound, err := planWaitAsked("", "")
|
||||
if err != nil || until != "moved" || bound != maxPlanWait {
|
||||
t.Fatalf("the defaults are %q %s %v, want moved and %s", until, bound, err, maxPlanWait)
|
||||
}
|
||||
if _, bound, _ := planWaitAsked("tier", "12"); bound != 12*time.Second {
|
||||
t.Errorf("a bare number is seconds; read %s", bound)
|
||||
}
|
||||
}
|
||||
|
||||
// **The answer is data, with its prose as a field beside it** (novox/hq issue 229): the verb's `answer` carries
|
||||
// the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string.
|
||||
func TestAWaitForAPlanAnswersAsData(t *testing.T) {
|
||||
p := followedPlan(7, inventory.PlanRolling, 1)
|
||||
st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0]
|
||||
doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2")
|
||||
stdout, _ := json.Marshal(doc)
|
||||
a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true)
|
||||
data, ok := a.Answer.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("the answer is not data: %v", a.Answer)
|
||||
}
|
||||
if data["came"] != "tier" || data["revision"] != float64(7) {
|
||||
t.Errorf("the data does not say what ended the wait: %v", data)
|
||||
}
|
||||
if plan, _ := data["plan"].(map[string]any); plan["state"] != "rolling" || plan["tier"] != float64(1) {
|
||||
t.Errorf("the data does not carry the plan: %v", data["plan"])
|
||||
}
|
||||
if _, text := data["text"]; text {
|
||||
t.Errorf("the prose is in the data too: %v", data)
|
||||
}
|
||||
if strings.Contains(a.Output, "{") || !strings.Contains(a.Output, "plan-1") || !strings.Contains(a.Output, "tier") {
|
||||
t.Errorf("the output is not the line a person reads: %q", a.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// The verb composes the wait, and asks for a window as long as its bound.
|
||||
func TestThePlansVerbComposesAWait(t *testing.T) {
|
||||
argv, err := argvFor("plans", map[string]any{"wait": "plan-1", "until": "done", "bound": "15s"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(argv, " "); got != "plans wait plan-1 --until done --bound 15s" {
|
||||
t.Fatalf("composed %q", got)
|
||||
}
|
||||
if _, err := argvFor("plans", map[string]any{"until": "done"}); err == nil {
|
||||
t.Error("until without wait was taken")
|
||||
}
|
||||
if _, err := argvFor("plans", map[string]any{"wait": "plan-1", "bound": "1h"}); err == nil {
|
||||
t.Error("a bound past what a call can wait was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Live, with the store: `plans wait` run as the verb runs it answers when the plan reaches its next tier, as
|
||||
// data, and says the bound passed when nothing moved (novox/hq issue 229).
|
||||
func TestAPlanFollowedFromTheStoreAnswersWhenItMoves(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now().UTC()
|
||||
walk := inventory.Plan{ID: "plan-229", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c229c229",
|
||||
Created: now, State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}}
|
||||
if err := open.inventory.SavePlan(ctx, &walk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := make(chan error, 1)
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
p, err := open.inventory.PlanByID(ctx, walk.ID)
|
||||
if err == nil {
|
||||
p.Tier, p.State = 1, inventory.PlanRolling
|
||||
err = open.inventory.SavePlan(ctx, &p)
|
||||
}
|
||||
moved <- err
|
||||
}()
|
||||
began := time.Now()
|
||||
var got planWaited
|
||||
out := printed(t, func() error {
|
||||
return plansCommand(ctx, []string{"wait", walk.ID, "--until", "tier", "--bound", "15s"})
|
||||
})
|
||||
if err := <-moved; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("plans wait did not answer data: %v: %q", err, out)
|
||||
}
|
||||
if got.Came != "tier" || got.Plan.Tier != 1 || got.Plan.State != inventory.PlanRolling {
|
||||
t.Fatalf("the wait answered %+v", got)
|
||||
}
|
||||
if waited := time.Since(began); waited > 10*time.Second {
|
||||
t.Errorf("it answered after %s, not when the plan moved", waited)
|
||||
}
|
||||
out = printed(t, func() error {
|
||||
return plansCommand(ctx, []string{"wait", walk.ID, "--bound", "2"})
|
||||
})
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil || got.Came != "bound" || got.Until != "moved" {
|
||||
t.Fatalf("a plan that did not move answered %+v (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The verb's schema says the longest bound in words, and the words are the value (review 75 on #266): the
|
||||
// catalogue cannot read maxPlanWait, so this holds them together.
|
||||
func TestThePlansSchemaSaysTheLongestBoundThereIs(t *testing.T) {
|
||||
v, ok := controllerVerb("plans")
|
||||
if !ok {
|
||||
t.Fatal("no plans verb")
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
bound, _ := props["bound"].(map[string]any)
|
||||
said, _ := bound["description"].(string)
|
||||
if !strings.Contains(said, maxPlanWait.String()+", the default, is the longest") {
|
||||
t.Fatalf("the schema says %q; the longest bound is %s", said, maxPlanWait)
|
||||
}
|
||||
}
|
||||
@@ -1027,6 +1027,9 @@ func (b overTheBus) declare(ctx context.Context, s readyNode, body []byte) (stri
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A send that carried the mesh's record of the controller and the node-engine — a person's push included —
|
||||
// ends the condition of one kept over it (novox/hq issue 201).
|
||||
settleCoreDowngrades(ctx, b.open.inventory, s.node, s.declared.Builds)
|
||||
if len(s.declared.Bindings) > 0 {
|
||||
// And where it bound each consumer of a provision that keeps its data (novox/hq ADR 0232):
|
||||
// what the next resolution keeps it at. On the same outliving context as the send's record.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -67,18 +68,95 @@ func listQueue(ctx context.Context, args []string, w io.Writer) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if *asJSON {
|
||||
body, err := json.MarshalIndent(q, "", " ")
|
||||
body, err := json.MarshalIndent(queueAnswerOf(q, now), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w, string(body))
|
||||
return nil
|
||||
}
|
||||
fmt.Fprint(w, queueText(q, time.Now()))
|
||||
fmt.Fprint(w, queueText(q, now))
|
||||
return nil
|
||||
}
|
||||
|
||||
// queueAnswer is the queue as data (novox/hq issue 455): what a caller reads to say how far back an ask
|
||||
// waits — "waiting in the build queue behind 2" — which the table alone could not tell it. Text is the
|
||||
// table, written from the same read: the verb answers it as its output and the rest as its answer, so the
|
||||
// two never disagree.
|
||||
type queueAnswer struct {
|
||||
Seat string `json:"seat"`
|
||||
Waiting int `json:"waiting"`
|
||||
InFlight int `json:"in-flight"`
|
||||
Dead int `json:"dead"`
|
||||
MaxDeliver int `json:"max-deliver"`
|
||||
Asks []queuedAnswer `json:"asks"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// queuedAnswer is one ask as data. Never what the request carries beyond its source — not `held`, every
|
||||
// artifact the mesh has built, nor a check's checkouts beside it.
|
||||
type queuedAnswer struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
// Kind is "check" for a pull request's merge check, "build" for anything else.
|
||||
Kind string `json:"kind"`
|
||||
Repository string `json:"repository"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
// Commit is the ref when it names a commit; empty when the ask follows a branch or the default.
|
||||
Commit string `json:"commit,omitempty"`
|
||||
// Machine is the one building an ask in flight; WasOn the one that started it and handed it back.
|
||||
Machine string `json:"machine,omitempty"`
|
||||
WasOn string `json:"was-on,omitempty"`
|
||||
AskedAt time.Time `json:"asked-at,omitzero"`
|
||||
// Since is when the ask came to its state: its start on the machine for one in flight that a
|
||||
// machine said it started, otherwise when it was asked.
|
||||
Since time.Time `json:"since,omitzero"`
|
||||
// Position is its place among the waiting, 1 being handed out next; 0 for an ask not waiting.
|
||||
Position int `json:"position,omitempty"`
|
||||
Seq uint64 `json:"seq"`
|
||||
}
|
||||
|
||||
// aCommitRef is a ref that names a commit by its full hash (SHA-1 or SHA-256).
|
||||
var aCommitRef = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`)
|
||||
|
||||
// queueAnswerOf is the queue as data, in the table's order: in flight, the waiting from the next one to
|
||||
// be handed out, then the dead. A pure function of one read (novox/hq issue 455).
|
||||
func queueAnswerOf(q link.Queue, now time.Time) queueAnswer {
|
||||
waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead)
|
||||
out := queueAnswer{Seat: q.Seat, Waiting: len(waiting), InFlight: len(running), Dead: len(dead),
|
||||
MaxDeliver: q.MaxDeliver, Asks: []queuedAnswer{}, Text: queueText(q, now)}
|
||||
one := func(a link.QueuedAsk, position int) queuedAnswer {
|
||||
kind := "build"
|
||||
if a.Request.Check != nil {
|
||||
kind = "check"
|
||||
}
|
||||
since := a.AskedAt
|
||||
if a.State == link.AskInFlight && a.On != "" && !a.Started.IsZero() {
|
||||
since = a.Started
|
||||
}
|
||||
commit := ""
|
||||
if aCommitRef.MatchString(a.Ref) {
|
||||
commit = a.Ref
|
||||
}
|
||||
return queuedAnswer{ID: a.ID, State: a.State, Kind: kind, Repository: link.WithoutPassword(a.Repository), Path: a.Path,
|
||||
Ref: a.Ref, Commit: commit, Machine: a.On, WasOn: a.Was, AskedAt: a.AskedAt, Since: since,
|
||||
Position: position, Seq: a.Seq}
|
||||
}
|
||||
for _, a := range running {
|
||||
out.Asks = append(out.Asks, one(a, 0))
|
||||
}
|
||||
for i, a := range waiting {
|
||||
out.Asks = append(out.Asks, one(a, i+1))
|
||||
}
|
||||
for _, a := range dead {
|
||||
out.Asks = append(out.Asks, one(a, 0))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// queueText is the queue as a person reads it: a summary line, then each kind in queue order.
|
||||
// Never what an ask carries as `held` — that is every artifact the mesh has built.
|
||||
func queueText(q link.Queue, now time.Time) string {
|
||||
@@ -92,7 +170,7 @@ func queueText(q link.Queue, now time.Time) string {
|
||||
return "asked " + now.Sub(t).Round(time.Second).String() + " ago"
|
||||
}
|
||||
what := func(a link.QueuedAsk) string {
|
||||
s := a.Repository
|
||||
s := link.WithoutPassword(a.Repository)
|
||||
if a.Path != "" {
|
||||
s += " at " + a.Path
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The queue answers as data as well as text (novox/hq issue 455): each ask's repository, commit, kind,
|
||||
// machine, since and position among the waiting, read from the same queue the table is written from.
|
||||
|
||||
const aCommit = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
func aQueueOfEachKind(now time.Time) link.Queue {
|
||||
asked := now.Add(-5 * time.Minute)
|
||||
started := now.Add(-2 * time.Minute)
|
||||
return link.Queue{Seat: link.TheBuildMachine, Delivered: 2, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{
|
||||
{Seq: 1, ID: "build-dead", Repository: "ssh://forge/novox/x", AskedAt: asked, State: link.AskDead},
|
||||
{Seq: 2, ID: "build-running", Repository: "ssh://forge/novox/mesh-host", Ref: aCommit, AskedAt: asked,
|
||||
State: link.AskInFlight, On: "ace", Started: started,
|
||||
Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-host"}}},
|
||||
{Seq: 3, ID: "build-first", Repository: "ssh://forge/novox/mesh-catalog", Ref: "main", AskedAt: asked,
|
||||
State: link.AskWaiting},
|
||||
{Seq: 4, ID: "build-second", Repository: "ssh://forge/novox/mesh-catalog", Path: "modules/gitea", Ref: aCommit,
|
||||
AskedAt: now.Add(-time.Minute), State: link.AskWaiting,
|
||||
Request: link.BuildRequest{ID: "build-second", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog"}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestTheQueueVerbAnswersEachAskAsData(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
||||
q := aQueueOfEachKind(now)
|
||||
got := queueAnswerOf(q, now)
|
||||
if got.Waiting != 2 || got.InFlight != 1 || got.Dead != 1 || got.Seat != link.TheBuildMachine {
|
||||
t.Fatalf("counts: %+v", got)
|
||||
}
|
||||
if got.Text != queueText(q, now) {
|
||||
t.Fatalf("the data's text is not the table read from the same queue:\n%s", got.Text)
|
||||
}
|
||||
byID := map[string]queuedAnswer{}
|
||||
for _, a := range got.Asks {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
running := byID["build-running"]
|
||||
if running.Kind != "check" || running.Machine != "ace" || running.Commit != aCommit || running.Position != 0 ||
|
||||
!running.Since.Equal(now.Add(-2*time.Minute)) || running.State != link.AskInFlight {
|
||||
t.Errorf("in flight: %+v", running)
|
||||
}
|
||||
first, second := byID["build-first"], byID["build-second"]
|
||||
if first.Position != 1 || first.Kind != "build" || first.Commit != "" || first.Ref != "main" || first.Machine != "" ||
|
||||
!first.Since.Equal(now.Add(-5*time.Minute)) {
|
||||
t.Errorf("first waiting: %+v", first)
|
||||
}
|
||||
if second.Position != 2 || second.Kind != "check" || second.Commit != aCommit || second.Path != "modules/gitea" ||
|
||||
second.Repository != "ssh://forge/novox/mesh-catalog" {
|
||||
t.Errorf("second waiting: %+v", second)
|
||||
}
|
||||
if dead := byID["build-dead"]; dead.Position != 0 || dead.State != link.AskDead {
|
||||
t.Errorf("dead: %+v", dead)
|
||||
}
|
||||
// In the table's order: in flight, then the waiting by position, then the dead.
|
||||
var order []string
|
||||
for _, a := range got.Asks {
|
||||
order = append(order, a.ID)
|
||||
}
|
||||
if want := []string{"build-running", "build-first", "build-second", "build-dead"}; !reflect.DeepEqual(order, want) {
|
||||
t.Errorf("order %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verb asks its command for JSON, and its answer carries the data while its output stays the table a
|
||||
// person reads, so a text caller reads what it always did.
|
||||
func TestTheQueueVerbKeepsTheTableAndCarriesTheData(t *testing.T) {
|
||||
argv, err := argvFor("queue", nil)
|
||||
if err != nil || !reflect.DeepEqual(argv, []string{"queue", "--json"}) {
|
||||
t.Fatalf("queue's command line: %v %v", argv, err)
|
||||
}
|
||||
now := time.Now()
|
||||
q := aQueueOfEachKind(now)
|
||||
body, err := json.Marshal(queueAnswerOf(q, now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer := answerOf(argv, body, "", true)
|
||||
if answer.Output != queueText(q, now) {
|
||||
t.Fatalf("the output is not the table:\n%s", answer.Output)
|
||||
}
|
||||
data, ok := answer.Answer.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("no data: %#v", answer.Answer)
|
||||
}
|
||||
if _, doubled := data["text"]; doubled {
|
||||
t.Error("the table is said twice: once as the output and again inside the data")
|
||||
}
|
||||
asks, _ := data["asks"].([]any)
|
||||
if len(asks) != 4 || data["waiting"] != float64(2) {
|
||||
t.Fatalf("data: %v", data)
|
||||
}
|
||||
second := asks[2].(map[string]any)
|
||||
if second["position"] != float64(2) || second["kind"] != "check" || second["commit"] != aCommit {
|
||||
t.Errorf("second waiting: %v", second)
|
||||
}
|
||||
for _, a := range asks {
|
||||
if _, held := a.(map[string]any)["held"]; held {
|
||||
t.Error("an ask carries what the mesh holds")
|
||||
}
|
||||
}
|
||||
// A failure is said as text, as before.
|
||||
failed := answerOf(argv, nil, "mesh-controller: the bus cannot be reached\n", false)
|
||||
if failed.Answer != nil || !strings.Contains(failed.Output, "cannot be reached") {
|
||||
t.Errorf("a failure: %+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
// A repository named with a password never shows it, in the table or the data.
|
||||
func TestTheQueueNeverShowsARepositorysPassword(t *testing.T) {
|
||||
if got := link.WithoutPassword("https://agent:s3cret@forge.example/novox/x.git"); strings.Contains(got, "s3cret") ||
|
||||
!strings.Contains(got, "agent@forge.example/novox/x.git") {
|
||||
t.Errorf("redacted: %s", got)
|
||||
}
|
||||
now := time.Now()
|
||||
q := link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{{Seq: 1, ID: "build-1",
|
||||
Repository: "https://agent:s3cret@forge.example/novox/x.git", State: link.AskWaiting}}}
|
||||
body, _ := json.Marshal(queueAnswerOf(q, now))
|
||||
if strings.Contains(string(body), "s3cret") {
|
||||
t.Errorf("the queue said a password: %s", body)
|
||||
}
|
||||
for _, kept := range []string{"ssh://git@forge:222/novox/x.git", "novox/x", "git@forge:novox/x.git", ""} {
|
||||
if got := link.WithoutPassword(kept); got != kept {
|
||||
t.Errorf("%q became %q", kept, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ func TestTheQueueVerbsRunTheirCommands(t *testing.T) {
|
||||
args map[string]any
|
||||
want []string
|
||||
}{
|
||||
{"queue", nil, []string{"queue"}},
|
||||
{"queue", nil, []string{"queue", "--json"}},
|
||||
{"cancel", map[string]any{"id": "build-1"}, []string{"cancel", "build-1"}},
|
||||
{"clear", nil, []string{"clear"}},
|
||||
{"clear", map[string]any{"dead": "true"}, []string{"clear", "--dead"}},
|
||||
|
||||
@@ -135,6 +135,17 @@ func recordedKept(ctx context.Context, open *stores, node string) (map[string]st
|
||||
}
|
||||
out[m] = was
|
||||
}
|
||||
// **And the controller and the node-engine at the build the machine was last sent, over an older record**
|
||||
// (novox/hq issue 201, core_downgrade.go). Under keepingEveryBuild they are kept already.
|
||||
if !every {
|
||||
core, err := coreKept(ctx, inv, node, sent, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for m, was := range core {
|
||||
out[m] = was
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -159,6 +170,12 @@ func keepRecorded(ctx context.Context, open *stores, node string, shelf map[stri
|
||||
return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+
|
||||
"list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m]))
|
||||
}
|
||||
if !found && guarded(coreComponent(m)) {
|
||||
return nil, fmt.Errorf("%s was last sent %s's build %s, newer than the one the mesh holds, and the build records "+
|
||||
"no longer hold it: this send cannot keep it and does not move it back — `push %s` sends the older build on "+
|
||||
"a person's word, and a newer merge or `rebuild %s` moves the record forward (novox/hq issue 201)",
|
||||
node, m, short(kept[m]), node, m)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%s records rather than rolls out, and %s runs its build %s, which the build "+
|
||||
"records no longer hold: this send cannot keep it and does not move it — `push %s` sends the new "+
|
||||
|
||||
@@ -51,6 +51,9 @@ type sendScope struct {
|
||||
modules map[string]bool
|
||||
// person is a person's act: it carries what it carries.
|
||||
person bool
|
||||
// puttingBack is a gate's own put-back of its modules: the one send that may carry a core module to a build
|
||||
// older than the one its machine runs (novox/hq issue 201).
|
||||
puttingBack bool
|
||||
}
|
||||
|
||||
type sendScopeKey struct{}
|
||||
@@ -237,6 +240,47 @@ var machineMoves = func(ctx context.Context, open *stores, f moveFacts, node str
|
||||
return f.moves(node, modules, sent, known, all), nil
|
||||
}
|
||||
|
||||
// withoutKept is a machine's moves less those its send keeps at the build the machine was last sent, and so does
|
||||
// not make: the grants step keeps every module (novox/hq issue 490), and every send keeps the controller and the
|
||||
// node-engine over an older record (issue 201). Read as the send composes it (sendKeeps, set after the checks that
|
||||
// call this), so a move it could not keep is still counted.
|
||||
func withoutKept(ctx context.Context, open *stores, node string, moves []inventory.CarriedMove) ([]inventory.CarriedMove, error) {
|
||||
if len(moves) == 0 {
|
||||
return moves, nil
|
||||
}
|
||||
keeping := ctx
|
||||
if _, on := keptExcept(ctx); !on {
|
||||
keeping = keepingRecorded(ctx)
|
||||
}
|
||||
keeps, err := recordedKept(keeping, open, node)
|
||||
if err != nil {
|
||||
return moves, err
|
||||
}
|
||||
// And a joining module held at the build it runs (novox/hq ADR 0328 §8): its new build is no move this send makes,
|
||||
// and judging it would read the gate as superseded by its own send (the re-review of 96e15f86, item 1).
|
||||
held, err := holdJoining(ctx, open.inventory, node, nil)
|
||||
if err != nil {
|
||||
return moves, err
|
||||
}
|
||||
if len(held) > 0 && keeps == nil {
|
||||
keeps = map[string]string{}
|
||||
}
|
||||
for m, h := range held {
|
||||
keeps[m] = h.commit
|
||||
}
|
||||
if len(keeps) == 0 {
|
||||
return moves, nil
|
||||
}
|
||||
var out []inventory.CarriedMove
|
||||
for _, mv := range moves {
|
||||
if was, kept := keeps[mv.Module]; kept && sameCommit(was, mv.From) {
|
||||
continue
|
||||
}
|
||||
out = append(out, mv)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ungatedIn refuses a send whose machines would move a build no gate has seen, outside its scope: the
|
||||
// machines named, and why; the machine holding the bus, added only for its user list, is left instead.
|
||||
func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder string) ([]string, error) {
|
||||
@@ -254,19 +298,11 @@ func ungatedIn(ctx context.Context, open *stores, names []string, addedHolder st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The grants step composes every module at the build its machine was last sent (novox/hq issue 490):
|
||||
// a move it keeps is not made. Read, not assumed, so a move it could not keep is still refused here.
|
||||
var keeps map[string]string
|
||||
if everyBuildKept(ctx, n) {
|
||||
if keeps, err = recordedKept(ctx, open, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if moves, err = withoutKept(ctx, open, n, moves); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var waiting []string
|
||||
for _, mv := range moves {
|
||||
if was, kept := keeps[mv.Module]; kept && sameCommit(was, mv.From) {
|
||||
continue
|
||||
}
|
||||
if !scope.judged[n] && !scope.modules[mv.Module] {
|
||||
waiting = append(waiting, fmt.Sprintf("%s %s → %s", mv.Module, short(mv.From), short(mv.To)))
|
||||
}
|
||||
@@ -308,6 +344,11 @@ func gatedSend(ctx context.Context, open *stores, node string, owns []inventory.
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
// A move the send keeps is not made, and is no move for the gate to judge (novox/hq issue 201): a core module
|
||||
// kept over an older record judged as moving back to it would read the gate as superseded by its own send.
|
||||
if moves, err = withoutKept(ctx, open, node, moves); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
own := func(module string) bool {
|
||||
return slices.ContainsFunc(owns, func(o inventory.CarriedMove) bool { return o.Module == module })
|
||||
}
|
||||
|
||||
@@ -690,7 +690,7 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
// being behind. Now every module of the tier whose first machine is the same is sent there in one
|
||||
// send, judged there by one gate — kept on the first of them, the others gated by it — and the rest
|
||||
// of the tier's machines are sent once, together, when the gates have passed.
|
||||
var pending []string
|
||||
var pending, waitsForAPerson []string
|
||||
firstOn := map[string][]string{}
|
||||
runningOf := map[string][]string{}
|
||||
var rest []string
|
||||
@@ -706,6 +706,20 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
return false, err
|
||||
}
|
||||
runningOf[m] = running
|
||||
// **A joining build held for a person is not sent** (novox/hq ADR 0328 §8, the re-review of 96e15f86): its
|
||||
// machines keep the build they run until its library is taken (or their engine keeps own groups), so sending
|
||||
// it would send nothing new and judge the old build. It waits for a person, said; the plan stays open, the other
|
||||
// modules are sent and judged, and it is sent on the walk after nothing holds it — no new merge.
|
||||
if state.FirstAt == nil {
|
||||
why, err := heldIn(ctx, inv, m, running)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if why != "" {
|
||||
waitsForAPerson = append(waitsForAPerson, why)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// **A build whose source is unchanged is never a move** (novox/hq issue 280): made from the source
|
||||
// of the build every machine running it was sent, it is registered with that build's artifacts —
|
||||
// nothing to send, and nothing for a gate to judge.
|
||||
@@ -868,9 +882,17 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
strings.Join(sent, ", "))
|
||||
return true, nil
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
note := "tier " + fmt.Sprint(p.Tier) + " built; waiting for " + strings.Join(pending, "; ") +
|
||||
" to report it applied before the rest are sent"
|
||||
if len(pending) > 0 || len(waitsForAPerson) > 0 {
|
||||
note := "tier " + fmt.Sprint(p.Tier) + " built; "
|
||||
if len(pending) > 0 {
|
||||
note += "waiting for " + strings.Join(pending, "; ") + " to report it applied before the rest are sent"
|
||||
}
|
||||
if len(waitsForAPerson) > 0 {
|
||||
if len(pending) > 0 {
|
||||
note += "; and "
|
||||
}
|
||||
note += "waiting for a person: " + strings.Join(waitsForAPerson, "; ")
|
||||
}
|
||||
changed := p.State != inventory.PlanRolling || p.Note != note
|
||||
p.State = inventory.PlanRolling
|
||||
p.Note = note
|
||||
@@ -1463,6 +1485,10 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
modules := set.String("modules", "", "or the modules it would change, comma-separated")
|
||||
moduleDirs := set.String("module-dirs", "", "with --paths: the directories holding a module.json at the commit, "+
|
||||
"comma-separated, as the forge's announcer says them (novox/hq issue 278)")
|
||||
// Following a plan (novox/hq issue 229): `plans wait <id>`.
|
||||
until := set.String("until", "", "with wait: what to wait for — moved (any change), tier (its next tier) or done (its end)")
|
||||
bound := set.String("bound", "", "with wait: how long to wait at most, as 15s or seconds (default and at most the "+
|
||||
"longest a call is answered within)")
|
||||
// Ending a plan by hand is a repair, and says why (novox/hq to-be 45 §7).
|
||||
why := addHandActFlags(set)
|
||||
positionals, err := parseAround(set, args)
|
||||
@@ -1482,6 +1508,9 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
now := time.Now()
|
||||
if len(positionals) == 2 && positionals[0] == "wait" {
|
||||
return planWaitCommand(ctx, inv, positionals[1], *until, *bound)
|
||||
}
|
||||
if len(positionals) == 1 {
|
||||
p, err := inv.PlanByID(ctx, positionals[0])
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// TestReplay237 replays novox/hq issue 237 (2026-10-04): a workstation already ran a module that
|
||||
// depends on a node seat nothing there held, and the module holding that seat was assigned to it. The
|
||||
// answer said the seat was now held, and then that the workstation was left out of the rest of the mesh
|
||||
// for lack of it, naming as the remedy the assignment just made. `plan` for the node, right after,
|
||||
// resolved. The outcome asserted: every line of the answer, as the controller seat's verb gives it
|
||||
// (standard output, then standard error), agrees with `plan` for the node right after the act. Written
|
||||
// with only what the controller had before its fix — register, assign's command, planFor — so it is laid
|
||||
// over the commit before.
|
||||
func TestReplay237(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, aContainer("web"))
|
||||
register(t, open, catalogue.Manifest{Module: "docker", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.ContainerRuntimeSeat, Scope: catalogue.ScopeNode}}})
|
||||
// Already on the node, from before the rule refused it: the node does not resolve until the
|
||||
// holder comes.
|
||||
if _, err := open.inventory.Assign(ctx, "laptop", "web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
said := answered(t, func() error { return assignCommand(ctx, "assign", []string{"laptop", "docker"}) })
|
||||
|
||||
if _, _, err := planFor(ctx, open, "laptop"); err != nil {
|
||||
t.Fatalf("laptop does not resolve right after the act: %v\nthe answer was:\n%s", err, said)
|
||||
}
|
||||
if !strings.Contains(said, "web on laptop now has "+catalogue.ContainerRuntimeSeat+" held") {
|
||||
t.Errorf("the answer does not say the seat is now held:\n%s", said)
|
||||
}
|
||||
for _, not := range []string{"laptop is left out", "does not resolve", "which nothing on laptop holds"} {
|
||||
if strings.Contains(said, not) {
|
||||
t.Errorf("the answer says %q, which `plan` right after contradicts:\n%s", not, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answered is what a command says as the controller seat's verb answers it: standard output, then
|
||||
// standard error (answerOf).
|
||||
func answered(t *testing.T, f func() error) string {
|
||||
t.Helper()
|
||||
read := func(w **os.File) (func() string, error) {
|
||||
old := *w
|
||||
r, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*w = pw
|
||||
got := make(chan string)
|
||||
go func() {
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
got <- buf.String()
|
||||
}()
|
||||
return func() string {
|
||||
_ = pw.Close()
|
||||
*w = old
|
||||
return <-got
|
||||
}, nil
|
||||
}
|
||||
stdout, err := read(&os.Stdout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stderr, err := read(&os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runErr := f()
|
||||
said := stdout() + stderr()
|
||||
if runErr != nil {
|
||||
t.Fatalf("%v\n%s", runErr, said)
|
||||
}
|
||||
return said
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// TestReplay401 replays novox/hq issue 401 (2026-10-10): the mounts module's smb-credentials, an own secret
|
||||
// only an outside party issues, was given by nobody, and the send made a random value for it and placed it on
|
||||
// the machine; the module read it as a credential given and wrong. The outcome asserted (ADR 0228): the send
|
||||
// makes no value for an own secret issued outside the mesh that nobody gave and places none, and a value the
|
||||
// mesh made for it before is not placed either; once a person gives one, that value is placed. Written with
|
||||
// only what the controller had before its fix (mesh-controller #262) — register, assign, planFor,
|
||||
// declarationFor and the inventory's secret reads — so it is laid over the commit before. Needs the store
|
||||
// (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
|
||||
func TestReplay401(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
const made, outside = "/var/lib/mesh/mounts/password", "/var/lib/mesh/mounts/smb-credentials"
|
||||
register(t, open, catalogue.Manifest{Module: "mounts", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{
|
||||
"password": {Path: made, Taken: catalogue.TakenAtStart},
|
||||
"smb-credentials": {Path: outside, Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
|
||||
},
|
||||
Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "path": "/var/lib/mesh/mounts", "mode": "0700"},
|
||||
}})
|
||||
if _, err := assign(ctx, open, "laptop", "mounts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := func() []map[string]any {
|
||||
t.Helper()
|
||||
plan, settings, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
|
||||
if err != nil {
|
||||
t.Fatalf("the send to laptop did not compose: %v", err)
|
||||
}
|
||||
return declared.Resources
|
||||
}
|
||||
placed := func(resources []map[string]any, path string) map[string]any {
|
||||
for _, r := range resources {
|
||||
if r["path"] == path && r["sealed"] != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
first := sent()
|
||||
if placed(first, made) == nil {
|
||||
t.Fatal("the secret the mesh makes was not placed")
|
||||
}
|
||||
if got := placed(first, outside); got != nil {
|
||||
t.Errorf("the send placed a value for smb-credentials, which nobody gave: %v", got)
|
||||
}
|
||||
if _, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "mounts", "smb-credentials"); err != nil || held {
|
||||
t.Fatalf("the send made a value for a secret issued outside the mesh (held %v, %v)", held, err)
|
||||
}
|
||||
|
||||
// A value the mesh made for it before the rule is not one a person gave: it is not placed.
|
||||
if _, err := open.inventory.SecretForModule(ctx, "laptop", "mounts", "smb-credentials"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := placed(sent(), outside); got != nil {
|
||||
t.Fatalf("a value the mesh made for a secret issued outside was placed: %v", got)
|
||||
}
|
||||
|
||||
// Given by a person, it is placed, as given.
|
||||
if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "mounts", "smb-credentials", "the share's credentials"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given, held, err := open.inventory.ModuleSecretIfIssued(ctx, "laptop", "mounts", "smb-credentials")
|
||||
if err != nil || !held {
|
||||
t.Fatalf("the value given is not held (%v)", err)
|
||||
}
|
||||
if got := placed(sent(), outside); got == nil || got["sealed"] != given {
|
||||
t.Fatalf("the value given is placed as %v", got)
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,10 @@ type seatRow struct {
|
||||
Delivers string `json:"delivers,omitempty"`
|
||||
Decision string `json:"decision"`
|
||||
// Replicated says the seat may be held on several machines at once (novox/hq ADR 0223).
|
||||
Replicated bool `json:"replicated,omitempty"`
|
||||
Holders []seatHolder `json:"holders"`
|
||||
Replicated bool `json:"replicated,omitempty"`
|
||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287).
|
||||
Paths []catalogue.SeatPath `json:"paths,omitempty"`
|
||||
Holders []seatHolder `json:"holders"`
|
||||
}
|
||||
|
||||
// seatsHeld is every seat the mesh defines with its holders, and every claim held that names no
|
||||
@@ -49,7 +51,7 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata
|
||||
rows := make([]seatRow, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision,
|
||||
Replicated: s.Replicated, Holders: []seatHolder{}}
|
||||
Replicated: s.Replicated, Paths: s.Paths, Holders: []seatHolder{}}
|
||||
seen := map[seatHolder]bool{}
|
||||
for _, h := range held {
|
||||
// Resolve the held claim to a seat rather than comparing names, so a record naming a
|
||||
@@ -255,12 +257,16 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
rows, outside := seatsHeld(catalogue.Seats(), world.Held)
|
||||
// Which module owns each seat's path on each machine (novox/hq ADR 0287): the seat naming it, and
|
||||
// that seat's holder there, with the stepping back applied.
|
||||
owners := catalogue.PathOwners(catalogue.Seats(), world.Held)
|
||||
|
||||
if *asJSON {
|
||||
out := struct {
|
||||
Seats []seatRow `json:"seats"`
|
||||
Outside []catalogue.Held `json:"outside,omitempty"`
|
||||
}{rows, outside}
|
||||
Seats []seatRow `json:"seats"`
|
||||
Owners []catalogue.PathOwner `json:"path-owners,omitempty"`
|
||||
Outside []catalogue.Held `json:"outside,omitempty"`
|
||||
}{rows, owners, outside}
|
||||
body, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -270,7 +276,7 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tHELD BY")
|
||||
fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tPATHS\tHELD BY")
|
||||
for _, r := range rows {
|
||||
delivers := r.Delivers
|
||||
if delivers == "" {
|
||||
@@ -284,11 +290,22 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
holders = strings.Join(parts, ", ")
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, holders)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, seatPathsText(r.Paths), holders)
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(owners) > 0 {
|
||||
fmt.Println("\nwho owns each seat's path, by machine (novox/hq ADR 0287):")
|
||||
w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "PATH\tMACHINE\tSEAT\tMODULE")
|
||||
for _, o := range owners {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", o.Path, o.Node, o.Seat, o.Module)
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(outside) > 0 {
|
||||
fmt.Println("\nheld, and not a seat this mesh defines (registered before the set closed — novox/hq ADR 0110):")
|
||||
for _, h := range outside {
|
||||
@@ -297,3 +314,19 @@ func seatsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seatPathsText is a seat's paths in one column: each path, and the seat it steps back for, if any.
|
||||
func seatPathsText(paths []catalogue.SeatPath) string {
|
||||
if len(paths) == 0 {
|
||||
return "—"
|
||||
}
|
||||
parts := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if p.StepsBackFor != "" {
|
||||
parts = append(parts, p.Path+" (steps back for "+p.StepsBackFor+")")
|
||||
continue
|
||||
}
|
||||
parts = append(parts, p.Path)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
@@ -74,3 +74,22 @@ func TestAKindedBenchIsNotHandedOver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The listing shows each seat's paths, and the seat a path steps back for (novox/hq ADR 0287).
|
||||
func TestTheSeatsListingShowsEachSeatsPaths(t *testing.T) {
|
||||
rows, _ := seatsHeld(catalogue.Seats(), nil)
|
||||
got := map[string]string{}
|
||||
for _, r := range rows {
|
||||
got[r.Seat] = seatPathsText(r.Paths)
|
||||
}
|
||||
for seat, want := range map[string]string{
|
||||
"node-hostname": "/etc/hosts, /etc/hostname",
|
||||
"node-uplink": "/etc/resolv.conf (steps back for node-resolver)",
|
||||
"node-resolver": "/etc/resolv.conf",
|
||||
"node-packet-filter": "—",
|
||||
} {
|
||||
if got[seat] != want {
|
||||
t.Errorf("%s lists its paths as %q, not %q", seat, got[seat], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"node", "show", str("node")}, nil
|
||||
case "judge-directories":
|
||||
if err := need("node"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{"node", "judge-directories", str("node")}, nil
|
||||
case "modules":
|
||||
return []string{"module", "list", "--json"}, nil
|
||||
case "seats":
|
||||
@@ -331,6 +336,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
}
|
||||
return argv, nil
|
||||
case "plans":
|
||||
if err := plainPlanIDs(a.given); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r := str("repository"); r != "" {
|
||||
argv := []string{"plans", "--what-if", r}
|
||||
if p := str("paths"); p != "" {
|
||||
@@ -360,6 +368,21 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return argv, nil
|
||||
}
|
||||
}
|
||||
if id := str("wait"); id != "" {
|
||||
// Following a plan (novox/hq issue 229): checked here too, so a call asking what no wait can do is
|
||||
// refused before a command is started.
|
||||
if _, _, err := planWaitAsked(a.given["until"], a.given["bound"]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := []string{"plans", "wait", id}
|
||||
if u := str("until"); u != "" {
|
||||
argv = append(argv, "--until", u)
|
||||
}
|
||||
if b := str("bound"); b != "" {
|
||||
argv = append(argv, "--bound", b)
|
||||
}
|
||||
return argv, nil
|
||||
}
|
||||
if id := str("id"); id != "" {
|
||||
return []string{"plans", id}, nil
|
||||
}
|
||||
@@ -426,7 +449,8 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return argv, nil
|
||||
// The build queue (novox/hq ADR 0219).
|
||||
case "queue":
|
||||
return []string{"queue"}, nil
|
||||
// As data, the table its output (novox/hq issue 455).
|
||||
return []string{"queue", "--json"}, nil
|
||||
case "cancel", "kill":
|
||||
if err := need("id"); err != nil {
|
||||
return nil, err
|
||||
@@ -977,7 +1001,16 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co
|
||||
// What the records say the registries may keep (novox/hq ADR 0251).
|
||||
"artifacts": true, "collect": true, "images": true, "mirrors": true,
|
||||
// The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239.
|
||||
"delivery": true}
|
||||
"delivery": true,
|
||||
// The build queue, each ask with its position among the waiting (novox/hq issue 455).
|
||||
"queue": true,
|
||||
// A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data.
|
||||
"plans": true}
|
||||
|
||||
// tableBesideVerbs are the JSON verbs whose document carries the table a person reads as its `text`: the
|
||||
// verb answers that table as its output, as it did before it answered data, and the rest as `answer` —
|
||||
// both from the one read, so they never disagree (novox/hq issue 455).
|
||||
var tableBesideVerbs = map[string]bool{"queue": true, "plans": true}
|
||||
|
||||
// overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the
|
||||
// command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs
|
||||
@@ -1097,6 +1130,12 @@ func answerOf(argv []string, stdout []byte, stderr string, ok bool) verbAnswer {
|
||||
var parsed any
|
||||
if json.Unmarshal(bytes.TrimSpace(stdout), &parsed) == nil {
|
||||
answer.Answer = parsed
|
||||
if doc, isDoc := parsed.(map[string]any); isDoc && tableBesideVerbs[argv[0]] {
|
||||
if table, said := doc["text"].(string); said {
|
||||
answer.Output = table + stderr
|
||||
delete(doc, "text")
|
||||
}
|
||||
}
|
||||
if overviewVerbs[argv[0]] {
|
||||
// Once, as data: the same document again as text doubled an answer that already
|
||||
// outgrew one message of the bus (novox/hq issue 314).
|
||||
@@ -1245,6 +1284,16 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||
// Whatever it did, `status` is composed again once it has.
|
||||
defer statusFrom.nudge()
|
||||
}
|
||||
if verb == "plans" && len(argv) > 1 && argv[1] == "wait" {
|
||||
// A wait holds its answer up to its bound (novox/hq issue 229), and is answered in full inside it.
|
||||
asked := ""
|
||||
if i := slices.Index(argv, "--bound"); i > 0 && i+1 < len(argv) {
|
||||
asked = argv[i+1]
|
||||
}
|
||||
if _, bound, err := planWaitAsked("", asked); err == nil {
|
||||
link.AnswersWithin(ctx, bound+planWaitStart)
|
||||
}
|
||||
}
|
||||
if answer, read := readHere(ctx, argv); read {
|
||||
return answer, nil
|
||||
}
|
||||
@@ -1479,7 +1528,9 @@ func seatAnnouncement(handlers map[string]link.ToolHandler) micro.Info {
|
||||
}
|
||||
|
||||
// nodeReads are the `node` subcommands a verb may run: the ones that only read.
|
||||
var nodeReads = map[string]bool{"list": true, "show": true}
|
||||
var nodeReads = map[string]bool{"list": true, "show": true,
|
||||
// Read-only on the node too: the engine judges its directories and changes nothing (novox/hq issue 358).
|
||||
"judge-directories": true}
|
||||
|
||||
// flagsOnly says a command line's rest names no subcommand: empty, or beginning with a flag. For a command
|
||||
// with no subcommands every word is a flag, its value or a name it reads.
|
||||
|
||||
@@ -256,7 +256,7 @@ func stringLit(e ast.Expr) (string, bool) {
|
||||
var accountedFlags = map[string]map[string]string{
|
||||
"status": {"json": "set by the verb: the answer is data"},
|
||||
"seats": {"json": "set by the verb: the answer is data"},
|
||||
"queue": {"json": "not set: the verb answers the table a person reads"},
|
||||
"queue": {"json": "set by the verb: the answer is data, its output the table (novox/hq issue 455)"},
|
||||
"plan": {"json": "set by the verb unless files is asked"},
|
||||
"push": {
|
||||
"wait": "set by the verb to 0: a tool call cannot hold a connection for a whole apply",
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
// make a machine look out of date for ever, or send something `plan` never showed.
|
||||
type sendable struct {
|
||||
Resources []map[string]any
|
||||
// Groups, Identities and Libraries are what the declaration says of libraries and joining modules (novox/hq ADR
|
||||
// 0328): composed only for an engine that keeps libraries, and not sent at all when empty — an older engine
|
||||
// refuses a key it does not know, whole.
|
||||
Groups []catalogue.GroupEntry
|
||||
Identities []catalogue.IdentityEntry
|
||||
Libraries []catalogue.LibraryEntry
|
||||
// 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 +116,15 @@ func (s sendable) Body() ([]byte, error) {
|
||||
if s.Generation > 0 {
|
||||
envelope["generation"] = s.Generation
|
||||
}
|
||||
if len(s.Groups) > 0 {
|
||||
envelope["groups"] = s.Groups
|
||||
}
|
||||
if len(s.Identities) > 0 {
|
||||
envelope["identities"] = s.Identities
|
||||
}
|
||||
if len(s.Libraries) > 0 {
|
||||
envelope["libraries"] = s.Libraries
|
||||
}
|
||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -172,7 +172,7 @@ func composeStatus(open *stores) func(context.Context) ([]byte, error) {
|
||||
var readingVerbs = map[string]bool{
|
||||
"tools": true, "calls": true, "status": true, "nodes": true, "node": true, "modules": true,
|
||||
"seats": true, "builds": true, "plan": true, "queue": true, "durations": true, "hand-acts": true,
|
||||
"doctor": true, "conditions": true, "healers": true,
|
||||
"doctor": true, "conditions": true, "healers": true, "judge-directories": true,
|
||||
}
|
||||
|
||||
// nudgingListener is the enrolment, nudging the summary when a machine said something new.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ require (
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
github.com/novox/mesh-host v0.0.0
|
||||
github.com/therootcompany/xz v1.0.1
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.48.0
|
||||
@@ -35,4 +36,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-20261012222214-7a41e120635b
|
||||
|
||||
@@ -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-20261012222214-7a41e120635b h1:PGdZJHUU9rCDucdrZIqSs4DGyxlo4lokvwmofpGk9RI=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012222214-7a41e120635b/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
@@ -38,6 +38,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw=
|
||||
github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
|
||||
@@ -82,7 +82,7 @@ func theCarriedAccounts(t *testing.T) string {
|
||||
// the commit the mesh runs, or as captured in testdata/beside (internal/beside, novox/hq issue 432).
|
||||
func theTemplate(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
path := filepath.Join(beside.Dir(t, "mesh-host"), "examples", "foundation-first-node-nats.lock")
|
||||
path := filepath.Join(beside.Dir(t, "mesh-host"), "examples", "foundation-first-node.lock")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+17
-1
@@ -336,6 +336,18 @@ func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask
|
||||
// a hand-over is (link.SetuidSearchContext), for the same reason.
|
||||
func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" }
|
||||
|
||||
// AskJudgeDirectoriesSubject is where the controller asks one machine's node-engine to judge, read-only, which of
|
||||
// its declared directories the walk that follows no link another account could make would refuse (novox/hq issue
|
||||
// 358): a request answered once, signed as a hand-over is (link.JudgeDirectoriesContext), for the same reason.
|
||||
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.
|
||||
@@ -614,7 +626,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// issue 356), which it answers on the request's reply: the one request a node is asked.
|
||||
AskHandOverSubject(p.Node),
|
||||
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
|
||||
AskSetuidSearchSubject(p.Node)}
|
||||
AskSetuidSearchSubject(p.Node),
|
||||
// And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way.
|
||||
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.
|
||||
|
||||
@@ -114,7 +114,9 @@ func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
|
||||
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
|
||||
if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) ||
|
||||
!slices.Contains(node.Subscribe, AskSetuidSearchSubject("one")) ||
|
||||
slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) {
|
||||
slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) ||
|
||||
!slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("one")) ||
|
||||
slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("two")) {
|
||||
t.Fatalf("a node cannot answer the hand-over it is asked: %v %v", node.AllowResponses, node.Subscribe)
|
||||
}
|
||||
person, _ := PermissionsFor(Principal{Kind: KindPerson, Node: "one", Module: "jo", PasswordHash: "x"})
|
||||
|
||||
+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.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: {
|
||||
|
||||
@@ -96,6 +96,16 @@ var WritersTable = []WriterRow{
|
||||
{State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal",
|
||||
KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController},
|
||||
// A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a
|
||||
// hand-over is, under a signing context of its own. It changes nothing on the machine.
|
||||
{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
|
||||
|
||||
@@ -17,6 +17,8 @@ var designRows = []string{
|
||||
"a machine's declaration",
|
||||
"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",
|
||||
@@ -186,3 +188,21 @@ func TestASetuidSearchAskHasOnePublisher(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A judgement of a machine's directories asked of it has one publisher, the controller** (novox/hq issue 358), as a
|
||||
// hand-over has.
|
||||
func TestADirectoryJudgementAskHasOnePublisher(t *testing.T) {
|
||||
for _, p := range []Principal{
|
||||
{Kind: KindNode, Node: "laptop"},
|
||||
{Kind: KindNodeTools, Node: "laptop", Module: RuntimeModule},
|
||||
{Kind: KindModule, Node: "laptop", Module: "notes"},
|
||||
} {
|
||||
err := CheckWriters(p, []string{AskJudgeDirectoriesSubject("laptop")})
|
||||
if err == nil || !strings.Contains(err.Error(), "a judgement of a machine's directories asked of it") {
|
||||
t.Errorf("%s may ask a judgement of directories: %v", p.Username(), err)
|
||||
}
|
||||
}
|
||||
if err := CheckWriters(Principal{Kind: KindController}, []string{AskJudgeDirectoriesSubject("laptop")}); err != nil {
|
||||
t.Fatalf("the controller may not ask a judgement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,8 +971,19 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
|
||||
|
||||
case catalogue.ArtifactArchive:
|
||||
src.ownHas(cleanEntry(a.From) + "/**")
|
||||
body, err := pack(filepath.Join(tree, a.From))
|
||||
var body []byte
|
||||
var err error
|
||||
if a.URL != "" {
|
||||
// An upstream file pinned by URL and sha256 (novox/hq ADR 0316): its build source is the pin, which
|
||||
// is in module.json, always part of it.
|
||||
body, err = upstreamArchive(ctx, workspace, a, say)
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
|
||||
}
|
||||
} else {
|
||||
src.ownHas(cleanEntry(a.From) + "/**")
|
||||
body, err = pack(filepath.Join(tree, a.From))
|
||||
}
|
||||
if err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: packing %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,305 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/bzip2"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/therootcompany/xz"
|
||||
)
|
||||
|
||||
// An archive whose source is somebody else's published file, pinned by URL and sha256 (novox/hq ADR 0316
|
||||
// §2, §6, to-be 56 §7): a mail client's release, an add-on's xpi.
|
||||
//
|
||||
// **Fetched by the build seat, checked against the pin before anything is read from it, and packed as any
|
||||
// archive is packed.** What a machine receives is the mesh's own archive from the artifact store, by digest,
|
||||
// exactly as for a directory of the repository; no machine ever asks the vendor. A file whose bytes are not
|
||||
// the pin's fails the build and names both digests, because the address serving something else is the one
|
||||
// thing the pin exists to catch.
|
||||
//
|
||||
// **Kept in the build seat's workspace under its sha256**, so it is fetched once: a vendor that serves only
|
||||
// its newest version at one address (Owl) would otherwise break every later build of the same pin.
|
||||
|
||||
// maxUpstream is the largest upstream file fetched, and the largest a release may unpack to: the node-engine
|
||||
// unpacks no archive larger than this either (mesh-host internal/apply/archive.go, maxArchive).
|
||||
const maxUpstream = 512 << 20
|
||||
|
||||
// maxEntries is the most entries an upstream archive may hold: millions of empty ones would fill the build
|
||||
// seat's inodes and the node's. Betterbird's release holds 64.
|
||||
const maxEntries = 100_000
|
||||
|
||||
// noDowngrade refuses a redirect off https: the digest still guards the bytes, and a fetch in the clear says
|
||||
// to whoever is on the path what the build seat fetches.
|
||||
func noDowngrade(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("%s redirected to %s, which is not https", via[0].URL, req.URL)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("more than 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upstreamClient fetches upstream files: a variable so a test serves its own.
|
||||
var upstreamClient = &http.Client{Timeout: 15 * time.Minute}
|
||||
|
||||
// upstreamDir is where the build seat keeps the upstream files it fetched, by sha256.
|
||||
func upstreamDir(workspace string) string { return filepath.Join(workspace, "upstream") }
|
||||
|
||||
// upstreamArchive is the packed archive of an artifact pinned by URL: the file fetched (or kept from an
|
||||
// earlier build) and checked, then laid out as the artifact says and packed.
|
||||
func upstreamArchive(ctx context.Context, workspace string, a catalogue.Artifact,
|
||||
say func(step, format string, args ...any)) ([]byte, error) {
|
||||
file, err := upstreamFile(ctx, workspace, a, say)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
laid := filepath.Join(workspace, "upstream-"+a.Name)
|
||||
if err := os.RemoveAll(laid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(laid)
|
||||
if err := os.MkdirAll(laid, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.File != "" {
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(laid, a.File), body, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := unpackUpstream(ctx, file, laid, a.Within); err != nil {
|
||||
return nil, fmt.Errorf("%s (%s): %w", a.URL, a.Version, err)
|
||||
}
|
||||
return pack(laid)
|
||||
}
|
||||
|
||||
// upstreamFile is the path of the pinned file in the workspace: kept from an earlier build when its bytes
|
||||
// are still the pin's, else fetched now and kept only when they are.
|
||||
func upstreamFile(ctx context.Context, workspace string, a catalogue.Artifact,
|
||||
say func(step, format string, args ...any)) (string, error) {
|
||||
want := strings.ToLower(a.SHA256)
|
||||
dir := upstreamDir(workspace)
|
||||
kept := filepath.Join(dir, want)
|
||||
if got, err := sumOf(kept); err == nil {
|
||||
if got == want {
|
||||
say("upstream", "%s %s: kept from an earlier build (sha256:%s)", a.Name, a.Version, short(want))
|
||||
return kept, nil
|
||||
}
|
||||
// Changed on disk since it was kept: fetched again rather than trusted.
|
||||
os.Remove(kept)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
say("upstream", "fetching %s (%s)", a.URL, a.Version)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client := *upstreamClient
|
||||
client.CheckRedirect = noDowngrade
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot fetch %s: %w", a.URL, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%s answered %s", a.URL, response.Status)
|
||||
}
|
||||
part, err := os.CreateTemp(dir, want+".*.part")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(part.Name())
|
||||
hash := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(part, hash), io.LimitReader(response.Body, maxUpstream+1))
|
||||
if cerr := part.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching %s: %w", a.URL, err)
|
||||
}
|
||||
if n > maxUpstream {
|
||||
return "", fmt.Errorf("%s is larger than %d bytes, more than a machine unpacks; nothing was kept", a.URL, maxUpstream)
|
||||
}
|
||||
got := hex.EncodeToString(hash.Sum(nil))
|
||||
if got != want {
|
||||
return "", fmt.Errorf("%s was pinned as sha256:%s and what it served is sha256:%s (%d bytes); nothing "+
|
||||
"was kept. The address serves another file than the one reviewed: compare it, then move the pin "+
|
||||
"in a reviewed change (novox/hq ADR 0316)", a.URL, want, got, n)
|
||||
}
|
||||
if err := os.Rename(part.Name(), kept); err != nil {
|
||||
return "", err
|
||||
}
|
||||
say("upstream", "%s %s: %d bytes, sha256:%s as pinned", a.Name, a.Version, n, short(want))
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// sumOf is the sha256 of a file, in hexadecimal.
|
||||
func sumOf(file string) (string, error) {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// unpackUpstream unpacks a tar archive, compressed with gzip, bzip2 or xz (told by its first bytes), into a
|
||||
// directory: of its entries only those within `within` (all, when empty), with that prefix taken off.
|
||||
//
|
||||
// **Only files and directories, and nothing outside.** A link, a device or an entry naming a path outside
|
||||
// the archive, or outside `within`, refuses the build by name: the node-engine unpacks only files and
|
||||
// directories, and an archive quietly unpacked without what it held would be a release that is not the one
|
||||
// pinned.
|
||||
func unpackUpstream(ctx context.Context, file, into, within string) error {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
buffered := bufio.NewReader(f)
|
||||
magic, _ := buffered.Peek(6)
|
||||
var stream io.Reader
|
||||
switch {
|
||||
case bytes.HasPrefix(magic, []byte{0x1f, 0x8b}):
|
||||
zipped, err := gzip.NewReader(buffered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zipped.Close()
|
||||
stream = zipped
|
||||
case bytes.HasPrefix(magic, []byte("BZh")):
|
||||
stream = bzip2.NewReader(buffered)
|
||||
case bytes.HasPrefix(magic, []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}):
|
||||
// Read in Go (github.com/therootcompany/xz, a port of xz-embedded), never by a tool the build seat's image
|
||||
// may not carry: the seat's image is the catalogue's build-agent recipe, which installs git and docker-cli
|
||||
// alone, and a release pinned as .tar.xz failed there on 2026-10-12 (hq issue 345). xzDictMax is a real
|
||||
// maximum: a block whose LZMA2 dictionary is larger is refused before its dictionary is allocated
|
||||
// (ErrMemlimit). The reader keeps a filter decoder for every block of a stream, so the file's framing is
|
||||
// walked first and a file of more blocks than xzBlocksMax is refused before anything is decoded
|
||||
// (xz_framing.go). What the stream unpacks to is bounded by untar (maxUpstream, maxEntries).
|
||||
if err := xzWithinLimits(file); err != nil {
|
||||
return err
|
||||
}
|
||||
zipped, err := xz.NewReader(buffered, xzDictMax)
|
||||
if err != nil {
|
||||
return fmt.Errorf("it is not an xz stream this build seat reads (a dictionary of %d MiB at most): %v",
|
||||
xzDictMax>>20, err)
|
||||
}
|
||||
stream = zipped
|
||||
default:
|
||||
return errors.New("it is not a tar archive compressed with gzip, bzip2 or xz; a single file is kept " +
|
||||
"whole by naming it in `file`")
|
||||
}
|
||||
return untar(stream, into, within)
|
||||
}
|
||||
|
||||
// xzDictMax is the largest xz dictionary read: xz's own presets use at most 64 MiB (-9); a stream asking for
|
||||
// more is refused rather than given the memory.
|
||||
const xzDictMax = 64 << 20
|
||||
|
||||
// untar writes a tar stream's files and directories within `within` into a directory.
|
||||
func untar(stream io.Reader, into, within string) error {
|
||||
reader := tar.NewReader(stream)
|
||||
prefix := ""
|
||||
if within != "" {
|
||||
prefix = path.Clean(within) + "/"
|
||||
}
|
||||
var total int64
|
||||
files, entries := 0, 0
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entries++; entries > maxEntries {
|
||||
return fmt.Errorf("it holds more than %d entries", maxEntries)
|
||||
}
|
||||
if header.Typeflag == tar.TypeXGlobalHeader {
|
||||
// A pax global header (git archive writes one) names no file.
|
||||
continue
|
||||
}
|
||||
name := path.Clean(strings.TrimPrefix(header.Name, "./"))
|
||||
if path.IsAbs(name) || name == ".." || strings.HasPrefix(name, "../") {
|
||||
return fmt.Errorf("%s names a path outside the archive", header.Name)
|
||||
}
|
||||
if name == "." || name+"/" == prefix ||
|
||||
(header.Typeflag == tar.TypeDir && strings.HasPrefix(prefix, name+"/")) {
|
||||
// The archive's root, the directory taken, or one of its parents.
|
||||
continue
|
||||
}
|
||||
if prefix != "" {
|
||||
if !strings.HasPrefix(name, prefix) {
|
||||
return fmt.Errorf("%s is outside %s, the directory the artifact is taken from", header.Name, within)
|
||||
}
|
||||
name = strings.TrimPrefix(name, prefix)
|
||||
}
|
||||
target := filepath.Join(into, filepath.FromSlash(name))
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
total += header.Size
|
||||
if total > maxUpstream {
|
||||
return fmt.Errorf("it unpacks to more than %d bytes, more than a machine unpacks", maxUpstream)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if header.Mode&0o111 != 0 {
|
||||
mode = 0o755
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, io.LimitReader(reader, header.Size)); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
files++
|
||||
default:
|
||||
return fmt.Errorf("%s is a %c, and an archive carries only files and directories", header.Name,
|
||||
header.Typeflag)
|
||||
}
|
||||
}
|
||||
if files == 0 {
|
||||
if within != "" {
|
||||
return fmt.Errorf("it holds no file within %s", within)
|
||||
}
|
||||
return errors.New("it holds no file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// An archive pinned by URL and sha256 (novox/hq ADR 0316 §2, §6; How it is checked: "an upstream artifact
|
||||
// whose download does not match its sha256 fails the build and names both digests").
|
||||
|
||||
type tarEntry struct {
|
||||
name, body string
|
||||
mode int64
|
||||
kind byte
|
||||
}
|
||||
|
||||
func tarOf(t *testing.T, entries []tarEntry) []byte {
|
||||
t.Helper()
|
||||
var raw bytes.Buffer
|
||||
w := tar.NewWriter(&raw)
|
||||
for _, e := range entries {
|
||||
kind := e.kind
|
||||
if kind == 0 {
|
||||
kind = tar.TypeReg
|
||||
}
|
||||
h := &tar.Header{Name: e.name, Mode: e.mode, Typeflag: kind, Size: int64(len(e.body))}
|
||||
if kind == tar.TypeSymlink {
|
||||
h.Linkname, h.Size = "/usr/share/hunspell", 0
|
||||
}
|
||||
if kind == tar.TypeDir {
|
||||
h.Size = 0
|
||||
}
|
||||
if err := w.WriteHeader(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if kind == tar.TypeReg {
|
||||
if _, err := w.Write([]byte(e.body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw.Bytes()
|
||||
}
|
||||
|
||||
func gzipped(t *testing.T, body []byte) []byte {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
z := gzip.NewWriter(&out)
|
||||
if _, err := z.Write(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := z.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func sumHex(b []byte) string {
|
||||
s := sha256.Sum256(b)
|
||||
return hex.EncodeToString(s[:])
|
||||
}
|
||||
|
||||
// vendor serves files by path over https and counts what it was asked.
|
||||
type vendor struct {
|
||||
files map[string][]byte
|
||||
asked int
|
||||
}
|
||||
|
||||
func serveVendor(t *testing.T, v *vendor) string {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
v.asked++
|
||||
body, ok := v.files[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write(body)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
before := upstreamClient
|
||||
// Under a public name, as a manifest must say it: example.com, which the test server's certificate
|
||||
// names, dialled at the server.
|
||||
transport := server.Client().Transport.(*http.Transport).Clone()
|
||||
addr := server.Listener.Addr().String()
|
||||
transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
||||
}
|
||||
upstreamClient = &http.Client{Transport: transport}
|
||||
t.Cleanup(func() { upstreamClient = before })
|
||||
return "https://example.com"
|
||||
}
|
||||
|
||||
// entriesOf lists a packed archive: each file with its mode and content.
|
||||
func entriesOf(t *testing.T, packed []byte) map[string]string {
|
||||
t.Helper()
|
||||
z, err := gzip.NewReader(bytes.NewReader(packed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := tar.NewReader(z)
|
||||
out := map[string]string{}
|
||||
for {
|
||||
h, err := r.Next()
|
||||
if err == io.EOF {
|
||||
return out
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(r)
|
||||
out[h.Name] = string(body) + " " + os.FileMode(h.Mode).String()
|
||||
}
|
||||
}
|
||||
|
||||
func release(t *testing.T) []byte {
|
||||
return gzipped(t, tarOf(t, []tarEntry{
|
||||
{name: "betterbird/", kind: tar.TypeDir, mode: 0o755},
|
||||
{name: "betterbird/application.ini", body: "[App]\nVersion=153.4.0\n", mode: 0o644},
|
||||
{name: "betterbird/betterbird", body: "#!/bin/sh\n", mode: 0o755},
|
||||
{name: "betterbird/defaults/pref/channel-prefs.js", body: "pref(\"app.update.channel\", \"default\");\n", mode: 0o644},
|
||||
}))
|
||||
}
|
||||
|
||||
func pinnedManifest(base, sum string) string {
|
||||
return `{"module":"betterbird","version":"1",
|
||||
"build":{"artifacts":[
|
||||
{"name":"archive","kind":"archive","url":"` + base + `/release.tar.gz","sha256":"` + sum + `","version":"153.4.0esr-bb10","within":"betterbird"}]},
|
||||
"resources":[{"id":"installation","type":"archive","path":"/opt/betterbird","artifact":"archive"}]}`
|
||||
}
|
||||
|
||||
func TestAnArchivePinnedByURLIsFetchedCheckedAndPackedWithoutItsOwnDirectory(t *testing.T) {
|
||||
body := release(t)
|
||||
v := &vendor{files: map[string][]byte{"/release.tar.gz": body}}
|
||||
base := serveVendor(t, v)
|
||||
r, workspace := aRepository(t, pinnedManifest(base, sumHex(body)), nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/catalogue.git", "", "", workspace, nil,
|
||||
Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest, _ := got.Manifest.Resources[0]["digest"].(string)
|
||||
if !strings.HasPrefix(digest, "sha256:") || r.archives["betterbird/archive"] != digest {
|
||||
t.Fatalf("the release was not published and pinned as the module's archive: %v, published %v",
|
||||
got.Manifest.Resources[0], r.archives)
|
||||
}
|
||||
|
||||
packed, err := upstreamArchive(context.Background(), workspace, catalogue.Artifact{Name: "archive", Kind: "archive",
|
||||
URL: base + "/release.tar.gz", SHA256: sumHex(body), Version: "153.4.0esr-bb10", Within: "betterbird"},
|
||||
func(string, string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if "sha256:"+sumHex(packed) != digest {
|
||||
t.Fatalf("a second packing of one pin gave another digest: %s and %s", digest, sumHex(packed))
|
||||
}
|
||||
entries := entriesOf(t, packed)
|
||||
want := map[string]string{
|
||||
"application.ini": "[App]\nVersion=153.4.0\n -rw-r--r--",
|
||||
"betterbird": "#!/bin/sh\n -rwxr-xr-x",
|
||||
"defaults/pref/channel-prefs.js": "pref(\"app.update.channel\", \"default\");\n -rw-r--r--",
|
||||
}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("the archive holds %v, want %v", entries, want)
|
||||
}
|
||||
for name, w := range want {
|
||||
if entries[name] != w {
|
||||
t.Errorf("%s is %q in the archive, want %q", name, entries[name], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFileIsFetchedOnceAndKeptByItsDigest(t *testing.T) {
|
||||
// The vendor of an add-on serves only its newest version at one address: a pin that was fetched once
|
||||
// must still build after the address moved on.
|
||||
body := release(t)
|
||||
v := &vendor{files: map[string][]byte{"/release.tar.gz": body}}
|
||||
base := serveVendor(t, v)
|
||||
r, workspace := aRepository(t, pinnedManifest(base, sumHex(body)), nil)
|
||||
first, err := Build(context.Background(), r.run, r, "https://forge.invalid/catalogue.git", "", "", workspace, nil,
|
||||
Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.files["/release.tar.gz"] = []byte("the vendor's next release")
|
||||
second, err := Build(context.Background(), r.run, r, "https://forge.invalid/catalogue.git", "", "", workspace, nil,
|
||||
Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("a pin fetched before failed to build once the address served something else: %v", err)
|
||||
}
|
||||
if v.asked != 1 {
|
||||
t.Fatalf("the vendor was asked %d times for one pin, want once", v.asked)
|
||||
}
|
||||
if first.Manifest.Resources[0]["digest"] != second.Manifest.Resources[0]["digest"] {
|
||||
t.Fatal("two builds of one pin gave two digests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFileWhoseDownloadDoesNotMatchItsPinFailsAndNamesBothDigests(t *testing.T) {
|
||||
pinned := release(t)
|
||||
served := gzipped(t, tarOf(t, []tarEntry{{name: "betterbird/betterbird", body: "not what was reviewed", mode: 0o755}}))
|
||||
v := &vendor{files: map[string][]byte{"/release.tar.gz": served}}
|
||||
base := serveVendor(t, v)
|
||||
r, workspace := aRepository(t, pinnedManifest(base, sumHex(pinned)), nil)
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/catalogue.git", "", "", workspace, nil,
|
||||
Npmrc{}, GitCredential{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a download that is not the pinned file was built")
|
||||
}
|
||||
for _, digest := range []string{sumHex(pinned), sumHex(served)} {
|
||||
if !strings.Contains(err.Error(), "sha256:"+digest) {
|
||||
t.Errorf("the refusal does not name sha256:%s: %v", digest, err)
|
||||
}
|
||||
}
|
||||
if len(r.archives) != 0 {
|
||||
t.Fatalf("something was published: %v", r.archives)
|
||||
}
|
||||
kept, _ := filepath.Glob(filepath.Join(upstreamDir(workspace), "*"))
|
||||
if len(kept) != 0 {
|
||||
t.Fatalf("the refused download was kept: %v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFileThatIsNotAnArchiveIsKeptWholeUnderItsName(t *testing.T) {
|
||||
xpi := []byte("PK\x03\x04 an add-on")
|
||||
v := &vendor{files: map[string][]byte{"/owl.xpi": xpi}}
|
||||
base := serveVendor(t, v)
|
||||
packed, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "owl", Kind: "archive",
|
||||
URL: base + "/owl.xpi", SHA256: sumHex(xpi), Version: "1.5.6.1", File: "owl.xpi"}, func(string, string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := entriesOf(t, packed)
|
||||
if len(entries) != 1 || entries["owl.xpi"] != string(xpi)+" -rw-r--r--" {
|
||||
t.Fatalf("the add-on was not kept whole as owl.xpi: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamArchiveHoldingALinkOrAnEntryOutsideItsDirectoryIsRefused(t *testing.T) {
|
||||
cases := map[string][]tarEntry{
|
||||
// The community package links the dictionaries; a release carrying a link is not one a machine unpacks.
|
||||
"is a 2": {{name: "betterbird/betterbird", body: "x", mode: 0o755},
|
||||
{name: "betterbird/dictionaries", kind: tar.TypeSymlink}},
|
||||
"outside betterbird": {{name: "betterbird/betterbird", body: "x", mode: 0o755},
|
||||
{name: "README", body: "x", mode: 0o644}},
|
||||
"outside the archive": {{name: "../etc/passwd", body: "x", mode: 0o644}},
|
||||
"no file within": {{name: "betterbird/", kind: tar.TypeDir, mode: 0o755}},
|
||||
}
|
||||
for want, entries := range cases {
|
||||
body := gzipped(t, tarOf(t, entries))
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.gz": body}}
|
||||
base := serveVendor(t, v)
|
||||
_, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "archive", Kind: "archive",
|
||||
URL: base + "/r.tar.gz", SHA256: sumHex(body), Version: "1", Within: "betterbird"}, func(string, string, ...any) {})
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("an archive whose entry is %q was not refused for it: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamArchiveCompressedWithXzIsRead(t *testing.T) {
|
||||
// Betterbird publishes its Linux release as .tar.xz; the build seat's image carries xz for it.
|
||||
if _, err := exec.LookPath("xz"); err != nil {
|
||||
t.Skip("NOT TESTED here: no xz on this machine (the build seat's image carries it)")
|
||||
}
|
||||
plain := tarOf(t, []tarEntry{{name: "betterbird/betterbird", body: "#!/bin/sh\n", mode: 0o755}})
|
||||
cmd := exec.Command("xz", "-zc")
|
||||
cmd.Stdin = bytes.NewReader(plain)
|
||||
compressed, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.xz": compressed}}
|
||||
base := serveVendor(t, v)
|
||||
packed, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "archive", Kind: "archive",
|
||||
URL: base + "/r.tar.xz", SHA256: sumHex(compressed), Version: "1", Within: "betterbird"}, func(string, string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for n := range entriesOf(t, packed) {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if strings.Join(names, ",") != "betterbird" {
|
||||
t.Fatalf("the xz release unpacked to %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamTarWithAGlobalHeaderNestedWithinAndTrailingDataIsRead(t *testing.T) {
|
||||
// git archive writes a pax global header; a release may unpack under a/b with a/ listed first; xz may be
|
||||
// followed by bytes after the tar's end, which must be read before xz is waited for.
|
||||
var raw bytes.Buffer
|
||||
w := tar.NewWriter(&raw)
|
||||
w.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, Name: "pax_global_header", PAXRecords: map[string]string{"comment": "abc"}})
|
||||
w.WriteHeader(&tar.Header{Name: "a/", Typeflag: tar.TypeDir, Mode: 0o755})
|
||||
w.WriteHeader(&tar.Header{Name: "a/b/", Typeflag: tar.TypeDir, Mode: 0o755})
|
||||
w.WriteHeader(&tar.Header{Name: "a/b/f", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1})
|
||||
w.Write([]byte("x"))
|
||||
w.Close()
|
||||
body := gzipped(t, raw.Bytes())
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.gz": body}}
|
||||
base := serveVendor(t, v)
|
||||
packed, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "a", Kind: "archive",
|
||||
URL: base + "/r.tar.gz", SHA256: sumHex(body), Version: "1", Within: "a/b"}, func(string, string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e := entriesOf(t, packed); len(e) != 1 || e["f"] == "" {
|
||||
t.Fatalf("%v", e)
|
||||
}
|
||||
if _, err := exec.LookPath("xz"); err != nil {
|
||||
t.Skip("NOT TESTED here: no xz")
|
||||
}
|
||||
// A megabyte after the tar's end: more than a pipe holds.
|
||||
trailing := append(append([]byte{}, raw.Bytes()...), bytes.Repeat([]byte{0}, 1<<20)...)
|
||||
cmd := exec.Command("xz", "-zc")
|
||||
cmd.Stdin = bytes.NewReader(trailing)
|
||||
compressed, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.files["/r.tar.xz"] = compressed
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := upstreamArchive(ctx, t.TempDir(), catalogue.Artifact{Name: "a", Kind: "archive", URL: base + "/r.tar.xz",
|
||||
SHA256: sumHex(compressed), Version: "1", Within: "a/b"}, func(string, string, ...any) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamArchiveWithTooManyEntriesIsRefused(t *testing.T) {
|
||||
var raw bytes.Buffer
|
||||
w := tar.NewWriter(&raw)
|
||||
for i := 0; i <= maxEntries; i++ {
|
||||
w.WriteHeader(&tar.Header{Name: fmt.Sprintf("d%d/", i), Typeflag: tar.TypeDir, Mode: 0o755})
|
||||
}
|
||||
w.Close()
|
||||
body := gzipped(t, raw.Bytes())
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.gz": body}}
|
||||
base := serveVendor(t, v)
|
||||
_, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "a", Kind: "archive",
|
||||
URL: base + "/r.tar.gz", SHA256: sumHex(body), Version: "1"}, func(string, string, ...any) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "entries") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARedirectOffHttpsIsNotFollowed(t *testing.T) {
|
||||
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("x")) }))
|
||||
t.Cleanup(plain.Close)
|
||||
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, plain.URL+"/r", http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(secure.Close)
|
||||
before := upstreamClient
|
||||
upstreamClient = secure.Client()
|
||||
t.Cleanup(func() { upstreamClient = before })
|
||||
_, err := upstreamFile(context.Background(), t.TempDir(), catalogue.Artifact{Name: "a", URL: secure.URL + "/r",
|
||||
SHA256: sumHex([]byte("x")), Version: "1", File: "a"}, func(string, string, ...any) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "not https") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzReleaseIsReadWithoutAnyXzToolOnTheBuildSeat(t *testing.T) {
|
||||
// The build seat's image (the catalogue's build-agent recipe) carries git and docker-cli alone: Betterbird's
|
||||
// .tar.xz failed there on 2026-10-12 with "this build seat has no xz to read it" (hq issue 345). It is
|
||||
// read in Go, with nothing on PATH.
|
||||
t.Setenv("PATH", "")
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "release.tar.xz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.xz": body}}
|
||||
base := serveVendor(t, v)
|
||||
packed, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "archive", Kind: "archive",
|
||||
URL: base + "/r.tar.xz", SHA256: sumHex(body), Version: "1", Within: "betterbird"}, func(string, string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := entriesOf(t, packed)
|
||||
if len(e) != 2 || e["betterbird"] != "#!/bin/sh\n -rwxr-xr-x" || e["defaults/p.js"] != "pref(\"a\", 1);\n -rw-r--r--" {
|
||||
t.Fatalf("%v", e)
|
||||
}
|
||||
}
|
||||
|
||||
// withDictionary is an xz stream whose first block's LZMA2 dictionary property is props, its header's CRC32
|
||||
// recomputed: a stream asking for a dictionary of its own choosing.
|
||||
func withDictionary(t *testing.T, stream []byte, props byte) []byte {
|
||||
t.Helper()
|
||||
out := append([]byte{}, stream...)
|
||||
const at = 12 // the stream header
|
||||
size := (int(out[at]) + 1) * 4
|
||||
header := out[at : at+size]
|
||||
flags := header[1]
|
||||
i := 2
|
||||
readVarint := func() {
|
||||
for header[i]&0x80 != 0 {
|
||||
i++
|
||||
}
|
||||
i++
|
||||
}
|
||||
if flags&0x40 != 0 {
|
||||
readVarint()
|
||||
}
|
||||
if flags&0x80 != 0 {
|
||||
readVarint()
|
||||
}
|
||||
if header[i] != 0x21 || header[i+1] != 0x01 {
|
||||
t.Fatalf("the fixture's first filter is not LZMA2: % x", header)
|
||||
}
|
||||
header[i+2] = props
|
||||
binary.LittleEndian.PutUint32(header[size-4:], crc32.ChecksumIEEE(header[:size-4]))
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAnXzStreamAskingForTooLargeADictionaryIsRefused(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join("testdata", "release.tar.xz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 1 GiB (props 36) and 4 GiB less one (props 40): over the 64 MiB maximum, refused before allocation.
|
||||
for _, props := range []byte{36, 40} {
|
||||
crafted := withDictionary(t, body, props)
|
||||
v := &vendor{files: map[string][]byte{"/r.tar.xz": crafted}}
|
||||
base := serveVendor(t, v)
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
_, err := upstreamArchive(context.Background(), t.TempDir(), catalogue.Artifact{Name: "archive", Kind: "archive",
|
||||
URL: base + "/r.tar.xz", SHA256: sumHex(crafted), Version: "1", Within: "betterbird"}, func(string, string, ...any) {})
|
||||
runtime.ReadMemStats(&after)
|
||||
if err == nil || !strings.Contains(err.Error(), "dictionary") {
|
||||
t.Errorf("props %d: a dictionary over the maximum was not refused: %v", props, err)
|
||||
}
|
||||
if grew := after.TotalAlloc - before.TotalAlloc; grew > 256<<20 {
|
||||
t.Errorf("props %d: %d bytes were allocated for a refused stream", props, grew)
|
||||
}
|
||||
}
|
||||
// The fixture as it is (a small dictionary) still reads.
|
||||
if crafted := withDictionary(t, body, 0); len(crafted) == 0 {
|
||||
t.Fatal("no fixture")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// xzBlocksMax and xzStreamsMax are the most blocks and the most concatenated streams an xz file may hold for
|
||||
// the build seat to read it.
|
||||
//
|
||||
// **Why a block count is limited at all.** The xz reader (github.com/therootcompany/xz v1.0.1) keeps one
|
||||
// filter decoder for every block that names a delta or BCJ filter, and frees them only when a stream ends:
|
||||
// its block counters are reset per stream, not per block. A stream of many tiny blocks therefore grows the
|
||||
// heap with its length: an `xz --block-size=1 --delta` file of 6 MB kept about 67 MiB live, which comes to
|
||||
// about 5.6 GiB at the 512 MiB download cap (review 68 on mesh-controller pull request 264, hq issue 345).
|
||||
//
|
||||
// **Why 1,024.** At about 340 bytes of decoder a block, 1,024 blocks keep at most about 0.35 MiB live. A
|
||||
// real release is far below it: Betterbird's archive is 1 stream with 1 block, and multi-threaded xz cuts
|
||||
// blocks of three times the dictionary (24 MiB at the default preset), so even 512 MiB unpacked in blocks
|
||||
// of 512 KiB stays within it. Streams are limited to 16 because a release is one stream; the decoders are
|
||||
// reused from one stream to the next, so the stream limit only bounds the work of this walk.
|
||||
const (
|
||||
xzBlocksMax = 1024
|
||||
xzStreamsMax = 16
|
||||
)
|
||||
|
||||
// xzWithinLimits walks an xz file's framing, without decompressing anything, and refuses one holding more
|
||||
// blocks or streams than the build seat reads.
|
||||
//
|
||||
// **The framing, not the index.** Each stream's index at its end lists its blocks, but the reader compares
|
||||
// the index with the blocks only after it has decoded them all, so a file whose index lies would still be
|
||||
// decoded block by block. This walk follows the same framing the reader follows: stream header, each block
|
||||
// header, the block's LZMA2 chunks (each chunk header carries its own compressed size, so a chunk is skipped
|
||||
// without being decoded), its padding and check, then the index and the footer. It counts the blocks the
|
||||
// reader would decode, and it also refuses an index whose count of blocks is not the count walked.
|
||||
func xzWithinLimits(file string) error {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, _, err = xzFraming(bufio.NewReader(f))
|
||||
if errors.Is(err, errXzTooManyBlocks) || errors.Is(err, errXzTooManyStreams) {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("it is not an xz file this build seat reads: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errXzTooManyBlocks = fmt.Errorf("it holds more than %d xz blocks, the most this build seat reads: "+
|
||||
"its xz reader keeps a filter decoder for every block (hq issue 345)", xzBlocksMax)
|
||||
errXzTooManyStreams = fmt.Errorf("it holds more than %d concatenated xz streams, the most this build "+
|
||||
"seat reads", xzStreamsMax)
|
||||
)
|
||||
|
||||
// xzFraming counts the streams and blocks of an xz file, checking its framing as it goes. It stops with
|
||||
// errXzTooManyBlocks or errXzTooManyStreams at the first block or stream over the limit.
|
||||
func xzFraming(r *bufio.Reader) (streams, blocks int, err error) {
|
||||
full := func(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
if err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
skip := func(n int) error {
|
||||
if got, err := r.Discard(n); err != nil || got != n {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
header, err := full(12)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: header: %v", streams+1, err)
|
||||
}
|
||||
if !bytes.Equal(header[:6], []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}) {
|
||||
return streams, blocks, fmt.Errorf("stream %d: no xz stream header", streams+1)
|
||||
}
|
||||
if header[6] != 0 || header[7]&0xf0 != 0 ||
|
||||
crc32.ChecksumIEEE(header[6:8]) != binary.LittleEndian.Uint32(header[8:12]) {
|
||||
return streams, blocks, fmt.Errorf("stream %d: a corrupt stream header", streams+1)
|
||||
}
|
||||
if streams++; streams > xzStreamsMax {
|
||||
return streams, blocks, errXzTooManyStreams
|
||||
}
|
||||
checkSize := 0
|
||||
if check := int(header[7]); check != 0 {
|
||||
checkSize = 4 << ((check - 1) / 3)
|
||||
}
|
||||
inStream := 0
|
||||
for {
|
||||
first, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: no index: %v", streams, io.ErrUnexpectedEOF)
|
||||
}
|
||||
if first == 0 {
|
||||
break // the index indicator
|
||||
}
|
||||
rest, err := full(int(first)*4 + 3)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: header: %v", streams, inStream+1, err)
|
||||
}
|
||||
if err := xzBlockHeader(append([]byte{first}, rest...)); err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: %v", streams, inStream+1, err)
|
||||
}
|
||||
inStream++
|
||||
if blocks++; blocks > xzBlocksMax {
|
||||
return streams, blocks, errXzTooManyBlocks
|
||||
}
|
||||
// The block's LZMA2 chunks, up to the end marker.
|
||||
size := 0
|
||||
for {
|
||||
control, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: %v", streams, inStream, io.ErrUnexpectedEOF)
|
||||
}
|
||||
size++
|
||||
if control == 0 {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case control == 1 || control == 2: // stored: a 16-bit size less one, then the bytes
|
||||
b, err := full(2)
|
||||
if err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(b)) + 1
|
||||
if err := skip(n); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
size += 2 + n
|
||||
case control >= 0x80: // LZMA: unpacked and packed sizes, the properties byte on a reset
|
||||
b, err := full(4)
|
||||
if err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
n := int(binary.BigEndian.Uint16(b[2:])) + 1
|
||||
size += 4
|
||||
if control >= 0xc0 {
|
||||
if err := skip(1); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
size++
|
||||
}
|
||||
if err := skip(n); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
size += n
|
||||
default:
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: an LZMA2 chunk of kind %#x",
|
||||
streams, inStream, control)
|
||||
}
|
||||
}
|
||||
if pad := (4 - size%4) % 4; pad > 0 {
|
||||
b, err := full(pad)
|
||||
if err != nil || !bytes.Equal(b, make([]byte, pad)) {
|
||||
return streams, blocks, fmt.Errorf("stream %d, block %d: corrupt block padding", streams, inStream)
|
||||
}
|
||||
}
|
||||
if err := skip(checkSize); err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
}
|
||||
// The index: its count of records, two numbers each, padding, CRC32.
|
||||
used := 1
|
||||
count, n, err := xzVLI(r)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err)
|
||||
}
|
||||
used += n
|
||||
if count != uint64(inStream) {
|
||||
return streams, blocks, fmt.Errorf("stream %d: its index lists %d blocks, but it holds %d",
|
||||
streams, count, inStream)
|
||||
}
|
||||
for i := 0; i < 2*inStream; i++ {
|
||||
_, n, err := xzVLI(r)
|
||||
if err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err)
|
||||
}
|
||||
used += n
|
||||
}
|
||||
if err := skip((4-used%4)%4 + 4); err != nil {
|
||||
return streams, blocks, fmt.Errorf("stream %d: index: %v", streams, err)
|
||||
}
|
||||
footer, err := full(12)
|
||||
if err != nil || footer[10] != 'Y' || footer[11] != 'Z' {
|
||||
return streams, blocks, fmt.Errorf("stream %d: no stream footer", streams)
|
||||
}
|
||||
// Stream padding: zero bytes, a multiple of four, then the end or another stream.
|
||||
zeros := 0
|
||||
for {
|
||||
b, err := r.ReadByte()
|
||||
if err == io.EOF {
|
||||
if zeros%4 != 0 {
|
||||
return streams, blocks, errors.New("stream padding not a multiple of four bytes")
|
||||
}
|
||||
return streams, blocks, nil
|
||||
}
|
||||
if err != nil {
|
||||
return streams, blocks, err
|
||||
}
|
||||
if b != 0 {
|
||||
_ = r.UnreadByte()
|
||||
break
|
||||
}
|
||||
zeros++
|
||||
}
|
||||
if zeros%4 != 0 {
|
||||
return streams, blocks, errors.New("stream padding not a multiple of four bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xzBlockHeader checks one block header: its CRC32, its flags, and that its last filter is LZMA2, the only
|
||||
// one whose chunks this walk can skip (and the only last filter the reader decodes).
|
||||
func xzBlockHeader(h []byte) error {
|
||||
size := len(h)
|
||||
if crc32.ChecksumIEEE(h[:size-4]) != binary.LittleEndian.Uint32(h[size-4:]) {
|
||||
return errors.New("a corrupt block header")
|
||||
}
|
||||
flags := h[1]
|
||||
if flags&0x3c != 0 {
|
||||
return errors.New("a block header with reserved flags")
|
||||
}
|
||||
body := bytes.NewReader(h[2 : size-4])
|
||||
if flags&0x40 != 0 {
|
||||
if _, _, err := xzVLI(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if flags&0x80 != 0 {
|
||||
if _, _, err := xzVLI(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var last uint64
|
||||
for i := 0; i <= int(flags&3); i++ {
|
||||
id, _, err := xzVLI(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
props, _, err := xzVLI(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if props > uint64(body.Len()) {
|
||||
return errors.New("a corrupt block header")
|
||||
}
|
||||
if _, err := body.Seek(int64(props), io.SeekCurrent); err != nil {
|
||||
return err
|
||||
}
|
||||
last = id
|
||||
}
|
||||
if last != 0x21 {
|
||||
return fmt.Errorf("a block whose last filter is %#x, not LZMA2", last)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// xzVLI reads one of xz's variable-length integers (at most 9 bytes, 7 bits each), and how many bytes it took.
|
||||
func xzVLI(r io.ByteReader) (uint64, int, error) {
|
||||
var v uint64
|
||||
for i := 0; i < 9; i++ {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return 0, i, io.ErrUnexpectedEOF
|
||||
}
|
||||
v |= uint64(b&0x7f) << (7 * i)
|
||||
if b&0x80 == 0 {
|
||||
if i > 0 && b == 0 {
|
||||
return 0, i + 1, errors.New("a number not in its shortest form")
|
||||
}
|
||||
return v, i + 1, nil
|
||||
}
|
||||
}
|
||||
return 0, 9, errors.New("a number longer than nine bytes")
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// xzStream is one xz stream (no integrity check) of data cut into blocks of blockSize bytes, each block's
|
||||
// LZMA2 data stored uncompressed, each block a delta filter before LZMA2 when delta is set: the shape of
|
||||
// `xz --block-size=N --filters=delta,lzma2`, built without any xz tool. listed is how many blocks its index
|
||||
// lists (-1: as many as it holds), so a stream whose index lies can be built too.
|
||||
func xzStream(data []byte, blockSize int, delta bool, listed int) []byte {
|
||||
var out bytes.Buffer
|
||||
flags := []byte{0, 0}
|
||||
out.Write([]byte{0xfd, '7', 'z', 'X', 'Z', 0x00})
|
||||
out.Write(flags)
|
||||
out.Write(binary.LittleEndian.AppendUint32(nil, crc32.ChecksumIEEE(flags)))
|
||||
type record struct{ unpadded, uncompressed uint64 }
|
||||
var records []record
|
||||
for at := 0; at < len(data); at += blockSize {
|
||||
block := data[at:min(at+blockSize, len(data))]
|
||||
if delta {
|
||||
// Delta-encoded with a distance of one; the decoder's history starts empty at each block.
|
||||
encoded := make([]byte, len(block))
|
||||
for i := range block {
|
||||
encoded[i] = block[i]
|
||||
if i > 0 {
|
||||
encoded[i] -= block[i-1]
|
||||
}
|
||||
}
|
||||
block = encoded
|
||||
}
|
||||
header := []byte{2, 0} // 12 bytes
|
||||
if delta {
|
||||
header[1] = 1 // two filters
|
||||
header = append(header, 0x03, 0x01, 0x00)
|
||||
}
|
||||
header = append(header, 0x21, 0x01, 0x00) // LZMA2, a 4 KiB dictionary
|
||||
for len(header) < 8 {
|
||||
header = append(header, 0)
|
||||
}
|
||||
header = binary.LittleEndian.AppendUint32(header, crc32.ChecksumIEEE(header))
|
||||
out.Write(header)
|
||||
size := 0
|
||||
for i, chunk := 0, block; len(chunk) > 0; i++ {
|
||||
n := min(len(chunk), 1<<16)
|
||||
control := byte(2)
|
||||
if i == 0 {
|
||||
control = 1 // the first chunk of a block resets the dictionary
|
||||
}
|
||||
out.WriteByte(control)
|
||||
out.Write(binary.BigEndian.AppendUint16(nil, uint16(n-1)))
|
||||
out.Write(chunk[:n])
|
||||
size += 3 + n
|
||||
chunk = chunk[n:]
|
||||
}
|
||||
out.WriteByte(0)
|
||||
size++
|
||||
out.Write(make([]byte, (4-size%4)%4))
|
||||
records = append(records, record{uint64(len(header) + size), uint64(len(block))})
|
||||
}
|
||||
if listed >= 0 {
|
||||
for len(records) < listed {
|
||||
records = append(records, records[0])
|
||||
}
|
||||
records = records[:listed]
|
||||
}
|
||||
vli := func(b []byte, v uint64) []byte {
|
||||
for v >= 0x80 {
|
||||
b = append(b, byte(v)|0x80)
|
||||
v >>= 7
|
||||
}
|
||||
return append(b, byte(v))
|
||||
}
|
||||
index := vli([]byte{0}, uint64(len(records)))
|
||||
for _, r := range records {
|
||||
index = vli(vli(index, r.unpadded), r.uncompressed)
|
||||
}
|
||||
for len(index)%4 != 0 {
|
||||
index = append(index, 0)
|
||||
}
|
||||
index = binary.LittleEndian.AppendUint32(index, crc32.ChecksumIEEE(index))
|
||||
out.Write(index)
|
||||
footer := binary.LittleEndian.AppendUint32(nil, uint32(len(index)/4-1))
|
||||
footer = append(footer, flags...)
|
||||
out.Write(binary.LittleEndian.AppendUint32(nil, crc32.ChecksumIEEE(footer)))
|
||||
out.Write(footer)
|
||||
out.WriteString("YZ")
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// tarHolding is a tar holding betterbird/data with body.
|
||||
func tarHolding(t *testing.T, body []byte) []byte {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
w := tar.NewWriter(&out)
|
||||
if err := w.WriteHeader(&tar.Header{Name: "betterbird/data", Mode: 0o644, Size: int64(len(body)),
|
||||
Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write(body)
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeTemp(t *testing.T, body []byte) string {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "r.tar.xz")
|
||||
if err := os.WriteFile(file, body, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func TestAnXzFileOfAFewDeltaBlocksAndTwoStreamsIsRead(t *testing.T) {
|
||||
// The streams this test builds are ones the reader decodes, so what the walk refuses below is the
|
||||
// same framing the reader follows.
|
||||
body := bytes.Repeat([]byte("betterbird "), 300)
|
||||
whole := tarHolding(t, body)
|
||||
half := len(whole) / 2
|
||||
for name, file := range map[string][]byte{
|
||||
"one stream of delta blocks": xzStream(whole, 512, true, -1),
|
||||
"two streams with padding": append(append(xzStream(whole[:half], 700, true, -1), 0, 0, 0, 0),
|
||||
xzStream(whole[half:], 100, false, -1)...),
|
||||
} {
|
||||
path := writeTemp(t, file)
|
||||
if xzPath, err := exec.LookPath("xz"); err == nil {
|
||||
if out, err := exec.Command(xzPath, "-t", path).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s: xz itself refuses the built stream: %v %s", name, err, out)
|
||||
}
|
||||
}
|
||||
into := t.TempDir()
|
||||
if err := unpackUpstream(context.Background(), path, into, "betterbird"); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(into, "data"))
|
||||
if err != nil || !bytes.Equal(got, body) {
|
||||
t.Fatalf("%s: unpacked %d bytes, want %d: %v", name, len(got), len(body), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzFileOfManyDeltaBlocksIsRefusedBeforeItIsDecoded(t *testing.T) {
|
||||
// Review 68 on pull request 264: `xz --block-size=1 --delta` of a 200 kB tar kept about 67 MiB live in
|
||||
// the reader, one delta decoder per block. Its index here lists one block, as a file crafted to pass a
|
||||
// check of the index alone would; the walk counts the blocks the reader would decode.
|
||||
body := make([]byte, 200_000)
|
||||
for i := range body {
|
||||
body[i] = byte(i * 7)
|
||||
}
|
||||
crafted := xzStream(tarHolding(t, body), 1, true, 1)
|
||||
path := writeTemp(t, crafted)
|
||||
var before, after runtime.MemStats
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&before)
|
||||
err := unpackUpstream(context.Background(), path, t.TempDir(), "betterbird")
|
||||
runtime.ReadMemStats(&after)
|
||||
if err == nil || !strings.Contains(err.Error(), "more than 1024 xz blocks") {
|
||||
t.Fatalf("a file of %d bytes in blocks of one byte was not refused for its blocks: %v", len(crafted), err)
|
||||
}
|
||||
if grew := after.TotalAlloc - before.TotalAlloc; grew > 4<<20 {
|
||||
t.Fatalf("%d bytes were allocated for a refused file", grew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheXzBlockLimitIsExact(t *testing.T) {
|
||||
data := make([]byte, xzBlocksMax+1)
|
||||
streams, blocks, err := xzFraming(bufio.NewReader(bytes.NewReader(xzStream(data[:xzBlocksMax], 1, true, -1))))
|
||||
if err != nil || streams != 1 || blocks != xzBlocksMax {
|
||||
t.Fatalf("%d blocks: %d streams, %d blocks, %v", xzBlocksMax, streams, blocks, err)
|
||||
}
|
||||
if _, _, err := xzFraming(bufio.NewReader(bytes.NewReader(xzStream(data, 1, true, -1)))); !errors.Is(err, errXzTooManyBlocks) {
|
||||
t.Fatalf("%d blocks: %v", xzBlocksMax+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzFileOfTooManyStreamsIsRefused(t *testing.T) {
|
||||
var file []byte
|
||||
for i := 0; i <= xzStreamsMax; i++ {
|
||||
file = append(file, xzStream([]byte("x"), 1, false, -1)...)
|
||||
}
|
||||
err := unpackUpstream(context.Background(), writeTemp(t, file), t.TempDir(), "")
|
||||
if err == nil || !strings.Contains(err.Error(), "more than 16 concatenated xz streams") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnXzIndexThatDoesNotListItsBlocksIsRefused(t *testing.T) {
|
||||
file := xzStream(tarHolding(t, []byte("x")), 512, false, 1)
|
||||
err := unpackUpstream(context.Background(), writeTemp(t, file), t.TempDir(), "betterbird")
|
||||
if err == nil || !strings.Contains(err.Error(), "its index lists 1 blocks, but it holds 4") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -312,7 +315,7 @@ func (b *Build) problems(module string) []string {
|
||||
module, a.Name, a.System, spokenSystems()))
|
||||
}
|
||||
} else {
|
||||
if a.From == "" {
|
||||
if a.From == "" && a.URL == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q says nothing about what it is built from", module, a.Name))
|
||||
}
|
||||
@@ -350,6 +353,85 @@ func (b *Build) problems(module string) []string {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
|
||||
}
|
||||
problems = append(problems, upstreamFileProblems(module, a)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's.
|
||||
var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa",
|
||||
".test", ".invalid", ".example", ".intranet", ".private"}
|
||||
|
||||
// notAPublicHost says why a host is not a public vendor's name, or "".
|
||||
func notAPublicHost(host string) string {
|
||||
h := strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if net.ParseIP(h) != nil {
|
||||
return "an address rather than a name"
|
||||
}
|
||||
if !strings.Contains(h, ".") {
|
||||
return "a name with no domain"
|
||||
}
|
||||
for _, s := range reservedSuffixes {
|
||||
if strings.HasSuffix(h, s) {
|
||||
return "a name under " + s + ", which is nobody's public domain"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters.
|
||||
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an
|
||||
// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository
|
||||
// besides; a file name or a directory within is a plain path that stays where it is put.
|
||||
func upstreamFileProblems(module string, a Artifact) []string {
|
||||
pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != ""
|
||||
if !pinned {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
say := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...))
|
||||
}
|
||||
if a.Kind != ArtifactArchive {
|
||||
say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+
|
||||
"mirrored from one (novox/hq ADR 0316)", a.Kind)
|
||||
return problems
|
||||
}
|
||||
if a.URL == "" {
|
||||
say("names a sha256, a version, a file or a directory within, and no url to fetch it from")
|
||||
return problems
|
||||
}
|
||||
if a.From != "" {
|
||||
say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL)
|
||||
}
|
||||
if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL)
|
||||
} else if why := notAPublicHost(u.Hostname()); why != "" {
|
||||
say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why)
|
||||
}
|
||||
if !sha256Hex.MatchString(a.SHA256) {
|
||||
say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+
|
||||
"address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL)
|
||||
}
|
||||
if strings.TrimSpace(a.Version) == "" {
|
||||
say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL)
|
||||
}
|
||||
if a.File != "" && a.Within != "" {
|
||||
say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+
|
||||
"of an archive", a.File, a.Within)
|
||||
}
|
||||
for field, p := range map[string]string{"file": a.File, "within": a.Within} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
clean := path.Clean(p)
|
||||
if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") ||
|
||||
(field == "file" && strings.Contains(p, "/")) {
|
||||
say("names %s %q, which is not a plain relative %s", field, p, map[string]string{
|
||||
"file": "file name", "within": "directory"}[field])
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -246,6 +246,25 @@ 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
|
||||
|
||||
// KeepsLibraries says this machine's node-engine makes the mesh's users and library groups and keeps libraries
|
||||
// (novox/hq ADR 0328: its statement's contract is link.LibraryContract or later). To an older, strict engine none
|
||||
// of it is sent, and a module that joins is left out of its declaration, naming why.
|
||||
KeepsLibraries bool
|
||||
// Identities is each joining module's allocated number, by module (ADR 0328 §4): the same on every machine. A
|
||||
// joining module with none here is left out, as at a question asked before its first send.
|
||||
Identities map[string]ModuleIdentity
|
||||
// Libraries is every library the operator defined, by name (§1); LibraryGroups each one's allocated group number
|
||||
// (§3); TakenLibraries those whose data the operator let the mesh take on their home, on the filesystem it is on
|
||||
// now (§7); LibraryPaths every sub-path any module in the mesh asks of each, kept on its home (§6).
|
||||
Libraries map[string]Library
|
||||
LibraryGroups map[string]int
|
||||
TakenLibraries map[string]bool
|
||||
LibraryPaths map[string][]string
|
||||
// RunnerJoinsOwnGroups says this machine's engine puts the tool runner's account in a joining module's own group
|
||||
// and takes a directory's setgid mode (novox/hq ADR 0334 §1, §2: its statement's contract is link.OwnGroupContract
|
||||
// or later); to an older one neither the identity's runner nor the setgid bit is sent.
|
||||
RunnerJoinsOwnGroups bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -320,6 +339,11 @@ type Composed struct {
|
||||
// naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told
|
||||
// everything else. Push and plan say it; the merge gate refuses a change that adds one.
|
||||
Unplaced []string
|
||||
// Groups, Identities and Libraries are what the declaration says of libraries and joining modules (novox/hq ADR
|
||||
// 0328): composed only for an engine that keeps libraries, and sent only when not empty.
|
||||
Groups []GroupEntry
|
||||
Identities []IdentityEntry
|
||||
Libraries []LibraryEntry
|
||||
}
|
||||
|
||||
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
|
||||
@@ -358,7 +382,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 +406,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 joined []Joined
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &joined)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -394,8 +421,10 @@ 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}
|
||||
composed.Groups, composed.Identities, composed.Libraries = r.libraryEnvelope(with, joined)
|
||||
return composed, nil
|
||||
}
|
||||
|
||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||
@@ -406,13 +435,26 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||
|
||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
received map[string]map[string][]Contribution, leftOut map[string]string,
|
||||
foreseen, unplaced *[]string) ([]map[string]any, error) {
|
||||
foreseen, unplaced *[]string, joinedOut *[]Joined) ([]map[string]any, error) {
|
||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||
// containers untouched, the machine told so by name — and the machine is told everything else.
|
||||
// Before placing, because a placement is a setting too.
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
// And a module whose libraries cannot be composed here, or that waits for one to be taken (novox/hq ADR 0328 §2,
|
||||
// §8): left out, naming why, its containers untouched — so a container keeps the user it ran as.
|
||||
for _, m := range r.Modules {
|
||||
if _, already := left[m.Module]; already {
|
||||
continue
|
||||
}
|
||||
if why := libraryLeftOut(m, r.Node, with); why != "" {
|
||||
if left == nil {
|
||||
left = map[string]string{}
|
||||
}
|
||||
left[m.Module] = why
|
||||
}
|
||||
}
|
||||
kept := make([]Manifest, 0, len(r.Modules))
|
||||
var stillBackedUp []Manifest
|
||||
for _, m := range r.Modules {
|
||||
@@ -553,6 +595,20 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
resources := m.Resources
|
||||
|
||||
// Its identity, where it joins (novox/hq ADR 0328 §4): its user and groups, and where its libraries are here.
|
||||
var joined Joined
|
||||
joins := Joins(m)
|
||||
var libPaths map[string]string
|
||||
if joins {
|
||||
if joined, err = joinedOf(m, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
libPaths = libraryPaths(m, with)
|
||||
if joinedOut != nil {
|
||||
*joinedOut = append(*joinedOut, joined)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +701,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 +1059,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
return nil, err
|
||||
}
|
||||
ownerInto(copied, placed)
|
||||
// And its identity and libraries, where it joins (novox/hq ADR 0328 §4, §5, §10): after the placement's
|
||||
// owner, which a joining module may not set.
|
||||
if joins {
|
||||
if err := joinedInto(copied, m, joined, libPaths, with.RunnerJoinsOwnGroups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// **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
|
||||
@@ -2431,6 +2499,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 {
|
||||
|
||||
@@ -71,6 +71,9 @@ var hostname = regexp.MustCompile(
|
||||
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
|
||||
`internal|example|tld|test|invalid)`)
|
||||
|
||||
// upstreamVersion is where an artifact pinned by URL says which release it is (Artifact.Version).
|
||||
var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`)
|
||||
|
||||
// address is a dotted quad.
|
||||
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
|
||||
|
||||
@@ -120,7 +123,10 @@ func InstallationProblems(m Manifest) []string {
|
||||
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
|
||||
}
|
||||
for _, ip := range addressesIn(value) {
|
||||
if meant[ip] {
|
||||
// An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name
|
||||
// in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's
|
||||
// names-on-purpose like any name a definition means (novox/hq ADR 0316).
|
||||
if meant[ip] || upstreamVersion.MatchString(at) {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
|
||||
|
||||
@@ -0,0 +1,907 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Libraries, and the modules that join them (novox/hq ADR 0328).
|
||||
//
|
||||
// **A library is a named tree of shared data with one home** (§1). The operator defines it — its name, the machine it
|
||||
// lives on, its root there and the machines it reaches — as the mesh-wide `libraries` setting of the controller's own
|
||||
// module, a trusted setting set at the controller's terminal or on the operator's Approve of a proposal (ADR 0277),
|
||||
// never through a verb alone. Libraries never overlap on a machine (§13), and a library never shares a name with a
|
||||
// module (§4), so `mesh-<library>` and `mesh-<module>` never collide.
|
||||
//
|
||||
// **A module asks for a library by name, sub-path and use** (§2): a manifest's `libraries` entries {id, library,
|
||||
// path, use}. On each machine an entry resolves to the library's root joined with the path; a module assigned to a
|
||||
// machine the library does not reach is left out there, naming the library. `${library:<id>}` is that host path, and
|
||||
// is filled only where a host path belongs (§5): a container's volume source, and a word of the module's tools, which
|
||||
// run on the machine. A `read` entry is mounted read-only (§10).
|
||||
//
|
||||
// **A module joins by using the model** (§14): it declares `libraries` or uses `${identity:…}`. It is given one number
|
||||
// from the mesh's range, as its user's and its own group's (§4); its user's primary group is the group of the first
|
||||
// library it uses, the others are added groups, and with no library its own group is primary. `${identity:user}`,
|
||||
// `${identity:uid}`, `${identity:group}` and `${identity:gid}` are filled wherever its resources take values (§5), and
|
||||
// its directories are owned by its user and primary group (§4). A module that does neither is composed exactly as
|
||||
// before.
|
||||
//
|
||||
// **The range is 50000–59999** for every number the mesh allocates — a joining module's and a library's group — from
|
||||
// one sequence, so a module's own group and a library's never share a number. It lies above the numbers distributions
|
||||
// give people and below the 60000s Debian keeps for its daemons, and nowhere near root's 0 or a root group's (0, adm's 4,
|
||||
// wheel's 10): no allocated number is root's (§15). The store refuses a row outside it.
|
||||
//
|
||||
// **Only to an engine that reads it** (Rendering.KeepsLibraries: its statement's contract is link.LibraryContract or
|
||||
// later), the declaration carries `groups`, `identities` and `libraries` (libraryEnvelope). To an older engine a module
|
||||
// that joins is left out of the declaration, naming why — its placeholders could not be filled — and everything else is
|
||||
// composed as before.
|
||||
|
||||
// The range the mesh allocates user and group numbers from (novox/hq ADR 0328 §3, §4, §15). The store holds the same
|
||||
// bounds.
|
||||
const (
|
||||
IdentityLow = 50000
|
||||
IdentityHigh = 59999
|
||||
)
|
||||
|
||||
// AllocatableID says whether n is a number the mesh may give: inside the range, so never 0 nor a root group's.
|
||||
func AllocatableID(n int) bool { return n >= IdentityLow && n <= IdentityHigh }
|
||||
|
||||
// ADR0328 is how the checks name the decision their rules come from.
|
||||
const ADR0328 = "novox/hq ADR 0328"
|
||||
|
||||
// MeshName is the user or group a module or a library is on every machine: mesh-<name>.
|
||||
func MeshName(name string) string { return "mesh-" + name }
|
||||
|
||||
// IdentityName is the user a joining module runs as, and its own group.
|
||||
func IdentityName(module string) string { return MeshName(module) }
|
||||
|
||||
// ---- libraries, as the operator defines them -----------------------------------------------------------------
|
||||
|
||||
// LibrariesSetting is the key of the controller's mesh-wide settings layer that defines the libraries (§1).
|
||||
const LibrariesSetting = "libraries"
|
||||
|
||||
// LibrariesModule is the module whose mesh-wide layer holds LibrariesSetting: the controller's own.
|
||||
const LibrariesModule = "mesh-controller"
|
||||
|
||||
// Library is one library as the operator defined it.
|
||||
type Library struct {
|
||||
Name string `json:"-"`
|
||||
// Home is the machine the data lives on; Root its path there, the same on every machine it reaches.
|
||||
Home string `json:"home"`
|
||||
Root string `json:"root"`
|
||||
// Reach is every machine the library reaches, its home among them.
|
||||
Reach []string `json:"reach,omitempty"`
|
||||
}
|
||||
|
||||
// Reaches says whether the library reaches a machine: its home, or one it names.
|
||||
func (l Library) Reaches(node string) bool { return node == l.Home || hasName(l.Reach, node) }
|
||||
|
||||
// Machines is every machine the library reaches, its home first.
|
||||
func (l Library) Machines() []string {
|
||||
out := []string{l.Home}
|
||||
for _, n := range l.Reach {
|
||||
if n != l.Home && !hasName(out, n) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Group is the library's group: mesh-<library>.
|
||||
func (l Library) Group() string { return MeshName(l.Name) }
|
||||
|
||||
var libraryName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
// ParseLibraries reads the `libraries` setting: {<name>: {home, root, reach}}, or refuses it in words.
|
||||
func ParseLibraries(v any) (map[string]Library, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
blocks, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a { <library>: {home, root, reach} } map (%s §1)", LibrariesSetting, ADR0328)
|
||||
}
|
||||
out := map[string]Library{}
|
||||
for name, raw := range blocks {
|
||||
if !libraryName.MatchString(name) {
|
||||
return nil, fmt.Errorf("the library %q: a library's name is lowercase letters, digits and dashes, starting "+
|
||||
"with a letter", name)
|
||||
}
|
||||
fields, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the library %s is {home, root, reach}, not %v", name, raw)
|
||||
}
|
||||
l := Library{Name: name}
|
||||
for k, v := range fields {
|
||||
switch k {
|
||||
case "home":
|
||||
l.Home, _ = v.(string)
|
||||
case "root":
|
||||
l.Root, _ = v.(string)
|
||||
case "reach":
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the library %s's reach is a list of machines", name)
|
||||
}
|
||||
for _, n := range list {
|
||||
s, _ := n.(string)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("the library %s's reach names a machine that is not a name: %v", name, n)
|
||||
}
|
||||
l.Reach = append(l.Reach, s)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("the library %s says %q; a library is its home, its root and its reach, and "+
|
||||
"nothing about an owner, a group, a mode or a number (%s §1)", name, k, ADR0328)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(l.Home) == "" {
|
||||
return nil, fmt.Errorf("the library %s names no home machine", name)
|
||||
}
|
||||
if !strings.HasPrefix(l.Root, "/") {
|
||||
return nil, fmt.Errorf("the library %s's root %q is not an absolute path", name, l.Root)
|
||||
}
|
||||
l.Root = filepath.Clean(l.Root)
|
||||
if why := systemPath(l.Root); why != "" {
|
||||
return nil, fmt.Errorf("the library %s's root is %s: %s, and the node-engine keeps a library's root in its "+
|
||||
"group — no library is ever there", name, l.Root, why)
|
||||
}
|
||||
sort.Strings(l.Reach)
|
||||
out[name] = l
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LibrariesProblems refuses what no library may be together (§4, §13): a library named as a registered module, and two
|
||||
// libraries on one machine whose roots lie inside each other — each named.
|
||||
func LibrariesProblems(libs map[string]Library, modules []string) []string {
|
||||
var problems []string
|
||||
names := make([]string, 0, len(libs))
|
||||
for n := range libs {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if hasName(modules, n) {
|
||||
problems = append(problems, fmt.Sprintf("the library %s has the name of the module %s: %s would be both "+
|
||||
"(%s §4)", n, n, MeshName(n), ADR0328))
|
||||
}
|
||||
}
|
||||
for i, a := range names {
|
||||
for _, b := range names[i+1:] {
|
||||
la, lb := libs[a], libs[b]
|
||||
shared := ""
|
||||
for _, m := range la.Machines() {
|
||||
if lb.Reaches(m) {
|
||||
shared = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if shared == "" {
|
||||
continue
|
||||
}
|
||||
if within(la.Root, lb.Root) || within(lb.Root, la.Root) {
|
||||
problems = append(problems, fmt.Sprintf("the libraries %s (%s) and %s (%s) overlap on %s: a library's "+
|
||||
"root lies neither inside another's nor around it — divide one library by the paths its modules "+
|
||||
"ask for (%s §13)", a, la.Root, b, lb.Root, shared, ADR0328))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// within says path is root or lies below it.
|
||||
func within(path, root string) bool {
|
||||
return path == root || strings.HasPrefix(path, strings.TrimSuffix(root, "/")+"/")
|
||||
}
|
||||
|
||||
func hasName(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- a module's use of them ---------------------------------------------------------------------------------
|
||||
|
||||
// LibraryUse is one `libraries` entry of a manifest: the id the module's resources name it by, the library, the
|
||||
// sub-path within it, and the use.
|
||||
type LibraryUse struct {
|
||||
ID string `json:"id"`
|
||||
Library string `json:"library"`
|
||||
Path string `json:"path"`
|
||||
Use string `json:"use"`
|
||||
}
|
||||
|
||||
// At is the entry's use, read by default.
|
||||
func (u LibraryUse) At() string {
|
||||
if u.Use == "" {
|
||||
return AccessRead
|
||||
}
|
||||
return u.Use
|
||||
}
|
||||
|
||||
// libraryRef is how a module names one of its library entries: ${library:<id>}.
|
||||
var libraryRef = regexp.MustCompile(`\$\{library:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
// identityRef is a module's identity by name: ${identity:user|uid|group|gid}.
|
||||
var identityRef = regexp.MustCompile(`\$\{identity:(user|uid|group|gid)\}`)
|
||||
|
||||
// Joins says a module joins the model (§14): it declares libraries or uses its identity anywhere.
|
||||
func Joins(m Manifest) bool {
|
||||
if len(m.Libraries) > 0 {
|
||||
return true
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
walkStrings(r, func(s string) {
|
||||
found = found || identityRef.MatchString(s) || strings.Contains(s, "${identity:")
|
||||
})
|
||||
}
|
||||
for _, b := range m.Bundles {
|
||||
for _, v := range b.Env {
|
||||
found = found || strings.Contains(v, "${identity:")
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// walkStrings calls see with every string a value holds, however deep.
|
||||
func walkStrings(v any, see func(string)) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
see(v)
|
||||
case []any:
|
||||
for _, x := range v {
|
||||
walkStrings(x, see)
|
||||
}
|
||||
case map[string]any:
|
||||
for _, x := range v {
|
||||
walkStrings(x, see)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStrings is v with every string it holds replaced by fill's answer, however deep; the first error stops it.
|
||||
func mapStrings(v any, fill func(string) (string, error)) (any, error) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return fill(v)
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, x := range v {
|
||||
y, err := mapStrings(x, fill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = y
|
||||
}
|
||||
return out, nil
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for k, x := range v {
|
||||
y, err := mapStrings(x, fill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = y
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var libraryEntryID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// numberWords are the setting names that carry a user or group number or an owner: none for a module that joins (§5).
|
||||
// "user" and "group" are left out: an application's own login or group name is a setting like any other.
|
||||
var numberWords = map[string]bool{"uid": true, "gid": true, "puid": true, "pgid": true, "user-id": true,
|
||||
"group-id": true, "owner": true}
|
||||
|
||||
// libraryProblems is what the module check refuses of a manifest's libraries and identity (§2, §4, §5, §14):
|
||||
//
|
||||
// - an entry that is not {id, library, path, use} with a relative sub-path inside its library, or names a host
|
||||
// path, an owner, a group, a mode or a number (the entry's shape refuses any other key);
|
||||
// - two entries with one id, or a `${library:<id>}` naming none;
|
||||
// - `${library:<id>}` anywhere but a container's volume source (and a word of its tools, a bundle's env);
|
||||
// - `${identity:…}` other than user, uid, group or gid;
|
||||
// - in a module that joins: a directory naming its owner. A setting of it naming a number or an owner is refused
|
||||
// where it is set (JoiningSettingProblem).
|
||||
func libraryProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
ids := map[string]bool{}
|
||||
for _, u := range m.Libraries {
|
||||
where := fmt.Sprintf("%s's library entry %q", m.Module, u.ID)
|
||||
switch {
|
||||
case !libraryEntryID.MatchString(u.ID):
|
||||
problems = append(problems, where+": an id is lowercase letters, digits and dashes")
|
||||
case ids[u.ID]:
|
||||
problems = append(problems, where+": two entries share the id")
|
||||
}
|
||||
ids[u.ID] = true
|
||||
if !libraryName.MatchString(u.Library) {
|
||||
problems = append(problems, fmt.Sprintf("%s names the library %q, which is not a library's name", where,
|
||||
u.Library))
|
||||
}
|
||||
clean := filepath.Clean(u.Path)
|
||||
if u.Path == "" || strings.HasPrefix(u.Path, "/") || clean == ".." || strings.HasPrefix(clean, "../") ||
|
||||
strings.ContainsAny(u.Path, "${}") {
|
||||
problems = append(problems, fmt.Sprintf("%s's path %q is a sub-path inside the library, relative and "+
|
||||
"staying inside it — never a host path (%s §2)", where, u.Path, ADR0328))
|
||||
}
|
||||
if u.At() != AccessRead && u.At() != AccessReadWrite {
|
||||
problems = append(problems, fmt.Sprintf("%s is used %q; a use is %q or %q", where, u.Use, AccessRead,
|
||||
AccessReadWrite))
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
kind := fmt.Sprint(r["type"])
|
||||
for field, v := range r {
|
||||
walkStrings(v, func(s string) {
|
||||
for _, tok := range regexp.MustCompile(`\$\{identity:[^}]*\}`).FindAllString(s, -1) {
|
||||
if !identityRef.MatchString(tok) {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says %s: the identity is "+
|
||||
"${identity:user}, ${identity:uid}, ${identity:group} or ${identity:gid} (%s §5)", m.Module,
|
||||
r["id"], tok, ADR0328))
|
||||
}
|
||||
}
|
||||
for _, match := range libraryRef.FindAllStringSubmatch(s, -1) {
|
||||
if !ids[match[1]] {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says ${library:%s}, and declares no "+
|
||||
"library entry %q", m.Module, r["id"], match[1], match[1]))
|
||||
}
|
||||
source := ""
|
||||
if kind == "container" && field == "volumes" {
|
||||
source = volumeSource(s)
|
||||
}
|
||||
if !strings.Contains(source, match[0]) {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v says %s in its %s: a library's path "+
|
||||
"is a host path, filled only in a container's volume source — the process reads the "+
|
||||
"container side its volume gives (%s §5)", m.Module, r["id"], match[0], field, ADR0328))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if !Joins(m) {
|
||||
return problems
|
||||
}
|
||||
problems = append(problems, toolsDirectoryProblems(m)...)
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
if owner, said := r["owner"]; said {
|
||||
problems = append(problems, fmt.Sprintf("%s: the directory %v names its owner %v; a module that joins "+
|
||||
"owns its directories by its user and primary group, and names no owner (%s §4)", m.Module,
|
||||
r["id"], owner, ADR0328))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// toolsDirectoryProblems refuses a joining module whose tools name one of its directories whose mode closes it to its
|
||||
// group (novox/hq ADR 0334 §3): the tool runner reads it through the module's own group, so it needs group read and
|
||||
// enter.
|
||||
func toolsDirectoryProblems(m Manifest) []string {
|
||||
modes := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
mode, _ := r["mode"].(string)
|
||||
modes[fmt.Sprint(r["id"])] = mode
|
||||
}
|
||||
}
|
||||
var words []string
|
||||
for _, b := range m.Bundles {
|
||||
for _, v := range b.Env {
|
||||
words = append(words, v)
|
||||
}
|
||||
}
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
for _, v := range a.Env {
|
||||
words = append(words, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var problems []string
|
||||
for _, w := range words {
|
||||
for _, match := range dirRef.FindAllStringSubmatch(w, -1) {
|
||||
id := match[1]
|
||||
mode, declared := modes[id]
|
||||
if !declared || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
var n uint64
|
||||
if _, err := fmt.Sscanf(mode, "%o", &n); err != nil || mode == "" {
|
||||
continue // its mode is judged where the directory is
|
||||
}
|
||||
if n&0o050 != 0o050 {
|
||||
problems = append(problems, fmt.Sprintf("%s's tools name its directory %s, whose mode %s closes it to its "+
|
||||
"own group: the tool runner reads it through that group, so it needs group read and enter (0750; "+
|
||||
"the mesh adds setgid, 2750) (novox/hq ADR 0334 §3)", m.Module, id, mode))
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(problems)
|
||||
return problems
|
||||
}
|
||||
|
||||
// LibraryWarnings is what the module check warns of a module's libraries (§4): an image that drops added groups
|
||||
// reaches only the first library the module uses.
|
||||
func LibraryWarnings(m Manifest) []string {
|
||||
seen := map[string]bool{}
|
||||
var libs []string
|
||||
for _, u := range m.Libraries {
|
||||
if !seen[u.Library] {
|
||||
seen[u.Library] = true
|
||||
libs = append(libs, u.Library)
|
||||
}
|
||||
}
|
||||
if len(libs) < 2 {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("%s uses the libraries %s: its user's primary group is %s, and %s are added groups — an "+
|
||||
"image that drops added groups when it changes user reaches only %s (%s §4)", m.Module, strings.Join(libs, ", "),
|
||||
MeshName(libs[0]), strings.Join(meshNames(libs[1:]), ", "), libs[0], ADR0328)}
|
||||
}
|
||||
|
||||
func meshNames(names []string) []string {
|
||||
out := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = MeshName(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JoiningSettingProblem refuses, where a setting is set, a layer of a module that joins that carries a user or group
|
||||
// number or an owner (§5): a key whose name says so, or an owner a `places` setting names — its directories are owned
|
||||
// by its user and primary group, and it reads its own as ${identity:…}.
|
||||
func JoiningSettingProblem(m Manifest, layers []Layer) error {
|
||||
if !Joins(m) {
|
||||
return nil
|
||||
}
|
||||
for _, l := range layers {
|
||||
for k := range l.Values {
|
||||
if numberWords[k] {
|
||||
return fmt.Errorf("%s joins, and the layer %q sets %q: no setting of a module that joins carries a user "+
|
||||
"or group number or an owner — it reads its own as ${identity:…} (%s §5)", m.Module, l.From, k, ADR0328)
|
||||
}
|
||||
}
|
||||
}
|
||||
return PlacesOwnerProblem(m, layers)
|
||||
}
|
||||
|
||||
// PlacesOwnerProblem refuses an owner a `places` setting names for a module that joins (§4, §5): its directories are
|
||||
// owned by its user and primary group.
|
||||
func PlacesOwnerProblem(m Manifest, layers []Layer) error {
|
||||
if !Joins(m) {
|
||||
return nil
|
||||
}
|
||||
placed, err := Places(m, layers)
|
||||
if err != nil {
|
||||
return nil // refused by name where it is judged
|
||||
}
|
||||
var owned []string
|
||||
for id, p := range placed {
|
||||
if p.Owner != "" {
|
||||
owned = append(owned, id)
|
||||
}
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(owned)
|
||||
return fmt.Errorf("%s places %s with an owner, and it joins: its directories are owned by its user and primary "+
|
||||
"group, and no setting of it carries an owner (%s §4, §5)", m.Module, strings.Join(owned, ", "), ADR0328)
|
||||
}
|
||||
|
||||
// ---- composition ------------------------------------------------------------------------------------------------
|
||||
|
||||
// ModuleIdentity is a joining module's allocated number: its user's and its own group's.
|
||||
type ModuleIdentity struct {
|
||||
Module string
|
||||
UID int
|
||||
}
|
||||
|
||||
// Joined is a joining module's identity on a machine, as its placeholders are filled and its directories owned.
|
||||
type Joined struct {
|
||||
Module string
|
||||
UID int
|
||||
// Primary is its user's primary group, by name and number; Added its other groups, by name, in the order used.
|
||||
Primary string
|
||||
PrimaryGID int
|
||||
Added []string
|
||||
AddedGIDs []int
|
||||
}
|
||||
|
||||
// librariesOf is the libraries a module uses, by name, in the order its entries first name them.
|
||||
func librariesOf(m Manifest) []string {
|
||||
var out []string
|
||||
for _, u := range m.Libraries {
|
||||
if !hasName(out, u.Library) {
|
||||
out = append(out, u.Library)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// joinedOf is module m's identity here: its number, and its groups from the libraries it uses. Refused, naming
|
||||
// what, where a number is missing or outside the range (§15).
|
||||
func joinedOf(m Manifest, with Rendering) (Joined, error) {
|
||||
id, has := with.Identities[m.Module]
|
||||
if !has {
|
||||
return Joined{}, fmt.Errorf("%s joins, and has no number yet: it is allocated at its first send", m.Module)
|
||||
}
|
||||
if !AllocatableID(id.UID) {
|
||||
return Joined{}, fmt.Errorf("%s's number is %d, outside %d–%d: no number the mesh gives is root's or a root "+
|
||||
"group's, and none is sent (%s §15)", m.Module, id.UID, IdentityLow, IdentityHigh, ADR0328)
|
||||
}
|
||||
j := Joined{Module: m.Module, UID: id.UID, Primary: IdentityName(m.Module), PrimaryGID: id.UID}
|
||||
for i, lib := range librariesOf(m) {
|
||||
gid, has := with.LibraryGroups[lib]
|
||||
if !has || !AllocatableID(gid) {
|
||||
return Joined{}, fmt.Errorf("%s uses the library %s, whose group has no number the mesh may give (%s §3, "+
|
||||
"§15)", m.Module, lib, ADR0328)
|
||||
}
|
||||
if i == 0 {
|
||||
j.Primary, j.PrimaryGID = MeshName(lib), gid
|
||||
continue
|
||||
}
|
||||
j.Added = append(j.Added, MeshName(lib))
|
||||
j.AddedGIDs = append(j.AddedGIDs, gid)
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// libraryLeftOut is why module m is left out of this machine's declaration for its libraries, or "": a library it
|
||||
// uses not defined or not reaching the machine (§2), or an engine that cannot be told it joins. A module held at the
|
||||
// user it ran as until its libraries are taken (§8) is not left out: it is composed at the build it ran before it
|
||||
// joined (cmd/mesh-controller holdJoining).
|
||||
func libraryLeftOut(m Manifest, node string, with Rendering) string {
|
||||
if !Joins(m) {
|
||||
return ""
|
||||
}
|
||||
if !with.KeepsLibraries {
|
||||
return fmt.Sprintf("%s joins the mesh's users and libraries (%s), and the node-engine on %s is older than "+
|
||||
"them: it is left out until that engine is updated", m.Module, ADR0328, node)
|
||||
}
|
||||
for _, lib := range librariesOf(m) {
|
||||
l, defined := with.Libraries[lib]
|
||||
switch {
|
||||
case !defined:
|
||||
return fmt.Sprintf("%s uses the library %s, which is not defined: the operator defines it (%s §1)",
|
||||
m.Module, lib, ADR0328)
|
||||
case !l.Reaches(node):
|
||||
return fmt.Sprintf("%s uses the library %s, which does not reach %s (%s §2)", m.Module, lib, node, ADR0328)
|
||||
}
|
||||
}
|
||||
if _, has := with.Identities[m.Module]; !has {
|
||||
return fmt.Sprintf("%s joins, and its number is allocated at its first send", m.Module)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// libraryPaths is where each of module m's library entries is on this machine: its library's root joined with the
|
||||
// entry's path, by entry id.
|
||||
func libraryPaths(m Manifest, with Rendering) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, u := range m.Libraries {
|
||||
if l, ok := with.Libraries[u.Library]; ok {
|
||||
out[u.ID] = filepath.Join(l.Root, filepath.Clean(u.Path))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityFill fills ${identity:…} in one string.
|
||||
func identityFill(s string, j Joined) string {
|
||||
return identityRef.ReplaceAllStringFunc(s, func(tok string) string {
|
||||
switch identityRef.FindStringSubmatch(tok)[1] {
|
||||
case "user":
|
||||
return IdentityName(j.Module)
|
||||
case "uid":
|
||||
return fmt.Sprint(j.UID)
|
||||
case "group":
|
||||
return j.Primary
|
||||
default:
|
||||
return fmt.Sprint(j.PrimaryGID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// joinedInto composes one resource of a joining module (§4, §5, §10): ${identity:…} filled everywhere, a container's
|
||||
// ${library:<id>} volume sources filled with the host path and a read entry mounted read-only, and a directory owned by
|
||||
// the module's user and primary group.
|
||||
func joinedInto(resource map[string]any, m Manifest, j Joined, paths map[string]string, setgid bool) error {
|
||||
for k, v := range resource {
|
||||
if k == "id" || k == "type" {
|
||||
continue
|
||||
}
|
||||
filled, err := mapStrings(v, func(s string) (string, error) { return identityFill(s, j), nil })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resource[k] = filled
|
||||
}
|
||||
// Its own directories belong to its user and its own group, with setgid (ADR 0334 §1, v4): the library's group is
|
||||
// its processes' primary, so what they make in a library keeps that group — and what they make in their own
|
||||
// directories keeps mesh-<module> by the setgid bit. The bit only to an engine that takes a mode with it
|
||||
// (Rendering.RunnerJoinsOwnGroups, link.OwnGroupContract): the one pinned refuses a mode not starting with 0.
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
resource["owner"] = fmt.Sprintf("%d:%d", j.UID, j.UID)
|
||||
if mode, ok := resource["mode"].(string); ok && setgid {
|
||||
var n uint64
|
||||
if _, err := fmt.Sscanf(mode, "%o", &n); err == nil && mode != "" {
|
||||
resource["mode"] = fmt.Sprintf("%04o", n|0o2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) != "container" {
|
||||
return nil
|
||||
}
|
||||
volumes, _ := resource["volumes"].([]any)
|
||||
out := make([]any, len(volumes))
|
||||
for i, v := range volumes {
|
||||
out[i] = v
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source := volumeSource(s)
|
||||
match := libraryRef.FindStringSubmatch(source)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
path, has := paths[match[1]]
|
||||
if !has {
|
||||
return fmt.Errorf("%s says ${library:%s}, and has no library entry %q", m.Module, match[1], match[1])
|
||||
}
|
||||
filled := strings.Replace(source, match[0], path, 1) + strings.TrimPrefix(s, source)
|
||||
for _, u := range m.Libraries {
|
||||
if u.ID == match[1] && u.At() == AccessRead {
|
||||
filled = readOnly(filled)
|
||||
}
|
||||
}
|
||||
out[i] = filled
|
||||
}
|
||||
if len(volumes) > 0 {
|
||||
resource["volumes"] = out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readOnly is a volume mounted read-only: "src:dst" becomes "src:dst:ro", and options gain ro in place of rw.
|
||||
func readOnly(v string) string {
|
||||
parts := strings.Split(v, ":")
|
||||
if len(parts) < 2 {
|
||||
return v
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
return v + ":ro"
|
||||
}
|
||||
opts := strings.Split(parts[len(parts)-1], ",")
|
||||
kept := []string{"ro"}
|
||||
for _, o := range opts {
|
||||
if o != "ro" && o != "rw" && o != "" {
|
||||
kept = append(kept, o)
|
||||
}
|
||||
}
|
||||
parts[len(parts)-1] = strings.Join(kept, ",")
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
// volumeSource is a volume's source as written: up to its first colon outside a ${…} placeholder, so
|
||||
// "${dir:config}:/config" is "${dir:config}".
|
||||
func volumeSource(v string) string {
|
||||
depth := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r == '{' && i > 0 && v[i-1] == '$':
|
||||
depth++
|
||||
case r == '}' && depth > 0:
|
||||
depth--
|
||||
case r == ':' && depth == 0:
|
||||
return v[:i]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// bundleLibraryFill fills a joining module's tool words (a bundle's env): ${identity:…}, and ${library:<id>} with the
|
||||
// host path, since its tools run on the machine (§5).
|
||||
func bundleLibraryFill(value string, m Manifest, with Rendering) (string, error) {
|
||||
if !strings.Contains(value, "${identity:") && !strings.Contains(value, "${library:") {
|
||||
return value, nil
|
||||
}
|
||||
j, err := joinedOf(m, with)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
paths := libraryPaths(m, with)
|
||||
var missing error
|
||||
value = libraryRef.ReplaceAllStringFunc(identityFill(value, j), func(tok string) string {
|
||||
id := libraryRef.FindStringSubmatch(tok)[1]
|
||||
p, has := paths[id]
|
||||
if !has {
|
||||
missing = fmt.Errorf("%s's tools say ${library:%s}, and it has no library entry %q", m.Module, id, id)
|
||||
return tok
|
||||
}
|
||||
return p
|
||||
})
|
||||
return value, missing
|
||||
}
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: no
|
||||
// runtime here, no bundle it loads, or a module left out of an adopted machine's declaration.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// LibraryEntry is one library as a machine's declaration names it (§3, §6, §7, §11).
|
||||
type LibraryEntry struct {
|
||||
Library string `json:"library"`
|
||||
Root string `json:"root"`
|
||||
Group string `json:"group"`
|
||||
GID int `json:"gid"`
|
||||
// Home says this machine is the library's home, where the engine keeps it; Taken that the operator let the mesh
|
||||
// take its existing data there (§7).
|
||||
Home bool `json:"home"`
|
||||
Taken bool `json:"taken"`
|
||||
// Paths are the sub-paths its modules ask for, relative to its root.
|
||||
Paths []string `json:"paths"`
|
||||
// Runner is the tool runner's account where a module whose tools run there uses the library: a member of its
|
||||
// group (§11). Empty otherwise.
|
||||
Runner string `json:"runner,omitempty"`
|
||||
}
|
||||
|
||||
// GroupEntry is one group a machine's declaration names: a library's, made on every machine the library reaches (§3).
|
||||
type GroupEntry struct {
|
||||
Name string `json:"name"`
|
||||
GID int `json:"gid"`
|
||||
}
|
||||
|
||||
// IdentityEntry is one joining module's user as a machine's declaration names it (§4; mesh-host internal/declaration
|
||||
// Identity): the engine always makes its own group with its uid; GID is its primary group's number — its first
|
||||
// library's, or its own — and Groups the numbers of its added groups.
|
||||
type IdentityEntry struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Groups []int `json:"groups,omitempty"`
|
||||
// Runner is the tool runner's account, put in this module's own group, where its tools run there (ADR 0334 §2).
|
||||
// Only to an engine that reads it (Rendering.RunnerJoinsOwnGroups).
|
||||
Runner string `json:"runner,omitempty"`
|
||||
}
|
||||
|
||||
// libraryEnvelope is what a machine's declaration says of libraries and joining modules: the group of every library
|
||||
// that reaches it, each joining module composed here, and each library a module here uses or that has its home here.
|
||||
func (r Resolution) libraryEnvelope(with Rendering, joined []Joined) ([]GroupEntry, []IdentityEntry, []LibraryEntry) {
|
||||
if !with.KeepsLibraries {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var groups []GroupEntry
|
||||
for name, l := range with.Libraries {
|
||||
if gid, has := with.LibraryGroups[name]; has && l.Reaches(r.Node) && AllocatableID(gid) {
|
||||
groups = append(groups, GroupEntry{Name: l.Group(), GID: gid})
|
||||
}
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
|
||||
var ids []IdentityEntry
|
||||
for _, j := range joined {
|
||||
e := IdentityEntry{Module: j.Module, Name: IdentityName(j.Module), UID: j.UID, GID: j.PrimaryGID,
|
||||
Groups: append([]int(nil), j.AddedGIDs...)}
|
||||
if with.RunnerJoinsOwnGroups {
|
||||
for _, m := range r.Modules {
|
||||
if m.Module == j.Module {
|
||||
e.Runner = r.toolsAccount(m, with)
|
||||
}
|
||||
}
|
||||
}
|
||||
ids = append(ids, e)
|
||||
}
|
||||
sort.Slice(ids, func(a, b int) bool { return ids[a].Module < ids[b].Module })
|
||||
used := map[string]map[string]bool{}
|
||||
runner := map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
if libraryLeftOut(m, r.Node, with) != "" {
|
||||
continue
|
||||
}
|
||||
for _, u := range m.Libraries {
|
||||
if used[u.Library] == nil {
|
||||
used[u.Library] = map[string]bool{}
|
||||
}
|
||||
used[u.Library][filepath.Clean(u.Path)] = true
|
||||
if a := r.toolsAccount(m, with); a != "" && runner[u.Library] == "" {
|
||||
runner[u.Library] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
var libs []LibraryEntry
|
||||
for name, l := range with.Libraries {
|
||||
home := l.Home == r.Node
|
||||
if used[name] == nil && !home {
|
||||
continue
|
||||
}
|
||||
gid, has := with.LibraryGroups[name]
|
||||
if !has || !l.Reaches(r.Node) {
|
||||
continue
|
||||
}
|
||||
paths := map[string]bool{}
|
||||
for p := range used[name] {
|
||||
paths[p] = true
|
||||
}
|
||||
if home {
|
||||
// On its home the engine keeps every sub-path any module asks for, wherever it runs (§6).
|
||||
for _, p := range with.LibraryPaths[name] {
|
||||
paths[filepath.Clean(p)] = true
|
||||
}
|
||||
}
|
||||
e := LibraryEntry{Library: name, Root: l.Root, Group: l.Group(), GID: gid, Home: home,
|
||||
Taken: with.TakenLibraries[name], Paths: []string{}, Runner: runner[name]}
|
||||
for p := range paths {
|
||||
e.Paths = append(e.Paths, p)
|
||||
}
|
||||
sort.Strings(e.Paths)
|
||||
libs = append(libs, e)
|
||||
}
|
||||
sort.Slice(libs, func(i, j int) bool { return libs[i].Library < libs[j].Library })
|
||||
return groups, ids, libs
|
||||
}
|
||||
|
||||
// LibraryUseShape is a module's libraries and whether it joins, in a form that compares: a move that changes either
|
||||
// changes who uses its libraries (the gate's reading of an unknown or a held library).
|
||||
func LibraryUseShape(m Manifest) string {
|
||||
var out []string
|
||||
for _, u := range m.Libraries {
|
||||
out = append(out, u.ID+"\x00"+u.Library+"\x00"+filepath.Clean(u.Path)+"\x00"+u.At())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return fmt.Sprint(Joins(m), out)
|
||||
}
|
||||
|
||||
// LibraryOfEntry is the library a module's entry id names, or "".
|
||||
func LibraryOfEntry(m Manifest, id string) string {
|
||||
for _, u := range m.Libraries {
|
||||
if u.ID == id {
|
||||
return u.Library
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AccessPathOn is where access id of module m is on a machine with these settings: its placement, else the
|
||||
// definition's default; false where neither says, or the module declares no such access.
|
||||
func AccessPathOn(m Manifest, layers []Layer, id string) (string, bool) {
|
||||
_, byID, err := accessesFor(m, layers)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
path, has := byID[id]
|
||||
if !has || path == "" {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(path), true
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0328 on the controller's side, row by row of its "How it is checked" table where the row is the
|
||||
// controller's: libraries defined (1, 13), entries resolved (2), groups and numbers (3, 4, 15), the identity and library
|
||||
// placeholders (5), a read use mounted read-only (10), the tool runner in a library's group (11), a joining module held
|
||||
// until its libraries are taken (8), and a module that does not join composed exactly as before (14).
|
||||
|
||||
const libraryImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
var mediaLibrary = Library{Name: "media", Home: "ace", Root: "/storage/media", Reach: []string{"novox"}}
|
||||
|
||||
// aRadarr writes movies and reads books, tells its image its identity by PUID and PGID, and has tools on the runner.
|
||||
func aRadarr(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "radarr", "tools/index.js")
|
||||
m.Libraries = []LibraryUse{
|
||||
{ID: "movies", Library: "media", Path: "movies", Use: AccessReadWrite},
|
||||
{ID: "books", Library: "books", Path: "shelf", Use: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "config", "type": "directory", "mode": "0750"},
|
||||
{"id": "env", "type": "file", "path": "${dir:config}/identity.env", "mode": "0644",
|
||||
"content": "PUID=${identity:uid}\nPGID=${identity:gid}\nUSER=${identity:user}\nGROUP=${identity:group}\nUMASK=002\n"},
|
||||
{"id": "app", "type": "container", "name": "radarr", "image": libraryImage,
|
||||
"env": map[string]any{"PUID": "${identity:uid}", "PGID": "${identity:gid}"},
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies", "${library:books}:/books:z"}},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// aPlain is a module that neither declares libraries nor uses its identity.
|
||||
func aPlain() Manifest {
|
||||
return Manifest{Module: "plain", Version: "1", Resources: []map[string]any{
|
||||
{"id": "data", "type": "directory", "mode": "0700", "owner": "999:999"},
|
||||
{"id": "app", "type": "container", "name": "plain", "image": libraryImage, "volumes": []any{"${dir:data}:/data"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func librariesRendering() Rendering {
|
||||
books := Library{Name: "books", Home: "ace", Root: "/storage/books"}
|
||||
return Rendering{KeepsLibraries: true,
|
||||
Libraries: map[string]Library{"media": mediaLibrary, "books": books},
|
||||
LibraryGroups: map[string]int{"media": 50010, "books": 50011},
|
||||
Identities: map[string]ModuleIdentity{"radarr": {Module: "radarr", UID: 50020}},
|
||||
TakenLibraries: map[string]bool{"media": true, "books": true},
|
||||
LibraryPaths: map[string][]string{"media": {"movies", "series"}},
|
||||
}
|
||||
}
|
||||
|
||||
func composeLibraries(t *testing.T, r Resolution, with Rendering) Composed {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return composed
|
||||
}
|
||||
|
||||
// Rows 2, 4, 5, 10: a joining module's identity is filled everywhere, its library entries resolve to root + path in its
|
||||
// volume sources, a read entry mounts read-only, its directories are owned by its user and primary group — the first
|
||||
// library it uses — and its other libraries are added groups.
|
||||
func TestAJoiningModuleIsComposedWithItsIdentityAndLibraries(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
composed := composeLibraries(t, r, librariesRendering())
|
||||
got := byID(composed.Resources)
|
||||
|
||||
app := got["radarr.app"]
|
||||
if fmt.Sprint(app["env"]) != fmt.Sprint(map[string]any{"PUID": "50020", "PGID": "50010"}) {
|
||||
t.Errorf("the container's environment is %v", app["env"])
|
||||
}
|
||||
want := []any{"/var/lib/radarr/config:/config", "/storage/media/movies:/movies", "/storage/books/shelf:/books:ro,z"}
|
||||
if !reflect.DeepEqual(app["volumes"], want) {
|
||||
t.Errorf("the volumes are %v; want %v", app["volumes"], want)
|
||||
}
|
||||
if content := fmt.Sprint(got["radarr.env"]["content"]); content !=
|
||||
"PUID=50020\nPGID=50010\nUSER=mesh-radarr\nGROUP=mesh-media\nUMASK=002\n" {
|
||||
t.Errorf("the identity file reads %q", content)
|
||||
}
|
||||
if owner := got["radarr.config"]["owner"]; owner != "50020:50020" {
|
||||
t.Errorf("its directory is owned by %v; want its user and own group (ADR 0334 §1)", owner)
|
||||
}
|
||||
if len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr",
|
||||
Name: "mesh-radarr", UID: 50020, GID: 50010, Groups: []int{50011}}) {
|
||||
t.Errorf("the identities are %+v", composed.Identities)
|
||||
}
|
||||
if w := LibraryWarnings(aRadarr(t)); len(w) != 1 || !strings.Contains(w[0], "reaches only media") {
|
||||
t.Errorf("a module of two libraries is warned %q", w)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows 3, 7, 11: every library reaching the machine has its group, with its number; each library used here or homed
|
||||
// here is named with its root, whether this is its home, whether it is taken, the sub-paths kept on its home, and the
|
||||
// tool runner's account where a module whose tools run there uses it.
|
||||
func TestTheDeclarationNamesTheLibrariesAndTheirGroups(t *testing.T) {
|
||||
with := librariesRendering()
|
||||
with.TakenLibraries = map[string]bool{"media": false, "books": true}
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
composed := composeLibraries(t, r, with)
|
||||
raw, _ := json.Marshal(map[string]any{"groups": composed.Groups, "libraries": composed.Libraries})
|
||||
want := `{"groups":[{"name":"mesh-books","gid":50011},{"name":"mesh-media","gid":50010}],` +
|
||||
`"libraries":[{"library":"books","root":"/storage/books","group":"mesh-books","gid":50011,"home":true,` +
|
||||
`"taken":true,"paths":["shelf"],"runner":"jochen"},` +
|
||||
`{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,"taken":false,` +
|
||||
`"paths":["movies","series"],"runner":"jochen"}]}`
|
||||
if string(raw) != want {
|
||||
t.Errorf("the declaration says\n %s\nwant\n %s", raw, want)
|
||||
}
|
||||
// On a machine it reaches but is not home: the group, the library as used there, and no sub-path from elsewhere.
|
||||
elsewhere := composeLibraries(t, Resolution{Node: "novox", Account: "jochen",
|
||||
Modules: []Manifest{aPlain(), theRuntime(t)}}, with)
|
||||
raw, _ = json.Marshal(map[string]any{"groups": elsewhere.Groups, "libraries": elsewhere.Libraries})
|
||||
if string(raw) != `{"groups":[{"name":"mesh-media","gid":50010}],"libraries":null}` {
|
||||
t.Errorf("on a machine it only reaches: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 2: a module assigned to a machine its library does not reach is left out there, naming the library; one whose
|
||||
// library is not defined likewise.
|
||||
func TestALibraryNotReachingTheMachineLeavesItsModuleOut(t *testing.T) {
|
||||
with := librariesRendering()
|
||||
with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: 50020}
|
||||
composed := composeLibraries(t, Resolution{Node: "far", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "does not reach far") {
|
||||
t.Errorf("radarr on a machine media does not reach: %q", why)
|
||||
}
|
||||
delete(with.Libraries, "books")
|
||||
composed = composeLibraries(t, Resolution{Node: "ace", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), theRuntime(t)}}, with)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "books, which is not defined") {
|
||||
t.Errorf("radarr with an undefined library: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 14: a module that neither declares libraries nor uses its identity is composed exactly as before — the whole
|
||||
// declaration: its resources, the filter its port reaches, the contributions it makes — on an engine that keeps
|
||||
// libraries or not; on a machine a library reaches, only that library's group is added. A joining module on an older
|
||||
// engine is left out, naming why.
|
||||
func TestAModuleThatDoesNotJoinIsComposedAsBefore(t *testing.T) {
|
||||
plain := aPlain()
|
||||
plain.Listens = []Listening{{Port: 8080, From: FromEverywhere, Name: "web", Why: "its page"}}
|
||||
plain.Contributes = map[string]map[string]any{"reverse-proxy": {"host": "plain", "port": 8080}}
|
||||
plain.Requires = []string{"reverse-proxy"}
|
||||
modules := []Manifest{plain, proxy(), {Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
theRuntime(t)}
|
||||
base := Rendering{Mesh: []string{"198.51.100.2"}, OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"}
|
||||
keeping := func(node string) Rendering {
|
||||
with := librariesRendering()
|
||||
with.Mesh, with.OutwardLinks, with.TunnelInterface = base.Mesh, base.OutwardLinks, base.TunnelInterface
|
||||
return with
|
||||
}
|
||||
for _, node := range []string{"far", "ace"} {
|
||||
r := Resolution{Node: node, Account: "jochen", Modules: modules}
|
||||
before := composeLibraries(t, r, base)
|
||||
after := composeLibraries(t, r, keeping(node))
|
||||
if node == "far" {
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatalf("on a machine no library reaches, a module that does not join composed otherwise:\n %+v\n %+v",
|
||||
before, after)
|
||||
}
|
||||
continue
|
||||
}
|
||||
groups := after.Groups
|
||||
after.Groups, after.Libraries = nil, nil
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatalf("on %s, a module that does not join composed otherwise:\n %+v\n %+v", node, before, after)
|
||||
}
|
||||
if len(groups) == 0 || len(after.Identities) > 0 {
|
||||
t.Fatalf("on the library's home: groups %v, identities %v", groups, after.Identities)
|
||||
}
|
||||
filter := fmt.Sprint(byID(before.Resources)["nftables."+FilteringID()]["content"])
|
||||
if !strings.Contains(filter, "8080") || received(t, before.Resources)[0].From != "plain" {
|
||||
t.Fatalf("the comparison held no filter or contribution of the module: %s", filter)
|
||||
}
|
||||
}
|
||||
if Joins(aPlain()) || !Joins(aRadarr(t)) {
|
||||
t.Fatal("joining is not read from the libraries and the identity")
|
||||
}
|
||||
older := librariesRendering()
|
||||
older.KeepsLibraries = false
|
||||
composed := composeLibraries(t, Resolution{Node: "ace", Account: "jochen",
|
||||
Modules: []Manifest{aRadarr(t), aPlain(), theRuntime(t)}}, older)
|
||||
if why := composed.LeftOut["radarr"]; !strings.Contains(why, "older than them") {
|
||||
t.Fatalf("a joining module on an older engine: %q", why)
|
||||
}
|
||||
if len(composed.Groups)+len(composed.Identities)+len(composed.Libraries) > 0 {
|
||||
t.Fatal("an older engine was sent groups, identities or libraries")
|
||||
}
|
||||
if byID(composed.Resources)["plain.app"] == nil {
|
||||
t.Fatal("a module that does not join was left out beside one that does")
|
||||
}
|
||||
}
|
||||
|
||||
// Row 15: no number outside the range is sent, root's above all; the composition refuses, naming why.
|
||||
func TestNoNumberTheMeshGivesIsRoot(t *testing.T) {
|
||||
for _, n := range []int{0, 4, 10, IdentityLow - 1, IdentityHigh + 1} {
|
||||
if AllocatableID(n) {
|
||||
t.Errorf("%d is allocatable", n)
|
||||
}
|
||||
with := librariesRendering()
|
||||
with.Identities["radarr"] = ModuleIdentity{Module: "radarr", UID: n}
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§15") {
|
||||
t.Errorf("a module numbered %d composed: %v", n, err)
|
||||
}
|
||||
with = librariesRendering()
|
||||
with.LibraryGroups["media"] = n
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
if _, err := r.Compose(with); err == nil || !strings.Contains(err.Error(), "§15") {
|
||||
t.Errorf("a library group numbered %d composed: %v", n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func libraryManifest(t *testing.T, extra map[string]any) []byte {
|
||||
t.Helper()
|
||||
m := map[string]any{"module": "app", "version": "1", "capabilities": []any{"container-runtime"},
|
||||
"libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"}},
|
||||
"resources": []any{map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${library:movies}:/movies"}}}}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// Rows 2 and 5 at the module check: library entries are accepted by name, sub-path and use, and refused with a host
|
||||
// path, an owner, a group, a mode or a number; ${library:<id>} only in a volume source; ${identity:…} only by its four
|
||||
// names; a joining module names no directory owner and declares no number setting.
|
||||
func TestTheModuleCheckHoldsLibrariesAndIdentityToTheDecision(t *testing.T) {
|
||||
if _, err := ParseManifest(libraryManifest(t, nil)); err != nil {
|
||||
t.Fatalf("a library entry was refused: %v", err)
|
||||
}
|
||||
entry := func(extra map[string]any) map[string]any {
|
||||
e := map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read-write"}
|
||||
for k, v := range extra {
|
||||
e[k] = v
|
||||
}
|
||||
return map[string]any{"libraries": []any{e}}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
extra map[string]any
|
||||
want string
|
||||
}{
|
||||
"a host path": {entry(map[string]any{"path": "/storage/media/movies"}), "never a host path"},
|
||||
"out of it": {entry(map[string]any{"path": "../other"}), "never a host path"},
|
||||
"an owner": {entry(map[string]any{"owner": "1000"}), "owner"},
|
||||
"a group": {entry(map[string]any{"group": "media"}), "group"},
|
||||
"a mode": {entry(map[string]any{"mode": "0775"}), "mode"},
|
||||
"a number": {entry(map[string]any{"gid": 1000}), "gid"},
|
||||
"a use": {entry(map[string]any{"use": "write"}), "is used \"write\""},
|
||||
"in the env": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "env": map[string]any{"MOVIES": "${library:movies}"}}}}, "volume source"},
|
||||
"in a file": {map[string]any{"resources": []any{map[string]any{"id": "f", "type": "file", "path": "/etc/app.conf",
|
||||
"mode": "0644", "content": "movies=${library:movies}"}}}, "volume source"},
|
||||
"on the container side": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "volumes": []any{"/srv:${library:movies}"}}}}, "volume source"},
|
||||
"an unknown identity": {map[string]any{"resources": []any{map[string]any{"id": "app", "type": "container",
|
||||
"name": "app", "image": libraryImage, "env": map[string]any{"HOME": "${identity:home}"}}}}, "${identity:home}"},
|
||||
"a directory owner": {map[string]any{"resources": []any{map[string]any{"id": "d", "type": "directory",
|
||||
"mode": "0750", "owner": "1000:1000"}}}, "names its owner"},
|
||||
} {
|
||||
_, err := ParseManifest(libraryManifest(t, c.extra))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v; want refused saying %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
// A module that does not join keeps its directory owner and its settings as they are (§14).
|
||||
plain := map[string]any{"module": "plain", "version": "1",
|
||||
"resources": []any{map[string]any{"id": "d", "type": "directory", "mode": "0750", "owner": "1000:1000"}}}
|
||||
raw, _ := json.Marshal(plain)
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Errorf("a module that does not join was refused: %v", err)
|
||||
}
|
||||
// A setting carrying a number, or a placement's owner, is refused for a joining module, and kept for one that does
|
||||
// not.
|
||||
if err := JoiningSettingProblem(aRadarr(t), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err == nil ||
|
||||
!strings.Contains(err.Error(), "carries a user or group number") {
|
||||
t.Errorf("a joining module's puid setting: %v", err)
|
||||
}
|
||||
if err := JoiningSettingProblem(aPlain(), []Layer{{From: "ace", Values: map[string]any{"puid": "1000"}}}); err != nil {
|
||||
t.Errorf("a plain module's puid setting: %v", err)
|
||||
}
|
||||
placed := []Layer{{From: "ace", Values: map[string]any{PlacesSetting: map[string]any{
|
||||
"config": map[string]any{"path": "/srv/x", "owner": "1000:1000"}}}}}
|
||||
if err := PlacesOwnerProblem(aRadarr(t), placed); err == nil || !strings.Contains(err.Error(), "ADR 0328") {
|
||||
t.Errorf("a joining module's placed owner: %v", err)
|
||||
}
|
||||
if err := PlacesOwnerProblem(aPlain(), placed); err != nil {
|
||||
t.Errorf("a plain module's placed owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 1: the libraries are the controller's own mesh-wide setting, the terminal's or the operator's Approve alone.
|
||||
func TestTheLibrariesAreTheOperatorsSetting(t *testing.T) {
|
||||
controller := Manifest{Module: LibrariesModule, Version: "1"}
|
||||
keys := TerminalKeys(controller)
|
||||
if !hasName(keys, LibrariesSetting) {
|
||||
t.Fatalf("the libraries are not among the terminal's keys: %v", keys)
|
||||
}
|
||||
if hasName(TerminalKeys(Manifest{Module: "radarr"}), LibrariesSetting) {
|
||||
t.Fatal("another module's libraries key is the terminal's")
|
||||
}
|
||||
if err := ComposesWith(controller, []Layer{{From: "ace", Values: map[string]any{LibrariesSetting: map[string]any{}}}},
|
||||
false); err == nil {
|
||||
t.Fatal("libraries set on one machine were accepted")
|
||||
}
|
||||
if _, err := ParseLibraries(map[string]any{"media": map[string]any{"home": "ace", "root": "/etc/media"}}); err == nil {
|
||||
t.Fatal("a library in the machine's own tree was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// What the node-engine this controller pins (mesh-host #95, ADR 0328's half) makes of a composed declaration with
|
||||
// libraries and a joining module: nothing refused.
|
||||
func TestTheEngineTakesWhatIsComposedForLibraries(t *testing.T) {
|
||||
for _, node := range []string{"ace", "novox"} {
|
||||
r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
with := librariesRendering()
|
||||
with.RunnerJoinsOwnGroups = true // contract 7 (mesh-host #97): the runner on identities, setgid on own directories
|
||||
with.Libraries["books"] = Library{Name: "books", Home: "ace", Root: "/storage/books", Reach: []string{"novox"}}
|
||||
composed := composeLibraries(t, r, with)
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources,
|
||||
"groups": composed.Groups, "identities": composed.Identities, "libraries": composed.Libraries})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("%s: the pinned node-engine refuses it: %v", node, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0334 §1, §2 (the review of 7ea6d36e, item 2): a joining module's own directories are owned by its user
|
||||
// and its own group, not the library's; and the tool runner's account joins the own group of every joining module whose
|
||||
// tools run on the machine — sent only to an engine that reads it (link.OwnGroupContract).
|
||||
func TestAJoiningModulesOwnDirectoriesAreItsOwnGroups(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}}
|
||||
got := byID(composeLibraries(t, r, librariesRendering()).Resources)
|
||||
if owner := got["radarr.config"]["owner"]; owner != "50020:50020" {
|
||||
t.Errorf("its directory is owned by %v; want its user and its own group", owner)
|
||||
}
|
||||
// ADR 0334 v4: with setgid, so what is made in it later keeps mesh-radarr though the process's primary group is the
|
||||
// library's.
|
||||
if mode := got["radarr.config"]["mode"]; mode != "0750" {
|
||||
t.Errorf("an engine that refuses setgid modes was sent %v", mode)
|
||||
}
|
||||
if env := got["radarr.app"]["env"].(map[string]any); env["PGID"] != "50010" {
|
||||
t.Errorf("its process's primary group is %v; want the library's, so what it makes there keeps the group", env["PGID"])
|
||||
}
|
||||
older := composeLibraries(t, r, librariesRendering())
|
||||
if older.Identities[0].Runner != "" {
|
||||
t.Errorf("an engine that cannot put the runner in a module's own group was told %q", older.Identities[0].Runner)
|
||||
}
|
||||
with := librariesRendering()
|
||||
with.RunnerJoinsOwnGroups = true
|
||||
newer := composeLibraries(t, r, with)
|
||||
if got := newer.Identities[0].Runner; got != "jochen" {
|
||||
t.Errorf("the runner joins %q's own group; want jochen, whose tools it runs", got)
|
||||
}
|
||||
if mode := byID(newer.Resources)["radarr.config"]["mode"]; mode != "2750" {
|
||||
t.Errorf("its directory's mode is %v; want 2750, setgid on its own 0750", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0334 §3: a joining module whose tools name a directory whose mode closes it to its group is refused at
|
||||
// the module check, naming the directory.
|
||||
func TestAToolsDirectoryIsOpenToItsGroup(t *testing.T) {
|
||||
closed := map[string]any{
|
||||
"libraries": []any{map[string]any{"id": "movies", "library": "media", "path": "movies", "use": "read"}},
|
||||
"resources": []any{
|
||||
map[string]any{"id": "config", "type": "directory", "mode": "0700"},
|
||||
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}},
|
||||
"build": map[string]any{"artifacts": []any{map[string]any{"name": "tools", "kind": "bundle",
|
||||
"language": "go", "system": "arch", "from": "cmd/app", "binary": "app", "loads": []any{"app"},
|
||||
"env": map[string]any{"APP_CONFIG": "${dir:config}/config.xml"}}}}}
|
||||
_, err := ParseManifest(libraryManifest(t, closed))
|
||||
if err == nil || !strings.Contains(err.Error(), "config") || !strings.Contains(err.Error(), "ADR 0334") {
|
||||
t.Fatalf("a tools' directory closed to its group: %v", err)
|
||||
}
|
||||
open := closed
|
||||
open["resources"] = []any{
|
||||
map[string]any{"id": "config", "type": "directory", "mode": "0750"},
|
||||
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${dir:config}:/config", "${library:movies}:/movies"}}}
|
||||
if _, err := ParseManifest(libraryManifest(t, open)); err != nil {
|
||||
t.Fatalf("a tools' directory open to its group was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 7ea6d36e, item 6: a service's unit takes the identity too — `User=${identity:user}` — at the module
|
||||
// check and in what is composed.
|
||||
func TestAServicesUnitTakesTheIdentity(t *testing.T) {
|
||||
unit := "[Service]\nUser=${identity:user}\nGroup=${identity:group}\nUMask=0002\nExecStart=/usr/bin/importer\n"
|
||||
raw := libraryManifest(t, map[string]any{"resources": []any{
|
||||
map[string]any{"id": "app", "type": "container", "name": "app", "image": libraryImage,
|
||||
"volumes": []any{"${library:movies}:/movies"}},
|
||||
map[string]any{"id": "watch", "type": "service", "name": "app-watch", "unit": unit}}})
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("a unit naming the identity was refused: %v", err)
|
||||
}
|
||||
with := librariesRendering()
|
||||
with.Identities["app"] = ModuleIdentity{Module: "app", UID: 50030}
|
||||
composed := composeLibraries(t, Resolution{Node: "ace", Modules: []Manifest{m}}, with)
|
||||
got := fmt.Sprint(byID(composed.Resources)["app.watch"]["unit"])
|
||||
if !strings.Contains(got, "User=mesh-app\nGroup=mesh-media\n") {
|
||||
t.Fatalf("the unit reads %q", got)
|
||||
}
|
||||
}
|
||||
@@ -536,6 +536,9 @@ type Manifest struct {
|
||||
// `directory` resource, which the mesh creates and owns: an access is mounted and nothing
|
||||
// about it is reconciled, and several modules may name the same one without conflict.
|
||||
Accesses []Access `json:"accesses,omitempty"`
|
||||
// Libraries are the libraries this module uses, each by name, sub-path and use (novox/hq ADR 0328 §2): where a
|
||||
// library is, and who may write it, is the operator's definition and the mesh's, never the module's.
|
||||
Libraries []LibraryUse `json:"libraries,omitempty"`
|
||||
|
||||
// Computed names something in the control plane that works this module's resources out per
|
||||
// node, instead of them being fixed here.
|
||||
@@ -976,6 +979,35 @@ type Artifact struct {
|
||||
// success, and was invisible to the launcher (novox/hq 04-ISSUES/142).
|
||||
Binary string `json:"binary,omitempty"`
|
||||
|
||||
// URL is where an archive's upstream file is fetched from, for an archive that is a release
|
||||
// somebody else published rather than a directory of this repository (novox/hq ADR 0316 §2, §6).
|
||||
// With it come SHA256 and Version, and From stays empty.
|
||||
//
|
||||
// **Pinned by the digest of what the vendor published, mirrored by the build seat.** A release of
|
||||
// about 80 MB copied into a repository is kept for ever in its history at every upgrade, and an
|
||||
// add-on whose licence forbids copying it to third parties may not be in a repository at all. So the
|
||||
// manifest names the file, the build seat fetches it, refuses it when its sha256 is not the pin's and
|
||||
// names both digests, and packs it as any archive is packed: what reaches a machine is the mesh's own
|
||||
// archive in the artifact store, fetched by digest like every other, and a machine never asks the
|
||||
// vendor. Upgrading is changing the pin, reviewed like any other change.
|
||||
URL string `json:"url,omitempty"`
|
||||
// SHA256 is the sha256 of the file at URL, as 64 lower-case hexadecimal characters.
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
// Version is what the vendor calls the release pinned, said where a person reads the pin; the build
|
||||
// does not read it.
|
||||
Version string `json:"version,omitempty"`
|
||||
// File is the name an upstream file that is not an archive is kept under: the archive built from it
|
||||
// holds that one file (an add-on's xpi). Empty means the file is a tar archive (gzip, bzip2 or xz)
|
||||
// whose files are the artifact.
|
||||
File string `json:"file,omitempty"`
|
||||
// Within is the directory inside an upstream tar archive whose content is the artifact, for a
|
||||
// release that unpacks into a directory of its own name (`betterbird/`). An entry outside it refuses
|
||||
// the build. Empty means the archive's root.
|
||||
Within string `json:"within,omitempty"`
|
||||
// NamesOnPurpose are the names this artifact means to name, each with why: the vendor's host an upstream
|
||||
// file is fetched from, which the installation check judges like any name (InstallationProblems).
|
||||
NamesOnPurpose map[string]string `json:"names-on-purpose,omitempty"`
|
||||
|
||||
// Entrypoints are the compiled files a tool host should load from this module, relative to the
|
||||
// bundle's root.
|
||||
//
|
||||
@@ -1689,6 +1721,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
// facts about the catalogue and are checked at registration (CatalogueProblems).
|
||||
problems = append(problems, declaredSeatProblems(m)...)
|
||||
problems = append(problems, RunsAsProblems(m)...)
|
||||
problems = append(problems, ReadsHolderProblems(m)...)
|
||||
if m.Computed != "" && len(m.Resources) > 0 {
|
||||
// One or the other. A module that both ships files and has them computed would leave
|
||||
// nobody able to say where a given file came from.
|
||||
@@ -1829,6 +1862,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)...)
|
||||
// Its libraries and its identity, and what a module that joins is held to (novox/hq ADR 0328).
|
||||
problems = append(problems, libraryProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import "path"
|
||||
|
||||
// The machine's own resolver (novox/hq ADR 0247).
|
||||
//
|
||||
// **Most machines have none.** Every machine lists the mesh's resolvers in /etc/resolv.conf and nothing
|
||||
@@ -66,43 +68,39 @@ func holdsSeat(m Manifest, seat string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// rendersResolverFile says whether a module asks the mesh to render the machine's resolver file.
|
||||
func rendersResolverFile(m Manifest) bool {
|
||||
for _, f := range m.Facts {
|
||||
if !f.Home && f.Path == ResolverFile {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolverFileOwner is the module that writes the machine's resolver file among the modules on one
|
||||
// machine (novox/hq ADR 0247): the holder of node-resolver when one renders it, else nobody is named here
|
||||
// and the file is whoever's it always was — the uplink holder's (ADR 0223).
|
||||
func ResolverFileOwner(modules []Manifest) string {
|
||||
for _, m := range modules {
|
||||
if holdsSeat(m, ResolverSeat) && rendersResolverFile(m) {
|
||||
return m.Module
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stepsBack is the module as it composes on a machine whose resolver file is the node-resolver holder's:
|
||||
// **the uplink holder's rendering of that file is left out**, and nothing else of it changes. Only the
|
||||
// uplink's holder steps back — any other module rendering the file beside the resolver's is still two
|
||||
// owners of one path, and is refused as before.
|
||||
// stepsBack is the module as it composes among the modules of one machine (novox/hq ADR 0287,
|
||||
// ADR 0247):
|
||||
// **where it holds a seat that names a path stepping back for another seat, and that other seat's holder
|
||||
// is on the machine and declares the path, its own rendering of the path is left out**, and nothing else
|
||||
// of it changes. Only a file the mesh renders for it steps back, as before: a resource on the path is
|
||||
// composed as declared (the declaration copies a module's resources whole), so an uplink holder declaring
|
||||
// the resolver file as a resource beside the resolver's holder is two owners of one path, and refused. The stepping back is the seat's definition — node-uplink stepping back from the resolver
|
||||
// file for node-resolver — so any other module declaring the path beside the owner's is still two owners
|
||||
// of one path, and is refused as before.
|
||||
func stepsBack(m Manifest, modules []Manifest) Manifest {
|
||||
if !holdsSeat(m, "node-uplink") || !rendersResolverFile(m) {
|
||||
return m
|
||||
drop := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
seat, known := SeatNamed(c.Name)
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
for _, p := range seat.Paths {
|
||||
if p.StepsBackFor == "" {
|
||||
continue
|
||||
}
|
||||
for _, o := range modules {
|
||||
if o.Module != m.Module && holdsSeat(o, p.StepsBackFor) && declaresPath(o, p.Path) {
|
||||
drop[p.Path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
owner := ResolverFileOwner(modules)
|
||||
if owner == "" || owner == m.Module {
|
||||
if len(drop) == 0 {
|
||||
return m
|
||||
}
|
||||
facts := make(map[string]RosterFile, len(m.Facts))
|
||||
for name, f := range m.Facts {
|
||||
if !f.Home && f.Path == ResolverFile {
|
||||
if !f.Home && drop[path.Clean(f.Path)] {
|
||||
continue
|
||||
}
|
||||
facts[name] = f
|
||||
@@ -113,9 +111,6 @@ func stepsBack(m Manifest, modules []Manifest) Manifest {
|
||||
|
||||
// steppedBack is every module of one machine as it composes there (stepsBack, each).
|
||||
func steppedBack(modules []Manifest) []Manifest {
|
||||
if ResolverFileOwner(modules) == "" {
|
||||
return modules
|
||||
}
|
||||
out := make([]Manifest, len(modules))
|
||||
for i, m := range modules {
|
||||
out[i] = stepsBack(m, modules)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A node's files and accounts, read (novox/hq ADR 0310, extending ADR 0268 §5; hq to-be 41 WP8).
|
||||
//
|
||||
// **A seat of its own on every node.** Reading a file, searching one, listing a directory, the owner and mode
|
||||
// of each component of a path, and looking up an account or a group are the reads ADR 0268 §5 has narrow tools
|
||||
// take over from the login shell's `execute`, so an approval is asked for an act and never for a read. Every
|
||||
// node needs them, the control-node included, and another module could offer them instead of the tool
|
||||
// runner's (ADR 0126 §9): a seat, so the mesh finds them on every node by one name.
|
||||
//
|
||||
// **Read as an account that holds no secret.** The holder runs as an account of its own (runs-as, ADR 0259
|
||||
// §8), so the node's file modes bound what it sees — never the operator's account, which sees the
|
||||
// operator's keys and the agent's credentials. On top, it refuses by name the places a secret is kept,
|
||||
// which the controller names for it (secretPlaces), and every answer passes the content rule's secret class
|
||||
// (mesh-sdk's outward, ADR 0310 §4).
|
||||
|
||||
// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310).
|
||||
const ReadsSeat = "node-reads"
|
||||
|
||||
// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is
|
||||
// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5).
|
||||
const ReadsSecretPlaces = "MESH_SECRET_PLACES"
|
||||
|
||||
// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the
|
||||
// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in
|
||||
// every answer it cuts.
|
||||
func readsVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " +
|
||||
"(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " +
|
||||
"file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " +
|
||||
"holds no secret: a component it may not reach says so. At most 200 paths in one call.",
|
||||
Input: schema(map[string]string{
|
||||
"paths": "one absolute path, or several separated by line breaks",
|
||||
"follow": "\"true\": also say where the path leads once every link is followed",
|
||||
}, []string{"paths"}, "follow"),
|
||||
Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}},
|
||||
{Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " +
|
||||
"link is followed, and whether that exists.",
|
||||
Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}),
|
||||
Replaces: []string{"readlink", "readlink -f", "realpath"}},
|
||||
{Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " +
|
||||
"link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " +
|
||||
"is refused by name.",
|
||||
Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}),
|
||||
Replaces: []string{"ls", "ls -la"}},
|
||||
{Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " +
|
||||
"(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " +
|
||||
"kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " +
|
||||
"number said. /proc and /sys are read as far as the holder's account may.",
|
||||
Input: schema(map[string]string{
|
||||
"path": "an absolute path to a file",
|
||||
"from": "the first line to answer, counted from 1 (optional)",
|
||||
"lines": "how many lines to answer (optional)",
|
||||
"bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)",
|
||||
}, []string{"path"}),
|
||||
Replaces: []string{"cat", "head", "tail", "less"}},
|
||||
{Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " +
|
||||
"every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " +
|
||||
"8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " +
|
||||
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " +
|
||||
"secret is withheld. The answer says which bound it met.",
|
||||
Input: schema(map[string]string{
|
||||
"text": "the fixed string to find",
|
||||
"path": "an absolute path to a file or a directory",
|
||||
"depth": "how many directories deep, up to 8 (optional)",
|
||||
"matches": "the most matches to answer, up to 500 (optional)",
|
||||
"case": "\"false\": match whatever the case (optional)",
|
||||
}, []string{"text", "path"}, "case"),
|
||||
Replaces: []string{"grep", "grep -r", "grep -F"}},
|
||||
{Name: "account", Description: "One account or group by name or number, as this node's account files say: " +
|
||||
"an account's number, group, home, shell and every group it is in; a group's number and members. With " +
|
||||
"neither, every account and group. Never a password field.",
|
||||
Input: schema(map[string]string{
|
||||
"name": "an account's name or number (optional)",
|
||||
"group": "a group's name or number (optional)",
|
||||
}, nil),
|
||||
Replaces: []string{"getent passwd", "getent group", "id", "groups"}},
|
||||
}
|
||||
}
|
||||
|
||||
// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an
|
||||
// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would
|
||||
// read as the operator's account, which sees the operator's keys and the agent's credentials.
|
||||
func ReadsHolderProblems(m Manifest) []string {
|
||||
if !m.ClaimsSeat(ReadsSeat) {
|
||||
return nil
|
||||
}
|
||||
if m.RunsAs == "" {
|
||||
return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+
|
||||
"bounded by that account's file modes, and the machine's runtime runs as the operator's account "+
|
||||
"(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)}
|
||||
}
|
||||
// In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes
|
||||
// are what bound its reads.
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs {
|
||||
continue
|
||||
}
|
||||
if groups, said := r["groups"]; said {
|
||||
if list, ok := groups.([]any); !ok || len(list) > 0 {
|
||||
return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+
|
||||
"reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310
|
||||
// §5): the directory of every module's own secret, every credential file a module is given and every grant
|
||||
// directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed
|
||||
// secrets, and every directory of the module that provides `secret` — the vault — wherever the node places it,
|
||||
// a directory declared without a path included. Sorted, each once, and a place under another dropped, so the
|
||||
// list says each refusal once.
|
||||
//
|
||||
// **Failure is loud.** A place still holding a placeholder, or not absolute, fails the composition with the
|
||||
// module named: a list that silently shrank would be a refusal the decision promises and nothing does.
|
||||
func (r Resolution) secretPlaces(with Rendering) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var problems []string
|
||||
add := func(module, what, path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") || strings.Contains(path, "${") {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s is %q, which is not a place on %s", module, what, path, r.Node))
|
||||
return
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
if path == "/" {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s is the root directory", module, what))
|
||||
return
|
||||
}
|
||||
seen[path] = true
|
||||
}
|
||||
for _, m := range r.Modules {
|
||||
for name, own := range m.OwnSecrets {
|
||||
add(m.Module, "own secret "+name, own.Path)
|
||||
if strings.HasPrefix(own.Path, "/") {
|
||||
add(m.Module, "own secret "+name+"'s directory", filepath.Dir(own.Path))
|
||||
}
|
||||
}
|
||||
for _, to := range m.SecretRequirements() {
|
||||
for _, f := range m.SecretFiles(to) {
|
||||
add(m.Module, "credential file for "+to, f.Path)
|
||||
}
|
||||
}
|
||||
for to, where := range m.Grants {
|
||||
add(m.Module, "grant directory for "+to, where)
|
||||
}
|
||||
add(m.Module, "place for the operator-sealed secrets (keeps)", m.Keeps)
|
||||
if providesSecret(m) {
|
||||
dirs := dirsFor(m, with)
|
||||
for _, id := range sortedKeys(dirs) {
|
||||
add(m.Module, "directory "+id, dirs[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, fmt.Errorf("the holder of %s on %s cannot be told where a secret is kept, so it would read "+
|
||||
"one (novox/hq ADR 0310 §5): %s", ReadsSeat, r.Node, strings.Join(problems, "; "))
|
||||
}
|
||||
all := make([]string, 0, len(seen))
|
||||
for p := range seen {
|
||||
all = append(all, p)
|
||||
}
|
||||
sort.Strings(all)
|
||||
var out []string
|
||||
for _, p := range all {
|
||||
if n := len(out); n > 0 && strings.HasPrefix(p, out[n-1]+"/") {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func providesSecret(m Manifest) bool {
|
||||
for _, p := range m.Provides {
|
||||
if p.Name == "secret" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readsWords is what the holder of node-reads is given beside its own words: the node's secret places.
|
||||
func (r Resolution) readsWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
|
||||
if !m.ClaimsSeat(ReadsSeat) {
|
||||
return words, nil
|
||||
}
|
||||
places, err := r.secretPlaces(with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := json.Marshal(places)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if words == nil {
|
||||
words = map[string]string{}
|
||||
}
|
||||
if _, has := words[ReadsSecretPlaces]; has {
|
||||
return nil, fmt.Errorf("%s gives its own %s; the node's secret places are the mesh's to say (novox/hq ADR 0310 §5)",
|
||||
m.Module, ReadsSecretPlaces)
|
||||
}
|
||||
words[ReadsSecretPlaces] = string(body)
|
||||
return words, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0310 §1, §2: node-reads is a node seat, and its verbs say the commands they replace, so
|
||||
// mesh_search finds them by `cat`, `grep` and `getent passwd` (ADR 0245 §3).
|
||||
func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) {
|
||||
s, ok := SeatNamed(ReadsSeat)
|
||||
if !ok {
|
||||
t.Fatalf("the mesh defines no seat %s", ReadsSeat)
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "0310") {
|
||||
t.Errorf("%s: scope %q, delivers %q, decision %q", ReadsSeat, s.Scope, s.Delivers, s.Decision)
|
||||
}
|
||||
replaced := map[string]string{}
|
||||
for _, v := range s.Serves {
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional: a new seat's holder serves every verb", ReadsSeat, v.Name)
|
||||
}
|
||||
if len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s says nothing it replaces", ReadsSeat, v.Name)
|
||||
}
|
||||
for _, c := range v.Replaces {
|
||||
replaced[c] = v.Name
|
||||
}
|
||||
}
|
||||
for command, verb := range map[string]string{"cat": "read", "grep": "search", "getent passwd": "account",
|
||||
"getent group": "account", "id": "account", "stat": "stat", "readlink": "readlink", "ls": "list"} {
|
||||
if replaced[command] != verb {
|
||||
t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], ReadsSeat, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would
|
||||
// carry — as the operator's account — is refused at parse.
|
||||
func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module": "reads", "version": "1",
|
||||
"claims": [{"name": "node-reads", "scope": "node"}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") {
|
||||
t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err)
|
||||
}
|
||||
if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads",
|
||||
Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 {
|
||||
t.Errorf("a holder running as its own account was refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory
|
||||
// of every module's own secret, every credential file and grant directory, the vault's directories and the
|
||||
// place the operator-sealed secrets are kept — and no other module is told.
|
||||
func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"},
|
||||
"reads": {"broker": "own"}, "desk": {"broker": "own"}}}
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := aToolsModule(t, "reads", "tools/index.js")
|
||||
reads.RunsAs, reads.SecretsOwner = "mesh-reads", "mesh-reads"
|
||||
reads.Claims = []Claim{{Name: ReadsSeat}}
|
||||
reads.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh-reads/broker"}}
|
||||
desk := aToolsModule(t, "desk", "tools/index.js")
|
||||
desk.RunsAs, desk.SecretsOwner = "desk", "desk"
|
||||
desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}}
|
||||
// The vault as the catalogue declares it: its directories have no path of their own, and the node places them.
|
||||
vault, err := placedManifest(catalogueManifest(t, "mesh-vault"), with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := Manifest{Module: "app", Version: "1", Secrets: map[string]string{"postgres-database": "/etc/app/db.secret"},
|
||||
OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}}
|
||||
r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}}
|
||||
owns, err := r.ownRuntimes(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var given map[string]map[string]string
|
||||
for _, p := range owns {
|
||||
if p["id"] == "reads."+OwnRuntimeID() {
|
||||
if err := json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if p["id"] == "desk."+OwnRuntimeID() && strings.Contains(p["env"].(map[string]string)[RuntimeToolEnv], ReadsSecretPlaces) {
|
||||
t.Error("a module not holding node-reads is told the node's secret places")
|
||||
}
|
||||
}
|
||||
var places []string
|
||||
if err := json.Unmarshal([]byte(given["reads"][ReadsSecretPlaces]), &places); err != nil {
|
||||
t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given)
|
||||
}
|
||||
want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads",
|
||||
"/var/lib/mesh-vault", "/var/lib/mesh/" + RuntimeModule}
|
||||
if strings.Join(places, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("secret places:\n got %v\nwant %v", places, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The review of #257: a secret place the controller cannot resolve fails the composition, naming the module,
|
||||
// rather than leaving the list one place shorter.
|
||||
func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "app", Version: "1", Keeps: "${dir:keep}/sealed"},
|
||||
}}
|
||||
if _, err := r.secretPlaces(Rendering{}); err == nil || !strings.Contains(err.Error(), "app") {
|
||||
t.Errorf("an unresolved place was dropped: %v", err)
|
||||
}
|
||||
r.Modules[0].Keeps = "relative/place"
|
||||
if _, err := r.secretPlaces(Rendering{}); err == nil {
|
||||
t.Error("a relative place was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused.
|
||||
func TestTheReadsAccountIsInNoGroup(t *testing.T) {
|
||||
m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}},
|
||||
Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}}
|
||||
if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") {
|
||||
t.Errorf("an account in docker was not refused: %v", p)
|
||||
}
|
||||
m.Resources[0]["groups"] = []any{}
|
||||
if p := ReadsHolderProblems(m); len(p) != 0 {
|
||||
t.Errorf("an account in no group was refused: %v", p)
|
||||
}
|
||||
}
|
||||
@@ -957,7 +957,9 @@ func checkResources(modules []Manifest) []string {
|
||||
// Judged as the machine composes them: where the node-resolver holder writes the resolver file, the
|
||||
// uplink holder's rendering of it is not composed, so it is not a second owner (novox/hq ADR 0247).
|
||||
modules = steppedBack(modules)
|
||||
var problems []string
|
||||
// And only a claimant of a seat naming a path declares it — the controller's generated resources
|
||||
// included, which are judged here at composition (novox/hq ADR 0287).
|
||||
problems := seatPathsOnMachine(modules)
|
||||
owner := map[string]string{}
|
||||
ownedPath := map[string]string{} // path → owning module, for the access check below
|
||||
packagePresent, packageAbsent := map[string][]string{}, map[string][]string{}
|
||||
|
||||
@@ -303,6 +303,10 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The holder of node-reads is told where this node keeps its secrets (novox/hq ADR 0310 §5).
|
||||
if words, err = r.readsWords(m, words, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(words) > 0 {
|
||||
body, err := json.Marshal(map[string]map[string]string{m.Module: words})
|
||||
if err != nil {
|
||||
@@ -406,6 +410,10 @@ func bundleWords(m Manifest, with Rendering) (map[string]string, error) {
|
||||
if value, err = portsFilledInto(value, m.Module+"'s bundle "+b.Name+" ("+word+")", m.Module, m.Listens, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A joining module's identity and its libraries' host paths: its tools run on the machine (ADR 0328 §5).
|
||||
if value, err = bundleLibraryFill(value, m, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]string{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The paths a seat's holder owns (novox/hq ADR 0287).
|
||||
//
|
||||
// **Who owns a machine-wide file is the role's question, answered once.** Until this, which module owned
|
||||
// /etc/hosts or /etc/resolv.conf was decided record by record (ADRs 0199, 0222, 0223, 0241) and enforced
|
||||
// only after the fact, by checkResources finding two modules on one machine declaring the same path: a
|
||||
// module that did not hold node-hostname could declare /etc/hosts on a machine where nobody held it, and
|
||||
// nothing refused it. Now a seat's definition names the paths the role owns on any machine, and three
|
||||
// refusals follow from it, each where the mesh can still say no:
|
||||
//
|
||||
// - a claim whose module does not declare every path its seat names, by CanHold, at registration and at
|
||||
// handover;
|
||||
// - a module declaring a path a seat names while claiming none of the seats that name it, at
|
||||
// registration (CatalogueProblems), and what the controller generates for a machine, at composition;
|
||||
// - a module's own seat naming a path another seat already names, at registration — the second loses.
|
||||
//
|
||||
// Reading a seat's path is access, never a resource, so it stays allowed.
|
||||
|
||||
// SeatPath is one path a seat's holder owns: absolute, literal, on the machine where the holder runs.
|
||||
type SeatPath struct {
|
||||
Path string `json:"path"`
|
||||
// StepsBackFor is a node seat whose holder owns the path instead, on a machine where that seat is held
|
||||
// (novox/hq ADR 0287, ADR 0247): this seat's holder still declares the path, and its declaration is not
|
||||
// composed there. Only the mesh's own seats step back, each by a record.
|
||||
StepsBackFor string `json:"steps-back-for,omitempty"`
|
||||
}
|
||||
|
||||
// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally
|
||||
// with no placeholder, and clean, so it compares with what a module declares byte for byte.
|
||||
func seatPathProblem(seat, scope, p string) string {
|
||||
switch {
|
||||
case scope != ScopeNode:
|
||||
return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+
|
||||
"because a path is a machine's (novox/hq ADR 0287)", seat, p, scope)
|
||||
case strings.Contains(p, "${"):
|
||||
return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+
|
||||
"literally (novox/hq ADR 0287)", seat, p)
|
||||
case !strings.HasPrefix(p, "/") || path.Clean(p) != p:
|
||||
return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)",
|
||||
seat, p)
|
||||
case path.Dir(p) == "/":
|
||||
// The root or a top-level directory is every role's, never one role's own (ADR 0287).
|
||||
return fmt.Sprintf("the seat %s names the path %q, the root or a top-level directory, which no one "+
|
||||
"role owns (novox/hq ADR 0287)", seat, p)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR
|
||||
// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it
|
||||
// without stepping back; and a seat steps back only for another node seat naming the same path without
|
||||
// stepping back, so the stepping back cannot cycle.
|
||||
func SeatPathProblems(set []Seat) []string {
|
||||
var problems []string
|
||||
owners := map[string][]string{} // path → the seats naming it without stepping back
|
||||
stepping := map[string][]SeatPath{} // seat → the paths it names stepping back
|
||||
byName := map[string]Seat{}
|
||||
for _, s := range set {
|
||||
byName[s.Name] = s
|
||||
for _, p := range s.Paths {
|
||||
if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" {
|
||||
problems = append(problems, why)
|
||||
}
|
||||
if p.StepsBackFor == "" {
|
||||
owners[p.Path] = append(owners[p.Path], s.Name)
|
||||
} else {
|
||||
stepping[s.Name] = append(stepping[s.Name], p)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range set {
|
||||
for _, p := range stepping[s.Name] {
|
||||
other, known := byName[p.StepsBackFor]
|
||||
switch {
|
||||
case !known || other.Scope != ScopeNode:
|
||||
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+
|
||||
"node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor))
|
||||
case !namesWithoutSteppingBack(other, p.Path):
|
||||
problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+
|
||||
"own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)",
|
||||
s.Name, p.Path, other.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range sortedKeys(owners) {
|
||||
if len(owners[p]) > 1 {
|
||||
problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+
|
||||
"no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and ")))
|
||||
}
|
||||
}
|
||||
for _, s := range set {
|
||||
for _, p := range stepping[s.Name] {
|
||||
if len(owners[p.Path]) == 0 {
|
||||
problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+
|
||||
"(novox/hq ADR 0287)", p.Path))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func namesWithoutSteppingBack(s Seat, p string) bool {
|
||||
for _, sp := range s.Paths {
|
||||
if sp.Path == p && sp.StepsBackFor == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file
|
||||
// the mesh renders for it outside the operator's home. An access is not here — reading is not owning.
|
||||
//
|
||||
// **Cleaned, because the machine writes the cleaned path**: /etc//hosts, /etc/./hosts, /etc/../etc/hosts and
|
||||
// /etc/hosts/ are all /etc/hosts to it, and a seat's path is clean by its own rule, so a declaration compared
|
||||
// as written would pass for a path nobody named.
|
||||
func declaredPaths(m Manifest) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||||
out[path.Clean(p)] = true
|
||||
}
|
||||
}
|
||||
for _, f := range m.Facts {
|
||||
if !f.Home && strings.HasPrefix(f.Path, "/") {
|
||||
out[path.Clean(f.Path)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declaresPath says whether a module declares one path, as a resource or as a file the mesh renders.
|
||||
func declaresPath(m Manifest, p string) bool { return declaredPaths(m)[p] }
|
||||
|
||||
// missingPaths is each path a seat names that a module does not declare, in the seat's order.
|
||||
func missingPaths(m Manifest, paths []SeatPath) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
has := declaredPaths(m)
|
||||
var missing []string
|
||||
for _, p := range paths {
|
||||
if !has[p.Path] {
|
||||
missing = append(missing, p.Path)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// pathSeats is which seats name each path: the mesh's own, and then those modules declare, in the order
|
||||
// given. A module's seat naming a path a seat already names loses it, and the refusal says so (novox/hq
|
||||
// ADR 0287 — as for a seat's name, the second declaration loses).
|
||||
func pathSeats(order []string, shelf Shelf, declaredBy map[string]string) (map[string][]string, []string) {
|
||||
named := map[string][]string{}
|
||||
for _, s := range Seats() {
|
||||
for _, p := range s.Paths {
|
||||
named[p.Path] = append(named[p.Path], s.Name)
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
for _, module := range order {
|
||||
for _, s := range shelf[module].DefinesSeats {
|
||||
if declaredBy != nil && declaredBy[s.Name] != module {
|
||||
continue // a second declaration of the seat's name, refused for that already
|
||||
}
|
||||
for _, p := range s.Paths {
|
||||
if others := named[p]; len(others) > 0 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares the seat %s naming the path %q, which %s already names; no two seats "+
|
||||
"name one path (novox/hq ADR 0287)", module, s.Name, p, strings.Join(others, " and ")))
|
||||
continue
|
||||
}
|
||||
named[p] = append(named[p], s.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return named, problems
|
||||
}
|
||||
|
||||
// unclaimedSeatPaths is each path a module declares that a seat names while it claims none of the seats
|
||||
// naming it (novox/hq ADR 0287): only a claimant declares a seat's path, and a module claiming any one of
|
||||
// them may.
|
||||
func unclaimedSeatPaths(m Manifest, named map[string][]string) []string {
|
||||
claims := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
claims[canonicalSeat(c.Name)] = true
|
||||
}
|
||||
var problems []string
|
||||
declared := declaredPaths(m)
|
||||
for _, p := range sortedKeys(declared) {
|
||||
seats := named[p]
|
||||
if len(seats) == 0 {
|
||||
continue
|
||||
}
|
||||
claimsOne := false
|
||||
for _, s := range seats {
|
||||
claimsOne = claimsOne || claims[s]
|
||||
}
|
||||
if !claimsOne {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s declares the path %q, which the seat %s names, and claims none of them; only a holder of "+
|
||||
"a seat naming a path declares it (novox/hq ADR 0287)", m.Module, p, strings.Join(seats, " and ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path
|
||||
// only on a node seat, absolute, literal and clean.
|
||||
func declaredSeatPathProblems(m Manifest, s SeatDeclaration) []string {
|
||||
var problems []string
|
||||
for _, p := range s.Paths {
|
||||
if why := seatPathProblem(s.Name, s.At(), p); why != "" {
|
||||
problems = append(problems, m.Module+": "+why)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat
|
||||
// never steps back: only the mesh's own seats do, each by a record.
|
||||
func asSeatPaths(paths []string) []SeatPath {
|
||||
out := make([]SeatPath, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
out = append(out, SeatPath{Path: p})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming
|
||||
// the path, and that seat's holder there, with the stepping back applied.
|
||||
type PathOwner struct {
|
||||
Path string `json:"path"`
|
||||
Node string `json:"node"`
|
||||
Seat string `json:"seat"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// PathOwners is, for every path a seat in the set names and every machine where a seat naming it is held,
|
||||
// who owns the path there. A seat that steps back for another held on the same machine is passed over.
|
||||
func PathOwners(set []Seat, held []Held) []PathOwner {
|
||||
// node → seat → holding module
|
||||
holders := map[string]map[string]string{}
|
||||
for _, h := range held {
|
||||
s, ok := SeatNamed(h.Claim)
|
||||
if !ok || h.Scope != ScopeNode || s.Scope != ScopeNode {
|
||||
continue
|
||||
}
|
||||
if holders[h.Node] == nil {
|
||||
holders[h.Node] = map[string]string{}
|
||||
}
|
||||
if _, taken := holders[h.Node][s.Name]; !taken {
|
||||
holders[h.Node][s.Name] = h.Module
|
||||
}
|
||||
}
|
||||
naming := map[string][]Seat{} // path → the seats naming it
|
||||
steps := map[string]map[string]string{} // path → seat → the seat it steps back for
|
||||
for _, s := range set {
|
||||
for _, p := range s.Paths {
|
||||
naming[p.Path] = append(naming[p.Path], s)
|
||||
if p.StepsBackFor != "" {
|
||||
if steps[p.Path] == nil {
|
||||
steps[p.Path] = map[string]string{}
|
||||
}
|
||||
steps[p.Path][s.Name] = p.StepsBackFor
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []PathOwner
|
||||
for _, p := range sortedKeys(naming) {
|
||||
for _, node := range sortedKeys(holders) {
|
||||
for _, s := range naming[p] {
|
||||
module, holds := holders[node][s.Name]
|
||||
if !holds {
|
||||
continue
|
||||
}
|
||||
if forSeat := steps[p][s.Name]; forSeat != "" {
|
||||
if _, there := holders[node][forSeat]; there {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, PathOwner{Path: p, Node: node, Seat: s.Name, Module: module})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatPathsOnMachine is each path the modules of one machine declare that a seat names, for a module
|
||||
// claiming none of the seats naming it (novox/hq ADR 0287). Registration refuses the same of a manifest;
|
||||
// this holds what the controller generates for a machine to it too, because a computed module's resources
|
||||
// exist only once composed (checkResources is asked them there). The seats are the mesh's own and those the
|
||||
// machine's modules declare.
|
||||
func seatPathsOnMachine(modules []Manifest) []string {
|
||||
shelf := Shelf{}
|
||||
var order []string
|
||||
for _, m := range modules {
|
||||
shelf[m.Module] = m
|
||||
order = append(order, m.Module)
|
||||
}
|
||||
named, _ := pathSeats(order, shelf, nil)
|
||||
var problems []string
|
||||
for _, m := range modules {
|
||||
problems = append(problems, unclaimedSeatPaths(m, named)...)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0287 (issue 412): a seat names the paths its holder owns, a claim without them is
|
||||
// refused, and only a claimant declares a seat's path.
|
||||
|
||||
// The starting set, exactly: node-hostname names /etc/hosts and /etc/hostname, node-uplink the resolver
|
||||
// file stepping back for node-resolver, node-resolver the resolver file, and every other seat names none.
|
||||
func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"node-hostname": "/etc/hosts /etc/hostname",
|
||||
"node-uplink": "/etc/resolv.conf>node-resolver",
|
||||
ResolverSeat: "/etc/resolv.conf",
|
||||
}
|
||||
for _, s := range DefaultSeats() {
|
||||
var got []string
|
||||
for _, p := range s.Paths {
|
||||
if p.StepsBackFor != "" {
|
||||
got = append(got, p.Path+">"+p.StepsBackFor)
|
||||
} else {
|
||||
got = append(got, p.Path)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, " ") != want[s.Name] {
|
||||
t.Errorf("%s names %v; ADR 0287 gives it %q", s.Name, got, want[s.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without
|
||||
// stepping back, and a stepping back that cannot cycle.
|
||||
func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) {
|
||||
if p := SeatPathProblems(DefaultSeats()); len(p) != 0 {
|
||||
t.Fatalf("the compiled set breaks ADR 0287: %v", p)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
why string
|
||||
set []Seat
|
||||
want string
|
||||
}{
|
||||
{"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}},
|
||||
"only a node seat names paths"},
|
||||
{"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}},
|
||||
"placeholder"},
|
||||
{"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}},
|
||||
"not a clean absolute path"},
|
||||
{"two owners", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}},
|
||||
{Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"},
|
||||
{"a cycle", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}},
|
||||
{Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}},
|
||||
"does not own that path itself"},
|
||||
{"stepping back for nobody", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}},
|
||||
"not a node seat"},
|
||||
} {
|
||||
if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s: %q does not say %q", c.why, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the store's rows carry is never the paths: a row read back keeps the compiled ones.
|
||||
func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) {
|
||||
before := seats
|
||||
t.Cleanup(func() { seats = before })
|
||||
UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}})
|
||||
s, _ := SeatNamed("node-hostname")
|
||||
if len(s.Paths) != 2 {
|
||||
t.Fatalf("node-hostname read back from its row names %v", s.Paths)
|
||||
}
|
||||
}
|
||||
|
||||
func hostnameSeat(t *testing.T) Seat {
|
||||
t.Helper()
|
||||
s, ok := SeatNamed("node-hostname")
|
||||
if !ok {
|
||||
t.Fatal("no node-hostname seat")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hostnameHolder(resources ...map[string]any) Manifest {
|
||||
return Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode,
|
||||
Serves: []string{"entries", "add", "remove"}}}, Resources: resources}
|
||||
}
|
||||
|
||||
// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or
|
||||
// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share.
|
||||
func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) {
|
||||
seat := hostnameSeat(t)
|
||||
hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"}
|
||||
name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}
|
||||
|
||||
if err := CanHold(hostnameHolder(hosts, name), seat); err != nil {
|
||||
t.Errorf("a holder declaring both paths as resources was refused: %v", err)
|
||||
}
|
||||
rendered := hostnameHolder(hosts)
|
||||
rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}}
|
||||
if err := CanHold(rendered, seat); err != nil {
|
||||
t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err)
|
||||
}
|
||||
err := CanHold(hostnameHolder(), seat)
|
||||
if err == nil {
|
||||
t.Fatal("a holder declaring neither path was accepted")
|
||||
}
|
||||
for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
// A file under the operator's home is placed per account: it is not the machine's path.
|
||||
home := hostnameHolder(hosts)
|
||||
home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}}
|
||||
if err := CanHold(home, seat); err == nil {
|
||||
t.Error("a file in the operator's home counted as the machine's /etc/hostname")
|
||||
}
|
||||
// At registration, the same refusal.
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got,
|
||||
"does not declare /etc/hostname") {
|
||||
t.Errorf("registration let a holder without /etc/hostname through: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring
|
||||
// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring
|
||||
// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed.
|
||||
func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) {
|
||||
stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}}
|
||||
got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ")
|
||||
if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) {
|
||||
t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got)
|
||||
}
|
||||
// However the path is spelled (novox/hq issue 412 review).
|
||||
unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") {
|
||||
t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got)
|
||||
}
|
||||
reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}}
|
||||
if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 {
|
||||
t.Errorf("reading /etc/hosts was refused: %v", got)
|
||||
}
|
||||
uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}}
|
||||
resolver := localResolver()
|
||||
resolver.Claims[0].Serves = []string{"routes", "route", "unroute"}
|
||||
if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 {
|
||||
t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat
|
||||
// names — the second declaration loses. Its holder declares them, as a mesh seat's does.
|
||||
func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) {
|
||||
meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Paths: []string{"/etc/a"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") {
|
||||
t.Errorf("a module's mesh seat naming a path was accepted: %q", got)
|
||||
}
|
||||
placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") {
|
||||
t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got)
|
||||
}
|
||||
|
||||
taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got,
|
||||
`b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) {
|
||||
t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got)
|
||||
}
|
||||
|
||||
own := Manifest{Module: "c", Version: "1",
|
||||
DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}},
|
||||
Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got,
|
||||
"c claims c-seat but does not declare /etc/c.conf") {
|
||||
t.Errorf("a holder of a module's seat without its path was accepted: %q", got)
|
||||
}
|
||||
own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}
|
||||
if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 {
|
||||
t.Errorf("a holder declaring its own seat's path was refused: %v", got)
|
||||
}
|
||||
other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got,
|
||||
`d declares the path "/etc/c.conf", which the seat c-seat names`) {
|
||||
t.Errorf("a module not claiming c-seat declared its path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type writesHosts struct{}
|
||||
|
||||
func (writesHosts) Resources(string) ([]map[string]any, bool, error) {
|
||||
return []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block",
|
||||
"content": "10.42.0.1 anchor\n"}}, true, nil
|
||||
}
|
||||
|
||||
// The controller is bound too: what it generates for a machine on a seat's path is refused at composition.
|
||||
func TestAGeneratedResourceOnASeatsPathIsRefused(t *testing.T) {
|
||||
r := Resolution{Node: "workstation", Modules: []Manifest{
|
||||
{Module: "mesh-names", Computed: "mesh-names"},
|
||||
}}
|
||||
_, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-names": writesHosts{}}})
|
||||
if err == nil {
|
||||
t.Fatal("a generated resource on /etc/hosts was composed")
|
||||
}
|
||||
for _, want := range []string{"mesh-names", "/etc/hosts", "node-hostname"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Who owns a seat's path on a machine: the seat naming it and its holder there, with the stepping back
|
||||
// applied — the resolver's holder where node-resolver is held, else the uplink's.
|
||||
func TestPathOwnersApplyTheSteppingBack(t *testing.T) {
|
||||
held := []Held{
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "laptop", Module: "networkmanager"},
|
||||
{Claim: ResolverSeat, Scope: ScopeNode, Node: "laptop", Module: "systemd-resolved"},
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "server", Module: "systemd-networkd"},
|
||||
{Claim: "node-hostname", Scope: ScopeNode, Node: "server", Module: "hostname"},
|
||||
}
|
||||
var got []string
|
||||
for _, o := range PathOwners(Seats(), held) {
|
||||
got = append(got, o.Path+" "+o.Node+" "+o.Seat+" "+o.Module)
|
||||
}
|
||||
want := []string{
|
||||
"/etc/hostname server node-hostname hostname",
|
||||
"/etc/hosts server node-hostname hostname",
|
||||
"/etc/resolv.conf laptop node-resolver systemd-resolved",
|
||||
"/etc/resolv.conf server node-uplink systemd-networkd",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("the owners are\n%s\nnot\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// The stepping back comes from the seat, and is of a rendered file only: an uplink holder declaring the
|
||||
// resolver file as a resource beside a node-resolver holder is two writers of one path, refused when the
|
||||
// machine is resolved — never composed twice — as before the seat named its paths.
|
||||
func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t *testing.T) {
|
||||
shelf := resolverShelf(t)
|
||||
uplink := shelf["systemd-networkd"]
|
||||
uplink.Facts = nil
|
||||
uplink.Resources = append(append([]map[string]any(nil), uplink.Resources...),
|
||||
map[string]any{"id": "resolvers", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.1\n"})
|
||||
shelf["systemd-networkd"] = uplink
|
||||
shelf["local-resolver"] = localResolver()
|
||||
_, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), `both declare the path "/etc/resolv.conf"`) {
|
||||
t.Fatalf("an uplink declaring the resolver file as a resource beside the resolver was not refused: %v", err)
|
||||
}
|
||||
// Its rendered file steps back, by the seat's definition, and nothing else of it does.
|
||||
rendered := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: "/etc//resolv.conf", Template: "x"},
|
||||
"other": {Path: "/etc/uplink.conf", Template: "x"}}}
|
||||
got := stepsBack(rendered, []Manifest{rendered, localResolver()})
|
||||
if len(got.Facts) != 1 || got.Facts["other"].Path == "" {
|
||||
t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts.
|
||||
func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) {
|
||||
for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} {
|
||||
stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got,
|
||||
`stranger declares the path "/etc/hosts", which the seat node-hostname names`) {
|
||||
t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got)
|
||||
}
|
||||
holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"},
|
||||
map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"})
|
||||
if err := CanHold(holder, hostnameSeat(t)); err != nil {
|
||||
t.Errorf("a holder declaring %s was refused: %v", spelled, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A seat names a role's own path, never the root or a top-level directory.
|
||||
func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) {
|
||||
for _, p := range []string{"/", "/etc"} {
|
||||
m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}}
|
||||
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") {
|
||||
t.Errorf("a module's seat naming %s was accepted: %q", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else
|
||||
// declares one.
|
||||
func TestTheCataloguePassesTheSeatPathRules(t *testing.T) {
|
||||
shelf := Shelf{}
|
||||
for _, m := range theCatalogue(t) {
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
for _, p := range CatalogueProblems(shelf) {
|
||||
if strings.Contains(p, "ADR 0287") {
|
||||
t.Errorf("the catalogue breaks ADR 0287: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,11 @@ type Seat struct {
|
||||
// ${contribution:<seat>:<kind>}. Compiled, never stored: like the protocol, it is the mesh's
|
||||
// definition of the role, and the store's rows carry no column for it.
|
||||
Receives []Receivable
|
||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287): a holder declares
|
||||
// every one, and a module claiming no seat that names a path may not declare it. Only a node seat
|
||||
// names paths. Compiled, never stored, like Receives: adding or taking away a path is a decision,
|
||||
// recorded, and the store's rows carry no column for it.
|
||||
Paths []SeatPath
|
||||
// Decision is the record that made it a seat.
|
||||
Decision string
|
||||
}
|
||||
@@ -178,6 +183,8 @@ var defaultSeats = append([]Seat{
|
||||
// holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an
|
||||
// alias on a mesh that knew it.
|
||||
{Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223",
|
||||
// The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287).
|
||||
Paths: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}},
|
||||
Serves: []Verb{
|
||||
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
|
||||
"the operator's, or the block of the module or tool that writes it.",
|
||||
@@ -299,17 +306,25 @@ var defaultSeats = append([]Seat{
|
||||
// resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers
|
||||
// the manager knows for it — the same for every holder, so a question about a machine's names is asked
|
||||
// the same way whatever manages its network. Optional until both holders serve them.
|
||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs()},
|
||||
//
|
||||
// It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287,
|
||||
// ADR 0247).
|
||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(),
|
||||
Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: ResolverSeat}}},
|
||||
// The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a
|
||||
// VPN client whose domains must go to its own servers while every other name goes to the mesh's.
|
||||
// Where it is held, its holder writes the resolver file and the uplink's holder steps back from it
|
||||
// (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link.
|
||||
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs()},
|
||||
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(),
|
||||
Paths: []SeatPath{{Path: ResolverFile}}},
|
||||
// A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server
|
||||
// exports a machine's folders to the private network and provides each as `nfs-share`; the holder of
|
||||
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
||||
{Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
|
||||
{Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
|
||||
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
||||
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
||||
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
||||
},
|
||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
||||
graphicalSessionSeats()...)
|
||||
@@ -361,10 +376,11 @@ func UseSeats(s []Seat) {
|
||||
row.Accepts, row.Emits, row.Serves = d.Accepts, d.Emits, d.Serves
|
||||
}
|
||||
}
|
||||
// What a seat receives and whether it is replicated are never stored (novox/hq ADR 0212, ADR
|
||||
// 0223), so they are always the compiled ones.
|
||||
// What a seat receives, whether it is replicated and the paths it names are never stored
|
||||
// (novox/hq ADR 0212, ADR 0223, ADR 0287), so they are always the compiled ones.
|
||||
if d, known := byName[row.Name]; known {
|
||||
row.Receives = d.Receives
|
||||
row.Paths = d.Paths
|
||||
row.Replicated = d.Replicated
|
||||
row.Serves = optionalAsCompiled(row.Serves, d.Serves)
|
||||
}
|
||||
@@ -538,6 +554,14 @@ func CanHold(m Manifest, seat Seat) error {
|
||||
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
||||
}
|
||||
// **A holder owns what the role owns** (novox/hq ADR 0287): every path the seat names, declared as
|
||||
// a resource or as a file the mesh renders for it, so which module owns /etc/hosts on a machine is
|
||||
// answered by the seat's definition and not by reading every manifest.
|
||||
if missing := missingPaths(m, seat.Paths); len(missing) > 0 {
|
||||
return fmt.Errorf("%s claims %s but does not declare %s, which that seat's holder owns (novox/hq "+
|
||||
"ADR 0287) — a holder declares every path its seat names, as a resource or as a file the mesh "+
|
||||
"renders for it", m.Module, seat.Name, strings.Join(missing, ", "))
|
||||
}
|
||||
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
|
||||
// does not answer what the role promises is every caller's timeout, found at registration and
|
||||
// at handover instead, naming the verbs rather than the fact that something is missing.
|
||||
|
||||
@@ -67,6 +67,10 @@ type SeatDeclaration struct {
|
||||
// Records are state buckets of the declaring module that each user reads under its own name — the
|
||||
// keys `<user>.…` and no other (ADR 0259 §3).
|
||||
Records []string `json:"records,omitempty"`
|
||||
// Paths are the paths on its machine the seat's holder owns (novox/hq ADR 0287), under the rules the
|
||||
// mesh's own seats name theirs by: a node seat only, each absolute and literal, and none another seat
|
||||
// names. A module's seat never steps back.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
}
|
||||
|
||||
// KindedBenches are the seats that may be kinded (novox/hq ADR 0234 §2): `channel` sends to the operator,
|
||||
@@ -163,6 +167,7 @@ func declaredSeatProblems(m Manifest) []string {
|
||||
}
|
||||
}
|
||||
problems = append(problems, trafficProblems(m, s)...)
|
||||
problems = append(problems, declaredSeatPathProblems(m, s)...)
|
||||
}
|
||||
|
||||
for _, u := range m.Uses {
|
||||
@@ -271,6 +276,10 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses.
|
||||
named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy)
|
||||
problems = append(problems, pathProblems...)
|
||||
|
||||
// Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2).
|
||||
kindsTaken := map[string]string{}
|
||||
|
||||
@@ -327,7 +336,15 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
"%s claims %s but does not serve %s, which that seat's protocol promises",
|
||||
module, c.Name, strings.Join(missing, ", ")))
|
||||
}
|
||||
// And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287).
|
||||
if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)",
|
||||
module, c.Name, strings.Join(missing, ", ")))
|
||||
}
|
||||
}
|
||||
// Only a claimant declares a seat's path (novox/hq ADR 0287).
|
||||
problems = append(problems, unclaimedSeatPaths(m, named)...)
|
||||
}
|
||||
// **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or
|
||||
// speaking for a kind that proves its sender, is never carried by a machine's runtime.
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// Forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
||||
@@ -59,8 +59,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 40 {
|
||||
t.Errorf("the mesh defines %d seats rather than 40; the set is closed, so a change here is "+
|
||||
if len(Seats()) != 41 {
|
||||
t.Errorf("the mesh defines %d seats rather than 41; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -337,6 +337,10 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
}
|
||||
|
||||
lands := settingKeysUsedBy(m)
|
||||
// The libraries are the controller's own mesh-wide setting (novox/hq ADR 0328 §1).
|
||||
if m.Module == LibrariesModule {
|
||||
lands[LibrariesSetting] = true
|
||||
}
|
||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
||||
for key := range m.Settings {
|
||||
@@ -509,6 +513,34 @@ 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 {
|
||||
// The libraries, on the controller's own layer (novox/hq ADR 0328 §1): each well formed, and only mesh-wide.
|
||||
if m.Module == LibrariesModule {
|
||||
for _, l := range layers {
|
||||
v, has := l.Values[LibrariesSetting]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if l.From != MeshWideLayer && !l.Default {
|
||||
return fmt.Errorf("%s is the mesh's, set on the whole mesh and never on one machine (%s §1)",
|
||||
LibrariesSetting, ADR0328)
|
||||
}
|
||||
if _, err := ParseLibraries(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := settingsHoldOneLine(m.Module, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -69,6 +79,10 @@ func TerminalKeys(m Manifest) []string {
|
||||
}
|
||||
delete(keys, PlacesSetting)
|
||||
delete(keys, AccessesSetting)
|
||||
// The libraries say where shared data is and who may write it (novox/hq ADR 0328 §1): the operator's alone.
|
||||
if m.Module == LibrariesModule {
|
||||
keys[LibrariesSetting] = true
|
||||
}
|
||||
rest := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
rest = append(rest, k)
|
||||
|
||||
@@ -56,9 +56,15 @@ 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"}}},
|
||||
// A joining module's identity, wherever its resources take values; a library's host path, only in a container's
|
||||
// volumes, whose source libraryProblems holds it to (novox/hq ADR 0328 §5).
|
||||
{namespace: "identity", patterns: []*regexp.Regexp{identityRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file", "run", "command", "user", "name", "unit"}}},
|
||||
{namespace: "library", patterns: []*regexp.Regexp{libraryRef}, fields: map[string][]string{"container": {"volumes"}},
|
||||
why: "a library's path is a host path, filled only in a container's volume source; the process reads the " +
|
||||
"container side its volume gives (novox/hq ADR 0328 §5)"},
|
||||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
||||
why: "a secret is never filled into an environment variable or any other field: put it in a file the " +
|
||||
"module declares and mount that (novox/hq ADR 0086)"},
|
||||
|
||||
@@ -33,7 +33,9 @@ func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) {
|
||||
if !slices.Equal(names, []string{"resolvers", "links"}) {
|
||||
t.Fatalf("node-uplink promises %v", names)
|
||||
}
|
||||
today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}}
|
||||
// The resolver file is the seat's path (novox/hq ADR 0287), which every holder of today renders.
|
||||
today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}}
|
||||
if err := CanHold(today, seat); err != nil {
|
||||
t.Fatalf("a holder that serves no verb yet is refused: %v", err)
|
||||
}
|
||||
@@ -55,7 +57,8 @@ func TestTheUplinkVerbsReadBackFromTheRowStayOptional(t *testing.T) {
|
||||
// The row as seeding stores it: both verbs, the mark not a column.
|
||||
UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "resolvers"}, {Name: "links"}}}})
|
||||
seat := uplinkSeat(t)
|
||||
holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}}
|
||||
holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}}
|
||||
if err := CanHold(holder, seat); err != nil {
|
||||
t.Fatalf("read back from the row, the verbs are required: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An archive pinned by URL and sha256 (novox/hq ADR 0316 §2, §6): the vendor's file, mirrored by the build
|
||||
// seat, never a copy in the repository.
|
||||
|
||||
const pinnedSum = "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2"
|
||||
|
||||
func upstreamManifest(artifact string) string {
|
||||
return `{"module":"a","version":"1","build":{"artifacts":[` + artifact + `]}}`
|
||||
}
|
||||
|
||||
func TestAnArchivePinnedByURLAndSha256IsAccepted(t *testing.T) {
|
||||
for _, a := range []string{
|
||||
`{"name":"archive","kind":"archive","url":"https://vendor.example.org/release-1.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1.0","within":"release"}`,
|
||||
`{"name":"addon","kind":"archive","url":"https://vendor.example.org/addon.xpi","sha256":"` + pinnedSum +
|
||||
`","version":"1.5.6.1","file":"addon.xpi"}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(upstreamManifest(a))); err != nil {
|
||||
t.Errorf("%s was refused: %v", a, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFileThatIsNotPinnedIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// No digest: what the address serves changes under the mesh.
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","version":"1"}`: "without a pinned sha256",
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"ABC","version":"1"}`: "without a pinned sha256",
|
||||
// No version: a person reading the pin cannot tell which release.
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `"}`: "says no version",
|
||||
// Not https.
|
||||
`{"name":"x","kind":"archive","url":"http://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `","version":"1"}`: "over https",
|
||||
// Both a directory of the repository and a URL.
|
||||
`{"name":"x","kind":"archive","from":"files","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1"}`: "one or the other",
|
||||
// A pin without a URL.
|
||||
`{"name":"x","kind":"archive","from":"files","sha256":"` + pinnedSum + `"}`: "no url",
|
||||
// Only an archive is mirrored from a file.
|
||||
`{"name":"x","kind":"image","from":"Dockerfile","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1"}`: "only an archive",
|
||||
// A file name that is a path, a directory within that leaves the archive, and both at once.
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/a.xpi","sha256":"` + pinnedSum +
|
||||
`","version":"1","file":"../a.xpi"}`: "not a plain relative file name",
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1","within":"../up"}`: "not a plain relative directory",
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1","within":"r","file":"r.tar.xz"}`: "a file is kept whole",
|
||||
}
|
||||
for artifact, want := range cases {
|
||||
_, err := ParseManifest([]byte(upstreamManifest(artifact)))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", artifact)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s was refused for another reason than %q: %v", artifact, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFilesVendorIsNamedOnPurposeAndAnInstallationsIsRefused(t *testing.T) {
|
||||
// The vendor's host is judged like any name, and passes through names-on-purpose; a version of four dotted
|
||||
// numbers is not an address, and a name in it is still judged.
|
||||
m, err := ParseManifest([]byte(upstreamManifest(`{"name":"owl","kind":"archive","url":"https://vendor.be/owl/owl.xpi","sha256":"` +
|
||||
pinnedSum + `","version":"1.5.6.1","file":"owl.xpi","names-on-purpose":{"vendor.be":"Owl's vendor publishes it there"}}`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := InstallationProblems(m); len(problems) != 0 {
|
||||
t.Fatalf("the declared vendor and the version were taken for an installation: %v", problems)
|
||||
}
|
||||
m.Build.Artifacts[0].NamesOnPurpose = nil
|
||||
if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "vendor.be") {
|
||||
t.Fatalf("an undeclared vendor host is judged: %v", problems)
|
||||
}
|
||||
m.Build.Artifacts[0].Version = "git.novox.be 10.0.0.1"
|
||||
if problems := InstallationProblems(m); len(problems) != 2 {
|
||||
t.Fatalf("a name in the version is judged: %v", problems)
|
||||
}
|
||||
m.Build.Artifacts[0].URL, m.Build.Artifacts[0].Version = "https://mail.novox.be/owl.xpi", "1"
|
||||
if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "mail.novox.be") {
|
||||
t.Fatalf("an installation's host in the url is judged: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFileFromAnAddressOrAReservedNameIsRefused(t *testing.T) {
|
||||
for _, u := range []string{"https://git.novox.internal/r.tar.xz", "https://10.1.2.3/r.tar.xz",
|
||||
"https://203.0.113.9/r.tar.xz", "https://[2001:db8::1]/r.tar.xz", "https://nas.lan/r.tar.xz", "https://vendor/r.tar.xz"} {
|
||||
_, err := ParseManifest([]byte(upstreamManifest(`{"name":"x","kind":"archive","url":"` + u + `","sha256":"` +
|
||||
pinnedSum + `","version":"1"}`)))
|
||||
if err == nil || !strings.Contains(err.Error(), "published under a public name") {
|
||||
t.Errorf("%s: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,9 +116,15 @@ var ControllerVerbs = []Verb{
|
||||
"limit": "how many builds to list (default 20); not with log",
|
||||
}, nil)},
|
||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id; " +
|
||||
"or, given wait, one plan followed until it moves.",
|
||||
Input: schema(map[string]string{
|
||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
||||
"wait": "a plan's id: wait until it moves, then answer it as data — the plan, and what ended the wait " +
|
||||
"(moved, tier, done, or bound); ask again to follow it further (novox/hq issue 229)",
|
||||
"until": "with wait: moved (any change, the default), tier (it reaches another tier) or done (it ends)",
|
||||
"bound": "with wait: how long to wait at most, as 15s or seconds — 20s, the default, is the longest, " +
|
||||
"inside what a call is answered within",
|
||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
||||
"close": "a plan's id: close a plan that will not move again, as failed by hand (novox/hq issue 254)",
|
||||
"retry": "a failed plan's id: ask its failed builds again under new ids, and carry the plan on from that tier (novox/hq ADR 0219)",
|
||||
@@ -333,7 +339,9 @@ var ControllerVerbs = []Verb{
|
||||
// The build queue, controlled by hand (novox/hq ADR 0219). Every verb that drops an ask leaves a
|
||||
// failed outcome for it, so a plan waiting on it fails visibly instead of hanging.
|
||||
{Name: "queue", Description: "Every ask in the build queue: waiting, in flight (on which machine, for how long), " +
|
||||
"and dead (handed out as often as allowed and never settled) — each with its id, repository, path, ref and when it was asked.",
|
||||
"and dead (handed out as often as allowed and never settled) — each with its id, repository, path, ref and when it was asked. " +
|
||||
"Answers the table as text and, as data, each ask's state, kind (check or build), repository, commit, machine, since " +
|
||||
"and position among the waiting (novox/hq issue 455).",
|
||||
Input: schema(nil, nil)},
|
||||
{Name: "cancel", Description: "Drop one waiting or dead ask from the build queue; its outcome is recorded failed, " +
|
||||
"cancelled by hand, and a plan that asked for it fails. One in flight is refused: `kill` ends it where it runs.",
|
||||
@@ -490,6 +498,13 @@ var ControllerVerbs = []Verb{
|
||||
"why": "with consumer or older-than: why — required, and recorded in the hand-act log",
|
||||
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " +
|
||||
"node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " +
|
||||
"each: placed, would be refused and why (the link and who could replace it), refused already by the placement " +
|
||||
"guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name",
|
||||
}, []string{"node"})},
|
||||
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
|
||||
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
|
||||
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
|
||||
|
||||
@@ -117,6 +117,10 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
||||
}
|
||||
}
|
||||
}
|
||||
// A module never takes a defined library's name: mesh-<module> and mesh-<library> never collide (ADR 0328 §4).
|
||||
if err := i.LibraryNamed(ctx, m.Module); err != nil {
|
||||
return fmt.Errorf("%s is not registered: %w", m.Module, err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -929,6 +933,10 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
|
||||
// the mesh-wide layer under it when the layer is one node's, and refuses the first thing that
|
||||
// cannot work (ADR 0163, rule 6). The same judgement composition makes; what passes here composes.
|
||||
func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||
// The libraries the controller's own layer defines (novox/hq ADR 0328 §1, §4, §13).
|
||||
if err := i.judgeLibraries(ctx, nodeName, module, values); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := i.declared(ctx, module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||
@@ -958,7 +966,38 @@ 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 {
|
||||
follows, ferr := i.followsABranch(ctx, module)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
if !follows {
|
||||
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w; and %s follows no branch "+
|
||||
"(it was registered by hand), so no build of it can be judged ahead of what is registered", module,
|
||||
where, from, err, module)
|
||||
}
|
||||
}
|
||||
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 joins carries no user or group number or owner in a setting (novox/hq ADR 0328 §5).
|
||||
if err := catalogue.JoiningSettingProblem(m, layers[len(layers)-1:]); err != nil {
|
||||
return fmt.Errorf("refused: %w", err)
|
||||
}
|
||||
// And a key that reaches nothing, refused here where somebody can still fix the spelling:
|
||||
// stored, it would be a setting somebody believes they made.
|
||||
@@ -969,6 +1008,40 @@ func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// followsABranch says a module's record names the branch it follows.
|
||||
func (i *Inventory) followsABranch(ctx context.Context, module string) (bool, error) {
|
||||
var ref string
|
||||
err := i.store.Pool().QueryRow(ctx, `select coalesce(ref, '') from module where name = $1`, module).Scan(&ref)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return ref != "", err
|
||||
}
|
||||
|
||||
// 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. Only a build of that very branch: never one of another branch or of none. False when no
|
||||
// such build is kept, and for a module whose record follows no branch (registered by hand).
|
||||
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, '') <> '' and 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.
|
||||
|
||||
@@ -38,13 +38,22 @@ type ResourceHealth struct {
|
||||
Waits []Wait `json:"waits,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.
|
||||
// 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".
|
||||
Reason string `json:"reason,omitempty"`
|
||||
What string `json:"what"`
|
||||
}
|
||||
|
||||
// NodeHealth is a machine's newest statement, as kept.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user