Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36008e0642 | ||
|
|
155819f307 | ||
|
|
eb5f9d2f53 | ||
|
|
0f58602c74 | ||
|
|
c3ae3f3e09 | ||
|
|
2a9697cf70 | ||
|
|
e4d2868679 | ||
|
|
fe857ba081 | ||
|
|
b9fc09c375 | ||
|
|
ec7b8bcd58 | ||
|
|
cec797e996 | ||
|
|
0e0ba93f6c | ||
|
|
0f1e1b09fd | ||
|
|
1b502a37e0 | ||
|
|
522f2253e7 | ||
|
|
2073bfe2e6 | ||
|
|
f5824b31c6 | ||
|
|
d059311c0f | ||
|
|
1a28cb3357 | ||
|
|
758537dd4e | ||
|
|
add807f034 | ||
|
|
8ca4b04321 | ||
|
|
8170fc58a3 | ||
|
|
5d7d8ee2d6 | ||
|
|
81e5458cbf | ||
|
|
fb74e24c9e |
@@ -65,6 +65,13 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
}
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here, in the
|
||||
// catalogue-wide test, and at registration, which refuses in the same words.
|
||||
if wrong := catalogue.TrustProblems(m); len(wrong) > 0 {
|
||||
for _, p := range wrong {
|
||||
fmt.Fprintf(out, "%s: %s\n", path, p)
|
||||
}
|
||||
failed += len(wrong)
|
||||
faulted[m.Module] = true
|
||||
}
|
||||
if named := catalogue.InstallationProblems(m); len(named) > 0 {
|
||||
for _, p := range named {
|
||||
fmt.Fprintf(out, "%s: %s\n", path, p)
|
||||
@@ -130,6 +137,13 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// **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
|
||||
for _, name := range names {
|
||||
unsaid += len(catalogue.UnsaidTrust(shelf[name]))
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
m := shelf[name]
|
||||
if faulted[name] {
|
||||
@@ -171,6 +185,11 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
if len(checks) > 0 {
|
||||
fmt.Fprintf(out, ", ready: %s", strings.Join(checks, "; "))
|
||||
}
|
||||
if missing := catalogue.UnsaidTrust(m); len(missing) > 0 {
|
||||
fmt.Fprintf(out, "; WARNING: %s ask(s) for a setting and do(es) not say whether it is trusted, so it counts as "+
|
||||
"trusted: set at the terminal alone; say %q false where nothing trusts it (novox/hq issue 339)",
|
||||
strings.Join(missing, ", "), catalogue.TrustedField)
|
||||
}
|
||||
if missing := catalogue.Undeclared(m); len(missing) > 0 {
|
||||
fmt.Fprintf(out, "; WARNING: %s stay(s) up and say(s) not how it is ready — judged by liveness alone, "+
|
||||
"refused from %s (ADR 0240 rule 8)", strings.Join(missing, ", "), catalogue.HealthRequiredFrom.Format("2006-01-02"))
|
||||
@@ -179,6 +198,7 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
}
|
||||
// The count the catalogue keeps (ADR 0240 rule 8), in a line its merge check reads.
|
||||
fmt.Fprintf(out, "%s %d\n", UndeclaredHealthLine, undeclared)
|
||||
fmt.Fprintf(out, "%s %d\n", UnsaidTrustLine, unsaid)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths))
|
||||
}
|
||||
@@ -193,6 +213,10 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||
// `health` in, over the manifests given: the catalogue's merge check compares it with the number it keeps.
|
||||
const UndeclaredHealthLine = "long-running resources without health:"
|
||||
|
||||
// UnsaidTrustLine starts the line `module check` says the count of files that ask for a setting and do not say
|
||||
// whether it is trusted, and so count as trusted (novox/hq issue 339).
|
||||
const UnsaidTrustLine = "files asking for a setting without saying whether it is trusted:"
|
||||
|
||||
// checkNow is the clock `module check` judges the date by; a test sets it.
|
||||
var checkNow = time.Now
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestASilenceThroughTheVerbHoldsAndTheConditionStaysOpen(t *testing.T) {
|
||||
func TestUnreadableConditionsAreNotAWellMesh(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
_, store := withConditionsInMemory(t)
|
||||
store.Fail = errors.New("the bus is away")
|
||||
store.SetFail(errors.New("the bus is away"))
|
||||
asked, err := theThreeQuestions(t.Context(), open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -98,7 +98,7 @@ func TestUnreadableConditionsAreNotAWellMesh(t *testing.T) {
|
||||
if !strings.HasPrefix(said, "the open conditions could NOT be read") || strings.Contains(said, "no open conditions") {
|
||||
t.Fatalf("%s", said)
|
||||
}
|
||||
store.Fail = nil
|
||||
store.SetFail(nil)
|
||||
asked, _ = theThreeQuestions(t.Context(), open)
|
||||
said = printed(t, func() error { return printStatus(asked) })
|
||||
if asked.well() && !strings.Contains(said, "no open conditions;") {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A directory the node-engine uses as found (novox/hq issue 339) waits for a person to hand it over at the
|
||||
// machine. Found before this send, it is no fault of the build: the gate passes with the wait carried, so an
|
||||
// urgent fix of that module still goes through. Found by this send, the send brought it, and the gate holds.
|
||||
func foundDirectory(module string, since time.Time) inventory.ResourceHealth {
|
||||
return inventory.ResourceHealth{Module: module, Resource: module + ".data", Kind: link.KindDirectory,
|
||||
Target: "/srv/" + module, State: link.StateUnhealthy, Since: since,
|
||||
Reason: link.ReasonUsedAsFound + " owned by 1000:1000, mode 700, as found; root, mode 755 was declared and " +
|
||||
"not given it — `mesh-host hand-over` at the machine hands it to the mesh"}
|
||||
}
|
||||
|
||||
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
|
||||
now := time.Now()
|
||||
sent := now.Add(-time.Minute)
|
||||
f := gateFacts{now: now, health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{foundDirectory("notes", sent.Add(-24*time.Hour))}}}}
|
||||
h, why := moduleHealthWord("notes", "laptop", sent, f)
|
||||
if h != healthPerson || !strings.Contains(why, "notes.data") || !strings.Contains(why, "hand-over") {
|
||||
t.Fatalf("a directory found before the send reads %v %q; want a wait for a person", h, why)
|
||||
}
|
||||
// Found by this very send: the send brought it, and it is not passed.
|
||||
f.health["laptop"] = inventory.NodeHealth{Node: "laptop", HeardAt: now,
|
||||
Resources: []inventory.ResourceHealth{foundDirectory("notes", sent.Add(time.Second))}}
|
||||
if h, why := moduleHealthWord("notes", "laptop", sent, f); h != healthNotYet {
|
||||
t.Fatalf("a directory this send found reads %v %q; want not yet", h, why)
|
||||
}
|
||||
// A container down beside the old wait is a fault, as before.
|
||||
f.health["laptop"] = inventory.NodeHealth{Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{
|
||||
foundDirectory("notes", sent.Add(-time.Hour)),
|
||||
{Module: "notes", Resource: "notes.web", Kind: "container", Target: "notes", State: link.StateUnhealthy, Reason: "down"}}}
|
||||
if h, why := moduleHealthWord("notes", "laptop", sent, f); h != healthNotYet {
|
||||
t.Fatalf("a container down beside the wait reads %v %q; want not yet", h, why)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole walk: a module whose directory was used as found long before still gets its fix to every machine,
|
||||
// its pass kept and the wait said; a directory this very send found holds it and puts it back.
|
||||
func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
found time.Duration // when the directory was found, against now
|
||||
passes bool
|
||||
}{
|
||||
{"found a day before the send", -24 * time.Hour, true},
|
||||
{"found by this send", time.Hour, false},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
b := aBacklog(t)
|
||||
ctx := t.Context()
|
||||
inv := b.open.inventory
|
||||
releaseHeard = func(context.Context, *stores) (map[string]bool, error) {
|
||||
return map[string]bool{"anchor": true, "laptop": true}, nil
|
||||
}
|
||||
backlogFacts := gatherGateFacts
|
||||
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
||||
f, err := backlogFacts(ctx, open, component)
|
||||
// The used-as-found condition, raised after the send (its second statement): its own kind, never a
|
||||
// fault the gate reads as the build's.
|
||||
f.judged, f.openErr = true, nil
|
||||
f.open = append(f.open, conditions.Condition{Key: usedAsFoundKey("app", "anchor"), Kind: kindUsedAsFound,
|
||||
Subject: conditions.Subject{Scope: conditions.ScopeModule, ID: "app.anchor", Machine: "anchor"},
|
||||
Raised: time.Now()})
|
||||
f.health = map[string]inventory.NodeHealth{}
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
f.health[n] = inventory.NodeHealth{Node: n, HeardAt: time.Now(), Resources: []inventory.ResourceHealth{
|
||||
{Module: "app", Resource: "app.web", Kind: "container", Target: "app", State: link.StateHealthy},
|
||||
foundDirectory("app", time.Now().Add(c.found)),
|
||||
{Module: "late", Resource: "late.web", Kind: "container", Target: "late", State: link.StateHealthy}}}
|
||||
}
|
||||
return f, err
|
||||
}
|
||||
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound
|
||||
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound })
|
||||
gateSettle, gateEvery, gateBound = 0, 0, 300*time.Millisecond
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
advancePlans(ctx, b.open)
|
||||
if p := b.release(t); p.State != inventory.PlanRolling {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
p := b.release(t)
|
||||
v, found, err := inv.GateOf(ctx, "build-app-c2")
|
||||
if c.passes {
|
||||
if p.State != inventory.PlanDone || err != nil || !found || v.Verdict != inventory.GatePassed {
|
||||
t.Fatalf("the walk is %s (%s); app's verdict %+v: want the fix through", p.State, p.Note, v)
|
||||
}
|
||||
if !strings.Contains(v.Why+p.Note, "hand it over") {
|
||||
t.Errorf("the wait is not carried: verdict %q, walk %q", v.Why, p.Note)
|
||||
}
|
||||
return
|
||||
}
|
||||
if p.State == inventory.PlanDone {
|
||||
t.Fatalf("a directory this send found let the walk through: %s", p.Note)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The condition says the wait in its own kind: the operator's, never urgent, and cleared once handed over.
|
||||
func TestADirectoryUsedAsFoundIsItsOwnCondition(t *testing.T) {
|
||||
k, _ := withConditionsInMemory(t)
|
||||
ctx := t.Context()
|
||||
rs := map[string][]inventory.ResourceHealth{"notes": {foundDirectory("notes", time.Now().Add(-time.Hour))}}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": i + 1}, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
open, _ := k.Open(ctx)
|
||||
var got *conditions.Condition
|
||||
for i, c := range open {
|
||||
if c.Key == usedAsFoundKey("notes", "laptop") {
|
||||
got = &open[i]
|
||||
}
|
||||
if c.Kind == kindModuleUnhealthy {
|
||||
t.Fatalf("raised as a fault: %+v", c)
|
||||
}
|
||||
}
|
||||
if got == nil || got.Resolver != conditions.ResolverOperator || got.Severity == conditions.Urgent ||
|
||||
!strings.Contains(got.Summary, "notes.data") {
|
||||
t.Fatalf("the condition: %+v", got)
|
||||
}
|
||||
// Long open is still not urgent: only a person can hand it over, and nothing is broken by the wait.
|
||||
if err := judgeModuleHealth(ctx, nil, k, "laptop", rs, map[string]int{"notes": 3}, time.Now().Add(48*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open, _ = k.Open(ctx)
|
||||
for _, c := range open {
|
||||
if c.Key == usedAsFoundKey("notes", "laptop") && c.Severity == conditions.Urgent {
|
||||
t.Fatal("a directory used as found became urgent")
|
||||
}
|
||||
}
|
||||
if err := judgeModuleHealth(ctx, nil, k, "laptop", map[string][]inventory.ResourceHealth{}, nil, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
open, _ = k.Open(ctx)
|
||||
for _, c := range open {
|
||||
if c.Key == usedAsFoundKey("notes", "laptop") {
|
||||
t.Fatal("not cleared once handed over")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,9 +216,10 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
||||
firstLine(f.openErr.Error())
|
||||
}
|
||||
for _, c := range f.open {
|
||||
// A wait for a person's new login is the module's reading, not a fault raised since the send: the
|
||||
// gate reads it from the statement below (ADR 0254).
|
||||
if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded {
|
||||
// A wait for a person's new login, or for a directory used as found to be handed over, is the module's
|
||||
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
|
||||
// novox/hq issue 339).
|
||||
if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
|
||||
continue
|
||||
}
|
||||
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
|
||||
@@ -329,7 +330,8 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact
|
||||
for _, c := range f.open {
|
||||
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
|
||||
slices.Contains(c.Subject.Also, machine))
|
||||
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) {
|
||||
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
|
||||
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindUsedAsFound {
|
||||
kept = append(kept, c)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -45,3 +45,28 @@ func TestModuleCheckCountsTheUndeclaredAndRefusesThemFromTheDate(t *testing.T) {
|
||||
t.Errorf("the refusal does not name the resource:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A file that asks for a setting without saying whether it is trusted counts as trusted (novox/hq issue 339):
|
||||
// `module check` lists and counts it, and never refuses it — there is nothing unsafe to refuse.
|
||||
func TestModuleCheckListsUnmarkedFilesAndNeverRefusesThem(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "module.json")
|
||||
os.WriteFile(path, []byte(`{"module":"power","resources":[
|
||||
{"id":"logind","type":"file","path":"/etc/systemd/logind.conf.d/power.conf","mode":"0644","trusted":true,
|
||||
"content":"HandleLidSwitch=${setting:lid}\n"},
|
||||
{"id":"note","type":"file","path":"/var/lib/power/note","mode":"0644","content":"${setting:greeting}\n"}]}`), 0o600)
|
||||
defer func() { checkNow = time.Now }()
|
||||
for _, at := range []time.Time{time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), time.Date(2036, 1, 1, 0, 0, 0, 0, time.UTC)} {
|
||||
checkNow = func() time.Time { return at }
|
||||
var out bytes.Buffer
|
||||
if err := moduleCheck([]string{path}, &out); err != nil {
|
||||
t.Fatalf("refused at %v: %v\n%s", at, err, out.String())
|
||||
}
|
||||
for _, want := range []string{"note ask(s) for a setting and do(es) not say whether it is trusted, so it counts as trusted",
|
||||
UnsaidTrustLine + " 1"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("the check does not say %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,3 +92,26 @@ func TestHoldingNeedsAnAnswer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The control-node withholds the login shell's `execute` (novox/hq ADR 0268): its holder there serves
|
||||
// nothing on the seat, and must not be judged silent for it, as the store's rows read it back.
|
||||
func TestALoginShellWithholdingExecuteIsNotSilent(t *testing.T) {
|
||||
defer catalogue.UseSeats(catalogue.DefaultSeats())
|
||||
var rows []catalogue.Seat
|
||||
for _, s := range catalogue.DefaultSeats() {
|
||||
stored := s
|
||||
stored.Serves = nil
|
||||
for _, v := range s.Serves {
|
||||
v.Optional = false // the store never keeps the mark
|
||||
stored.Serves = append(stored.Serves, v)
|
||||
}
|
||||
rows = append(rows, stored)
|
||||
}
|
||||
catalogue.UseSeats(rows)
|
||||
recorded := []catalogue.Held{{Claim: catalogue.LoginShellSeat, Scope: catalogue.ScopeNode, Node: "anchor", Module: "zsh"}}
|
||||
expected := holdersToHear(catalogue.SeatsWithAProtocol(), recorded, nil, map[string]bool{"anchor": true}, nil, time.Now())
|
||||
if _, asked := expected[catalogue.LoginShellSeat]; asked {
|
||||
t.Fatalf("the login shell's holder is expected to answer, so withholding execute would be said silent: %v",
|
||||
expected[catalogue.LoginShellSeat])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,8 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
|
||||
}
|
||||
standing := map[string]conditions.Condition{}
|
||||
for _, c := range open {
|
||||
if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded) && c.Subject.Machine == node {
|
||||
if (c.Kind == kindModuleUnhealthy || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound) &&
|
||||
c.Subject.Machine == node {
|
||||
standing[c.Key] = c
|
||||
}
|
||||
}
|
||||
@@ -158,6 +159,21 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
|
||||
heldOn := map[string]string{}
|
||||
providers := map[catalogue.Chosen]bool{}
|
||||
for _, m := range modules {
|
||||
// **A directory used as found is said as that** (novox/hq issue 339): the operator's to hand over at the
|
||||
// machine, never urgent — nothing is broken by the wait that a person was not told of — and its own kind,
|
||||
// so the gate never reads it as a fault of the build that happened to be sent beside it.
|
||||
if said, waits := foundWait(m, node, unhealthy[m]); waits {
|
||||
o := usedAsFoundObservation(m, node, said, unhealthy[m])
|
||||
seen[o.Key()] = true
|
||||
became[m] = kindUsedAsFound
|
||||
if _, isOpen := standing[o.Key()]; streaks[m] < moduleUnhealthyAfter && !isOpen {
|
||||
continue
|
||||
}
|
||||
if _, err := k.Observe(ctx, o); err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
// **A wait for a person's new login is said as that** (novox/hq ADR 0254): one plain sentence to the
|
||||
// operator, never urgent, cleared on the first statement that no longer says it.
|
||||
if said, waits := personWait(m, node, unhealthy[m]); waits {
|
||||
@@ -209,6 +225,9 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
|
||||
if c.Kind == kindReloginNeeded {
|
||||
why = fmt.Sprintf("%s says %s no longer waits for a new login", node, module)
|
||||
}
|
||||
if c.Kind == kindUsedAsFound {
|
||||
why = fmt.Sprintf("%s says no directory of %s is used as found any more", node, module)
|
||||
}
|
||||
if on, held := heldOn[key]; held {
|
||||
why = fmt.Sprintf("what %s finds on %s waits on %s, which is unhealthy: held under its condition", module, node, on)
|
||||
}
|
||||
@@ -499,6 +518,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
if waits && !f.groupsAdded[module] {
|
||||
waits = false
|
||||
}
|
||||
var found []string
|
||||
for _, r := range h.Resources {
|
||||
if r.Module != module {
|
||||
continue
|
||||
@@ -506,6 +526,14 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
if waits && r.State == link.StateUnhealthy {
|
||||
continue
|
||||
}
|
||||
// **A directory used as found before this send waits for a person** (novox/hq issue 339): the node-engine
|
||||
// left its owner and mode, and only someone at the machine can hand it over. It is no fault of this
|
||||
// build, so it does not hold the module's walk — an urgent fix still goes through — and the verdict
|
||||
// carries the wait. Found by this very send, the send brought it, and it is judged as unhealthy.
|
||||
if usedAsFound(r) && r.Since.Before(since) {
|
||||
found = append(found, r.Resource)
|
||||
continue
|
||||
}
|
||||
switch r.State {
|
||||
case link.StateHealthy:
|
||||
case link.StateStarting:
|
||||
@@ -521,12 +549,25 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
|
||||
reasonAfter(r.Reason))
|
||||
}
|
||||
}
|
||||
if waits {
|
||||
return healthPerson, wait
|
||||
if waits || len(found) > 0 {
|
||||
var said []string
|
||||
if waits {
|
||||
said = append(said, wait)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for a person to hand it over "+
|
||||
"(`mesh-host hand-over <directory>` at the machine)", machine, module, strings.Join(found, ", ")))
|
||||
}
|
||||
return healthPerson, strings.Join(said, "; ")
|
||||
}
|
||||
return healthGood, ""
|
||||
}
|
||||
|
||||
// usedAsFound is a directory the node-engine states it uses as found (novox/hq issue 339).
|
||||
func usedAsFound(r inventory.ResourceHealth) bool {
|
||||
return r.Kind == link.KindDirectory && r.State == link.StateUnhealthy && strings.HasPrefix(r.Reason, link.ReasonUsedAsFound)
|
||||
}
|
||||
|
||||
func reasonAfter(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
@@ -595,3 +636,49 @@ func addsAccountGroups(from catalogue.Manifest, hadFrom bool, to catalogue.Manif
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// kindUsedAsFound is a module's condition while the node-engine uses one of its directories as found (novox/hq
|
||||
// issue 339): its own kind, the operator's, never urgent, and never read by the gate as a fault of a build.
|
||||
const kindUsedAsFound = "directory-used-as-found"
|
||||
|
||||
// usedAsFoundKey is a module's used-as-found condition on a machine.
|
||||
func usedAsFoundKey(module, node string) string {
|
||||
return conditions.Key(conditions.ScopeModule, module+"."+node, kindUsedAsFound)
|
||||
}
|
||||
|
||||
// foundWait is whether everything unhealthy of a module on a machine is a directory used as found, and that in
|
||||
// one sentence. Anything else unhealthy beside it is judged as a fault, with the directory among its resources.
|
||||
func foundWait(module, node string, rs []inventory.ResourceHealth) (string, bool) {
|
||||
var ids, why []string
|
||||
for _, r := range rs {
|
||||
if r.Module != module || r.State != link.StateUnhealthy {
|
||||
continue
|
||||
}
|
||||
if !usedAsFound(r) {
|
||||
return "", false
|
||||
}
|
||||
ids = append(ids, r.Resource)
|
||||
why = append(why, strings.TrimSpace(strings.TrimPrefix(r.Reason, link.ReasonUsedAsFound)))
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%s on %s uses %s as found: %s", module, node, strings.Join(ids, ", "),
|
||||
strings.Join(why, "; ")), true
|
||||
}
|
||||
|
||||
// usedAsFoundObservation is a module whose directory the node-engine uses as found, in words: the operator's, a
|
||||
// warning however long it stays, its summary naming the directories and their owners; the paths are evidence.
|
||||
func usedAsFoundObservation(module, node, said string, rs []inventory.ResourceHealth) conditions.Observation {
|
||||
o := moduleUnhealthyObservation(module, node, rs)
|
||||
o.Token, o.Kind, o.Resolver, o.Severity, o.Summary = kindUsedAsFound, kindUsedAsFound, conditions.ResolverOperator,
|
||||
conditions.Warning, said
|
||||
o.Headline = fmt.Sprintf("%s waits for a directory on %s", module, node)
|
||||
o.Explanation = fmt.Sprintf("A directory of %s was already on %s, with another owner or mode than %s declares. "+
|
||||
"The mesh left it as it was rather than hand it to an account, so %s may not be able to use it.",
|
||||
module, node, module, module)
|
||||
o.Needs = fmt.Sprintf("on %s, run mesh-host hand-over with the directory's path as root.", node)
|
||||
o.Resolved = fmt.Sprintf("%s's directory on %s is the mesh's", module, node)
|
||||
o.Actions = nil
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -445,6 +445,9 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, values, positionals[0], where); err != nil {
|
||||
return err
|
||||
}
|
||||
added, changed, removed := settingsChange(before, values)
|
||||
if len(removed) > 0 && !*replace {
|
||||
return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; "+
|
||||
@@ -596,6 +599,13 @@ func settingsCommand(ctx context.Context, args []string) error {
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("settings clear <module> [--node <node>]")
|
||||
}
|
||||
before, _, err := inv.Layer(ctx, *node, positionals[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -988,6 +998,9 @@ func whereItComesFrom(repository, ref, commit, path string, self bool) (inventor
|
||||
// definition that got past the check — written elsewhere, or checked by nobody — is refused here
|
||||
// in the same words. A name meant on purpose is declared with its reason and passes.
|
||||
func namesNoInstallation(m catalogue.Manifest) error {
|
||||
if problems := catalogue.TrustProblems(m); len(problems) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(problems, "; "))
|
||||
}
|
||||
named := catalogue.InstallationProblems(m)
|
||||
if len(named) == 0 {
|
||||
return nil
|
||||
@@ -1051,3 +1064,47 @@ func declaresTools(m catalogue.Manifest) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The keys no verb may change are catalogue.TerminalKeys (novox/hq issue 339). `places` says where the
|
||||
// node-engine creates and, as root, owns a module's directories, with an owner the setting names; `accesses` says
|
||||
// which of the machine's paths are mounted into a module's container; a provider's trust anchors say what every
|
||||
// consumer trusts. Set through a verb, any of them lets any caller of the mesh's verbs — an agent among them —
|
||||
// have root hand it a directory, mount one of the machine's into a container it reaches, or have the mesh trust
|
||||
// an authority of its own. They are the operator's, typed at the controller's terminal.
|
||||
|
||||
// throughAVerb says whether this process runs a seat verb's command line: the serving controller names the
|
||||
// verb in the environment of every command it runs for one (runVerb), and a person at the terminal runs none.
|
||||
// Every verb route reaches a command through runVerb — the named verbs and the generic `command` alike — so
|
||||
// this is the one place that knows, whatever line the verb composed.
|
||||
func throughAVerb() (string, bool) {
|
||||
verb := os.Getenv(verbVar)
|
||||
return verb, verb != ""
|
||||
}
|
||||
|
||||
// refuseTerminalSettingsThroughAVerb refuses a layer change through a verb that would add, change or remove
|
||||
// places or accesses; a change that leaves both as they were is not refused.
|
||||
func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inventory, before, after map[string]any,
|
||||
module, where string) error {
|
||||
verb, through := throughAVerb()
|
||||
if !through {
|
||||
return nil
|
||||
}
|
||||
// Judged against the module's definition as the catalogue holds it: what it serves and which of its files
|
||||
// are trusted. A catalogue that cannot be read refuses rather than judging against nothing.
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("which settings of %s are the terminal's cannot be read, so nothing was changed: %w", module, err)
|
||||
}
|
||||
for _, key := range catalogue.TerminalKeys(shelf[module]) {
|
||||
was, _ := json.Marshal(before[key])
|
||||
now, _ := json.Marshal(after[key])
|
||||
if string(was) == string(now) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only, never through a verb (this "+
|
||||
"line came through %q): it says where root creates and owns a module's directories, which of "+
|
||||
"the machine's paths are mounted into its container, or what the mesh's consumers trust, and whoever "+
|
||||
"may call a verb includes agents (novox/hq issue 339). Nothing was changed", key, module, where, verb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,6 +204,14 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
||||
}
|
||||
return reloginWords(orModule(module), machineOr(o, "a machine"), false)
|
||||
}),
|
||||
kindUsedAsFound: worded(func(o conditions.Observation) words {
|
||||
module := ""
|
||||
if o.Scope == conditions.ScopeModule && o.Machine != "" {
|
||||
module = strings.TrimSuffix(o.ID, "."+o.Machine)
|
||||
}
|
||||
w := usedAsFoundObservation(orModule(module), machineOr(o, "a machine"), o.Summary, nil)
|
||||
return words{Headline: w.Headline, Explanation: w.Explanation, Needs: w.Needs, Resolved: w.Resolved}
|
||||
}),
|
||||
kindProviderFailing: worded(func(o conditions.Observation) words {
|
||||
thing, consumer := conditions.ThingWords(o), idPart(o, 2)
|
||||
if consumer == "" {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// **The planner over edges the store derives**, not edges written by hand: modules registered, builds
|
||||
// recorded with what they stood on and which repositories they read, the relation answered by
|
||||
// inventory.Dependencies (dependenciesOf over the records), and the merge planned by reachOfMerge — the
|
||||
// path a real merge takes, short of the bus.
|
||||
//
|
||||
// **The repository rows are CURRENT BEHAVIOUR, documented — not the rule the operator states**
|
||||
// (novox/hq issue 338, and the decision pending on it): a build that read a repository gives its module a
|
||||
// packages edge to every module built from that repository, and mergeCandidates moves it on any merge to
|
||||
// that repository, whatever the files. So a change to C alone, or to a README, moves the module that
|
||||
// packages C's repository. ADR 0238 §3 records exactly that today ("a repository a recipe names"); the
|
||||
// expectations marked 338 change with that decision.
|
||||
func TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday(t *testing.T) {
|
||||
inv := inventory.ForTest(t)
|
||||
ctx := t.Context()
|
||||
asked := time.Now().Add(-time.Hour)
|
||||
register := func(m catalogue.Manifest, repository, path string, against []string, read []inventory.ReadRepository) {
|
||||
t.Helper()
|
||||
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
|
||||
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
|
||||
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Read: read, Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
controllerRead := []inventory.ReadRepository{{Repository: "novox/mesh-controller", Ref: "main"}}
|
||||
agent := catalogue.Manifest{Module: "build-agent", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}}
|
||||
|
||||
// The shape of issue 338.
|
||||
register(catalogue.Manifest{Module: "mesh-controller", Version: "1"}, "novox/mesh-controller", "", nil, nil)
|
||||
register(agent, "novox/mesh-catalog", "modules/build-agent", nil, controllerRead)
|
||||
register(catalogue.Manifest{Module: "route-proxy", Version: "1"}, "novox/mesh-catalog", "modules/route-proxy", nil, controllerRead)
|
||||
register(catalogue.Manifest{Module: "gitea", Version: "1"}, "novox/mesh-catalog", "modules/gitea", nil, nil)
|
||||
// A, B and C in one repository; D built against A's artifact, E declaring B, P packaging the repository.
|
||||
for _, n := range []string{"a", "b", "c"} {
|
||||
register(catalogue.Manifest{Module: n, Version: "1"}, "novox/one", "modules/"+n, nil, nil)
|
||||
}
|
||||
register(catalogue.Manifest{Module: "d", Version: "1"}, "novox/two", "d",
|
||||
[]string{catalogue.ArtifactStoreScheme + "a/runtime@sha256:" + strings.Repeat("0", 64)}, nil)
|
||||
register(catalogue.Manifest{Module: "e", Version: "1", Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "BASE", Module: "b", Artifact: "runtime"}}}}, "novox/two", "e", nil, nil)
|
||||
register(catalogue.Manifest{Module: "p", Version: "1"}, "novox/two", "p", nil,
|
||||
[]inventory.ReadRepository{{Repository: "novox/one", Ref: "main"}})
|
||||
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read, err := inv.ReadRepositories(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edges, err := inv.Dependencies(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The edges the hand-written rows of TestASharedRepositoryMovesWhatPackagesItAsItDoesToday use are
|
||||
// the ones derived here.
|
||||
var shared []inventory.Edge
|
||||
in338 := map[string]bool{"mesh-controller": true, "build-agent": true, "route-proxy": true, "gitea": true}
|
||||
for _, e := range edges {
|
||||
if in338[e.From] && in338[e.To] {
|
||||
shared = append(shared, e)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(shared, sharedRepositoryEdges) {
|
||||
t.Errorf("derived %v\nthe hand-written rows use %v", shared, sharedRepositoryEdges)
|
||||
}
|
||||
// Each kind derived from its record: built against (stands-on), build.on (declared), read (packages).
|
||||
for _, want := range []inventory.Edge{
|
||||
dep("d", inventory.EdgeStandsOn, "a"),
|
||||
dep("e", inventory.EdgeDeclared, "b"),
|
||||
dep("p", inventory.EdgePackages, "a"),
|
||||
dep("p", inventory.EdgePackages, "b"),
|
||||
dep("p", inventory.EdgePackages, "c"),
|
||||
dep("d", inventory.EdgeBuiltBy, "build-agent"),
|
||||
} {
|
||||
found := false
|
||||
for _, e := range edges {
|
||||
found = found || e == want
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no %s %s %s derived: %v", want.From, want.Kind, want.To, edges)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what, repo string
|
||||
paths []string
|
||||
want string
|
||||
issue338 bool
|
||||
}{
|
||||
{"A and B changed, C untouched: D after A, E after B; P packages their repository", "one",
|
||||
[]string{"modules/a/x.go", "modules/b/x.go"}, "a,b,p | d,e", false},
|
||||
{"C alone: C, and P, which packages C's repository", "one",
|
||||
[]string{"modules/c/x.go"}, "c,p", true},
|
||||
{"a README of the repository P packages: P moves, nothing built from it does", "one",
|
||||
[]string{"README.md"}, "p", true},
|
||||
{"the dependent's repository: D alone", "two", []string{"d/main.go"}, "d", false},
|
||||
{"a README of the controller's repository: all three, three tiers", "mesh-controller",
|
||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy", true},
|
||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||
[]string{"modules/build-agent/module.json"}, "build-agent", false},
|
||||
} {
|
||||
_, got := planMerge(t, c.repo, c.paths, entries, read, edges)
|
||||
if got != c.want {
|
||||
tag := ""
|
||||
if c.issue338 {
|
||||
tag = " (current behaviour, issue 338)"
|
||||
}
|
||||
t.Errorf("%s: planned %q, wanted %q%s", c.what, got, c.want, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The delivery planner's rules, as recorded (novox/hq ADR 0162 §1, ADR 0238 §3), held by one table and
|
||||
// one property over reachOfMerge — the planner's one answer to "what does this merge move, and in which
|
||||
// order". A row that fails here on main is a planner that breaks a recorded rule: the row stays, the
|
||||
// expectation is not bent to the code.
|
||||
//
|
||||
// The kinds of edge, as the code reads them (release_plan.go):
|
||||
//
|
||||
// kind widens the plan orders the tiers
|
||||
// stands-on yes yes, after its base is built
|
||||
// declared yes yes, after its base is built
|
||||
// packages yes no, the same tier (a code dependency)
|
||||
// built-by no yes, after the build machine — except for what the build machine stands
|
||||
// on, and for the controller whose worker it binds
|
||||
// worker-of no yes, the build seat's holder after the controller (hq issue 206)
|
||||
|
||||
const (
|
||||
repoOne = "http://forge.internal:20000/novox/one.git"
|
||||
repoTwo = "http://forge.internal:20000/novox/two.git"
|
||||
)
|
||||
|
||||
// dep is one edge of the catalogue's relation: from depends on to, in the way kind says.
|
||||
func dep(from, kind, to string) inventory.Edge {
|
||||
return inventory.Edge{From: from, To: to, Kind: kind}
|
||||
}
|
||||
|
||||
// tiered is a plan's tiers as one line: a tier's modules by comma, tiers by " | ". Empty for no plan.
|
||||
func tiered(tiers [][]string) string {
|
||||
var out []string
|
||||
for _, t := range tiers {
|
||||
out = append(out, strings.Join(t, ","))
|
||||
}
|
||||
return strings.Join(out, " | ")
|
||||
}
|
||||
|
||||
// planMerge is what reachOfMerge plans for a merge of these files into a repository's main: its tiers as
|
||||
// one line, and the files no build reads. It also holds the plan to its own shape: every module it builds
|
||||
// is in exactly one tier.
|
||||
func planMerge(t *testing.T, repo string, paths []string, entries []inventory.Entry,
|
||||
read map[string][]inventory.ReadRepository, edges []inventory.Edge) (mergeReach, string) {
|
||||
t.Helper()
|
||||
m := link.SourceMoved{Owner: "novox", Repo: repo, Base: "main", Commit: "head", Paths: paths}
|
||||
r := reachOfMerge(m, entries, read, edges)
|
||||
seen := map[string]int{}
|
||||
for _, tier := range r.Plan.Tiers {
|
||||
for _, name := range tier {
|
||||
seen[name]++
|
||||
}
|
||||
}
|
||||
for name := range r.Plan.Modules {
|
||||
if seen[name] != 1 {
|
||||
t.Errorf("%s/%v: %s is in %d tiers of %v", repo, paths, name, seen[name], r.Plan.Tiers)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(r.Plan.Modules) {
|
||||
t.Errorf("%s/%v: the tiers %v hold modules the plan does not (%d)", repo, paths, r.Plan.Tiers, len(r.Plan.Modules))
|
||||
}
|
||||
return r, tiered(r.Plan.Tiers)
|
||||
}
|
||||
|
||||
// **A merge moves the modules whose directory it changed, and everything built on them; nothing else.**
|
||||
// Each row is a catalogue (modules in directories of one or two repositories), its dependencies with
|
||||
// their kinds, the files one commit changed, and the exact plan: the moved set and its tier order.
|
||||
func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
||||
in := func(repo, dir string, names ...string) []inventory.Entry {
|
||||
var out []inventory.Entry
|
||||
for _, n := range names {
|
||||
d := dir + "/" + n
|
||||
if dir == "" {
|
||||
d = n
|
||||
}
|
||||
out = append(out, fromRepo(n, repo, d))
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Modules a to f, x and z in novox/one under modules/; g in novox/two at g/.
|
||||
entries := append(in(repoOne, "modules", "a", "b", "c", "d", "e", "f", "x", "z"), in(repoTwo, "", "g")...)
|
||||
const (
|
||||
standsOn = inventory.EdgeStandsOn
|
||||
declared = inventory.EdgeDeclared
|
||||
packages = inventory.EdgePackages
|
||||
builtBy = inventory.EdgeBuiltBy
|
||||
workerOf = inventory.EdgeWorkerOf
|
||||
)
|
||||
// The two real cycles the kinds resolve themselves (ADR 0162 §1, hq issue 206).
|
||||
runtime := append(in(repoOne, "modules", "runtime", "builder"), fromRepo("controller", repoTwo, ""))
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
entries []inventory.Entry
|
||||
edges []inventory.Edge
|
||||
repo string
|
||||
paths []string
|
||||
want string // the tiers, " | " between them
|
||||
unread string
|
||||
cycle bool
|
||||
}{
|
||||
// The operator's case: two modules changed, a third beside them in the same repository untouched,
|
||||
// each changed one with a dependent.
|
||||
{what: "A and B changed, C untouched beside them, D on A and E on B",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
|
||||
repo: "one", paths: []string{"modules/a/main.go", "modules/b/module.json"}, want: "a,b | d,e"},
|
||||
{what: "only A's directory: A and what stands on it",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
|
||||
repo: "one", paths: []string{"modules/a/main.go"}, want: "a | d"},
|
||||
{what: "only C's directory: C alone, nothing is built on it",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("e", declared, "b")},
|
||||
repo: "one", paths: []string{"modules/c/Dockerfile"}, want: "c"},
|
||||
{what: "only the dependent changed: its base does not move",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/d/main.go"}, want: "d"},
|
||||
{what: "transitive: F on D on A, A changed",
|
||||
edges: []inventory.Edge{dep("f", standsOn, "d"), dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | d | f"},
|
||||
{what: "transitive across kinds: F declared on D, D packages A",
|
||||
edges: []inventory.Edge{dep("f", declared, "d"), dep("d", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a,d | f"},
|
||||
|
||||
// Each kind alone: X depends on A, A changed (widening), then both changed (ordering).
|
||||
{what: "stands-on (built against A's artifact) widens", edges: []inventory.Edge{dep("x", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
||||
{what: "stands-on orders", edges: []inventory.Edge{dep("x", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "declared (build.on) widens", edges: []inventory.Edge{dep("x", declared, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | x"},
|
||||
{what: "declared orders", edges: []inventory.Edge{dep("x", declared, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "packages widens, into the same tier", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a,x"},
|
||||
{what: "packages does not order", edges: []inventory.Edge{dep("x", packages, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
||||
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||
{what: "a change to the base alone does not move what it builds", edges: []inventory.Edge{dep("x", builtBy, "a"),
|
||||
dep("d", builtBy, "a"), dep("e", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/module.json"}, want: "a | e"},
|
||||
|
||||
// The cycles the kinds resolve: the build machine stands on the runtime image the runtime image is
|
||||
// built by; the build seat's holder follows the controller that is built by it.
|
||||
{what: "the build machine's base comes first, built by the build machine that runs", entries: runtime,
|
||||
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
|
||||
repo: "one", paths: []string{"modules/runtime/Dockerfile", "modules/builder/main.go"}, want: "runtime | builder"},
|
||||
{what: "the runtime image alone takes the build machine on it along", entries: runtime,
|
||||
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
|
||||
repo: "one", paths: []string{"modules/runtime/Dockerfile"}, want: "runtime | builder"},
|
||||
{what: "the build machine alone moves alone", entries: runtime,
|
||||
edges: []inventory.Edge{dep("runtime", builtBy, "builder"), dep("builder", standsOn, "runtime")},
|
||||
repo: "one", paths: []string{"modules/builder/main.go"}, want: "builder"},
|
||||
{what: "the build seat's holder follows the controller it binds the worker of", entries: runtime,
|
||||
edges: []inventory.Edge{dep("controller", builtBy, "builder"), dep("builder", workerOf, "controller")},
|
||||
repo: "two", paths: []string{"cmd/main.go"}, want: "controller"},
|
||||
|
||||
// Two repositories.
|
||||
{what: "a dependent in another repository follows its base",
|
||||
edges: []inventory.Edge{dep("g", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | g"},
|
||||
{what: "a directory of the same name in another repository is not this one's",
|
||||
edges: []inventory.Edge{dep("g", standsOn, "a")},
|
||||
repo: "two", paths: []string{"modules/a/x"}, want: "", unread: "modules/a/x"},
|
||||
{what: "the dependent's own repository moves the dependent alone",
|
||||
edges: []inventory.Edge{dep("g", standsOn, "a")},
|
||||
repo: "two", paths: []string{"g/main.go"}, want: "g"},
|
||||
|
||||
// A diamond.
|
||||
{what: "a diamond, one side changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
|
||||
repo: "one", paths: []string{"modules/a/x"}, want: "a | d"},
|
||||
{what: "a diamond, both sides changed", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", standsOn, "b")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/b/x"}, want: "a,b | d"},
|
||||
{what: "a diamond on one base", edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", declared, "b"),
|
||||
dep("a", standsOn, "z"), dep("b", standsOn, "z")},
|
||||
repo: "one", paths: []string{"modules/z/x"}, want: "z | a,b | d"},
|
||||
{what: "a diamond of mixed kinds orders on the ordering side only",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a"), dep("d", packages, "b")},
|
||||
repo: "one", paths: []string{"modules/b/x"}, want: "b,d"},
|
||||
|
||||
// A cycle the catalogue should never produce: what remains is one last tier, and said.
|
||||
{what: "a cycle is one last tier, not lost", edges: []inventory.Edge{dep("a", standsOn, "b"), dep("b", standsOn, "a"),
|
||||
dep("f", standsOn, "c")},
|
||||
repo: "one", paths: []string{"modules/a/x", "modules/c/x"}, want: "c | f | a,b", cycle: true},
|
||||
|
||||
// Files no build reads.
|
||||
{what: "a README at the root of a repository whose modules all live below it",
|
||||
edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"README.md"}, want: "", unread: "README.md"},
|
||||
{what: "a directory no module lives in", edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/lib/x.go", "modules/README.md"}, want: "",
|
||||
unread: "modules/lib/x.go,modules/README.md"},
|
||||
{what: "a module's directory beside a root file", edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"merge-check.sh", "modules/a/x"}, want: "a | d", unread: "merge-check.sh"},
|
||||
{what: "a directory whose name begins with a module's", edges: []inventory.Edge{dep("d", standsOn, "a")},
|
||||
repo: "one", paths: []string{"modules/ab/x"}, want: "", unread: "modules/ab/x"},
|
||||
} {
|
||||
e := entries
|
||||
if c.entries != nil {
|
||||
e = c.entries
|
||||
}
|
||||
r, got := planMerge(t, c.repo, c.paths, e, nil, c.edges)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
|
||||
}
|
||||
if u := strings.Join(r.Unread, ","); u != c.unread {
|
||||
t.Errorf("%s: unread %q, wanted %q", c.what, u, c.unread)
|
||||
}
|
||||
// A packages edge in the last tier is no cycle; hasCycle said one on main at 8170fc5.
|
||||
if hasCycle(r.Plan.Tiers, c.edges) != c.cycle {
|
||||
t.Errorf("%s: a cycle said %v, wanted %v (%v)", c.what, !c.cycle, c.cycle, r.Plan.Tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **CURRENT BEHAVIOUR, documented — not the rule the operator states.** novox/hq issue 338 (a module
|
||||
// built from a shared repository moves on every merge to it) and the decision pending on it would change
|
||||
// every row here. Today:
|
||||
//
|
||||
// - mesh-controller is built from its repository's root, so every file of that repository touches it;
|
||||
// - route-proxy and build-agent package the whole of that repository (a build context), so the build
|
||||
// record's `read` makes them move on any merge to it, whatever the files, and dependenciesOf gives
|
||||
// each a packages edge to every module built from it;
|
||||
// - built-by (route-proxy on build-agent) and worker-of (build-agent on the controller) make it three
|
||||
// tiers.
|
||||
//
|
||||
// These follow ADR 0238 §3 as written ("the whole repository for a module built from its root, and a
|
||||
// repository a recipe names"), so they are not failures; when the decision on issue 338 lands, these
|
||||
// expectations change with it. The edges are the ones dependenciesOf derives from this catalogue — held
|
||||
// to that by TestASharedRepositoryIsPlannedFromTheRecordsAsItIsToday, which derives them from the store.
|
||||
func TestASharedRepositoryMovesWhatPackagesItAsItDoesToday(t *testing.T) {
|
||||
const catalogueRepo = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
||||
entries := []inventory.Entry{
|
||||
fromRepo("mesh-controller", controllerRepo, ""),
|
||||
fromRepo("build-agent", catalogueRepo, "modules/build-agent"),
|
||||
fromRepo("route-proxy", catalogueRepo, "modules/route-proxy"),
|
||||
fromRepo("gitea", catalogueRepo, "modules/gitea"),
|
||||
}
|
||||
read := map[string][]inventory.ReadRepository{
|
||||
"build-agent": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||
"route-proxy": {{Repository: "novox/mesh-controller", Ref: "main"}},
|
||||
}
|
||||
edges := sharedRepositoryEdges
|
||||
for _, c := range []struct {
|
||||
what, repo string
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
// The live three-tier plan of 2026-10-08 (issue 338), in the worker-of order (issue 206) that
|
||||
// TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency's controller case holds too.
|
||||
{"a README of the controller's repository moves all three, in three tiers", "mesh-controller",
|
||||
[]string{"README.md"}, "mesh-controller | build-agent | route-proxy"},
|
||||
{"the controller's own code: the same", "mesh-controller",
|
||||
[]string{"cmd/mesh-controller/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||
{"the route proxy's program alone: the same, the controller with it", "mesh-controller",
|
||||
[]string{"examples/route-proxy/main.go"}, "mesh-controller | build-agent | route-proxy"},
|
||||
// In the catalogue, where they live, the rule is path-precise.
|
||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||
[]string{"modules/route-proxy/module.json"}, "route-proxy"},
|
||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||
[]string{"modules/build-agent/module.json"}, "build-agent"},
|
||||
{"another module of the catalogue: neither", "mesh-catalog",
|
||||
[]string{"modules/gitea/index.ts"}, "gitea"},
|
||||
} {
|
||||
r, got := planMerge(t, c.repo, c.paths, entries, read, edges)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: planned %q, wanted %q (as today; issue 338)", c.what, got, c.want)
|
||||
}
|
||||
if c.repo == "mesh-controller" && strings.Join(r.Unread, ",") != "" {
|
||||
t.Errorf("%s: a root-built module reads every file, and %v were said unread", c.what, r.Unread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sharedRepositoryEdges is what dependenciesOf derives for the catalogue of the test above, sorted as it
|
||||
// sorts them.
|
||||
var sharedRepositoryEdges = []inventory.Edge{
|
||||
dep("build-agent", inventory.EdgePackages, "mesh-controller"),
|
||||
dep("build-agent", inventory.EdgeWorkerOf, "mesh-controller"),
|
||||
dep("gitea", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("mesh-controller", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("route-proxy", inventory.EdgeBuiltBy, "build-agent"),
|
||||
dep("route-proxy", inventory.EdgePackages, "mesh-controller"),
|
||||
}
|
||||
|
||||
// **The planner's invariant, over random catalogues.** For any catalogue whose dependencies form no cycle
|
||||
// and any set of changed files in one repository:
|
||||
//
|
||||
// - the plan is exactly the modules of that repository whose directory holds a changed file (every file,
|
||||
// for a module built from the root), and everything reachable from them along stands-on, declared and
|
||||
// packages — never along built-by or worker-of;
|
||||
// - every stands-on, declared, built-by and worker-of edge with both ends in the plan has the module
|
||||
// depended on in an earlier tier;
|
||||
// - no cycle is said.
|
||||
//
|
||||
// Seeded, so a failure is replayed by its seed and case.
|
||||
func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *testing.T) {
|
||||
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
||||
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
||||
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true, inventory.EdgePackages: true}
|
||||
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
||||
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
||||
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
||||
// is a prefix of another.
|
||||
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
|
||||
files := []string{"README.md", "merge-check.sh", "lib/z.go", "docs/x.md", "modules/README.md"}
|
||||
|
||||
falseCycles, firstFalseCycle := 0, ""
|
||||
for _, seed := range []uint64{1, 2, 3, 0x338, 0x162} {
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
for n := 0; n < 100; n++ {
|
||||
repos := 1 + rng.IntN(3)
|
||||
repoName := func(i int) string { return fmt.Sprintf("r%d", i) }
|
||||
count := 1 + rng.IntN(12)
|
||||
var entries []inventory.Entry
|
||||
repoOf, dirOf := map[string]int{}, map[string]string{}
|
||||
for i := 0; i < count; i++ {
|
||||
name := fmt.Sprintf("m%02d", i)
|
||||
repo := rng.IntN(repos)
|
||||
dir := dirs[rng.IntN(len(dirs))]
|
||||
if rng.IntN(12) == 0 {
|
||||
dir = "" // built from the repository's root
|
||||
}
|
||||
repoOf[name], dirOf[name] = repo, dir
|
||||
entries = append(entries, fromRepo(name, "http://forge.internal:20000/novox/"+repoName(repo)+".git", dir))
|
||||
}
|
||||
// A graph with no cycle: a module depends only on modules made before it.
|
||||
var edges []inventory.Edge
|
||||
for i := 1; i < count; i++ {
|
||||
for j := 0; j < i; j++ {
|
||||
if rng.IntN(4) == 0 {
|
||||
edges = append(edges, dep(fmt.Sprintf("m%02d", i), kinds[rng.IntN(len(kinds))], fmt.Sprintf("m%02d", j)))
|
||||
}
|
||||
}
|
||||
}
|
||||
merged := rng.IntN(repos)
|
||||
var paths []string
|
||||
for k := 1 + rng.IntN(4); k > 0; k-- {
|
||||
if rng.IntN(3) == 0 {
|
||||
paths = append(paths, files[rng.IntN(len(files))])
|
||||
} else {
|
||||
paths = append(paths, dirs[rng.IntN(len(dirs))]+"/f.go")
|
||||
}
|
||||
}
|
||||
|
||||
// What the rules say.
|
||||
want := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
name := e.Manifest.Module
|
||||
if repoOf[name] != merged {
|
||||
continue
|
||||
}
|
||||
for _, p := range paths {
|
||||
if d := dirOf[name]; d == "" || p == d || strings.HasPrefix(p, d+"/") {
|
||||
want[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for grew := true; grew; {
|
||||
grew = false
|
||||
for _, e := range edges {
|
||||
if widens[e.Kind] && want[e.To] && !want[e.From] {
|
||||
want[e.From], grew = true, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r, _ := planMerge(t, repoName(merged), paths, entries, nil, edges)
|
||||
got := map[string]bool{}
|
||||
tierOf := map[string]int{}
|
||||
for i, tier := range r.Plan.Tiers {
|
||||
for _, name := range tier {
|
||||
got[name], tierOf[name] = true, i
|
||||
}
|
||||
}
|
||||
replay := func() string {
|
||||
return fmt.Sprintf("seed %#x case %d: repository %s, files %v\n modules %v\n edges %v\n tiers %v",
|
||||
seed, n, repoName(merged), paths, describe(entries), edges, r.Plan.Tiers)
|
||||
}
|
||||
if !sameSet(got, want) {
|
||||
t.Fatalf("planned %v, wanted %v\n%s", keys(got), keys(want), replay())
|
||||
}
|
||||
for _, e := range edges {
|
||||
if orders[e.Kind] && got[e.From] && got[e.To] && tierOf[e.To] >= tierOf[e.From] {
|
||||
t.Fatalf("%s %s %s, and %s is in tier %d, not before %s's %d\n%s", e.From, e.Kind, e.To,
|
||||
e.To, tierOf[e.To], e.From, tierOf[e.From], replay())
|
||||
}
|
||||
}
|
||||
if hasCycle(r.Plan.Tiers, edges) {
|
||||
falseCycles++
|
||||
if firstFalseCycle == "" {
|
||||
firstFalseCycle = replay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Said once, after the other invariants have run over every case, so it hides none of them (hasCycle
|
||||
// counted a packages edge on main at 8170fc5: 19 of these 500 cases).
|
||||
if falseCycles > 0 {
|
||||
t.Errorf("a cycle said of a graph with none in %d of 500 cases; "+
|
||||
"the first:\n%s", falseCycles, firstFalseCycle)
|
||||
}
|
||||
}
|
||||
|
||||
func sameSet(a, b map[string]bool) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for k := range a {
|
||||
if !b[k] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func keys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func describe(entries []inventory.Entry) []string {
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
out = append(out, fmt.Sprintf("%s@%s:%q", e.Manifest.Module,
|
||||
strings.TrimSuffix(strings.TrimPrefix(e.Source.Repository, "http://forge.internal:20000/novox/"), ".git"),
|
||||
e.Source.Path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -157,7 +157,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// hasCycle says whether the tiers' last tier holds modules that still depend on each other.
|
||||
// hasCycle says whether the tiers' last tier holds modules that still depend on each other. A packages
|
||||
// edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
|
||||
// no cycle, and saying one was is a false report in every such plan's log.
|
||||
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
||||
if len(tiers) == 0 {
|
||||
return false
|
||||
@@ -167,6 +169,9 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
||||
last[m] = true
|
||||
}
|
||||
for _, e := range edges {
|
||||
if e.Kind == inventory.EdgePackages {
|
||||
continue
|
||||
}
|
||||
if last[e.From] && last[e.To] {
|
||||
return true
|
||||
}
|
||||
@@ -701,7 +706,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
|
||||
// **Sent with others, judged with them** (issue 281): the gate of the send that carried it is its
|
||||
// verdict on its first machine. A failure there stopped the plan already.
|
||||
if state.GatedBy != "" {
|
||||
@@ -715,6 +719,9 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
||||
}
|
||||
passedWith(m, state, state.GatedBy, lead.Gate)
|
||||
}
|
||||
// Read after its pass is taken over from the send that carried it, so a passed gate is never judged
|
||||
// again from the first machine's later reports (novox/hq issue 335).
|
||||
step := nextRollout(*state, running, policy.Together, reports, now, planWaitBound)
|
||||
switch {
|
||||
case step.failed != "":
|
||||
// The first machine refused or failed what it was sent, or never said: the gate failed, and
|
||||
@@ -974,6 +981,19 @@ func failFirstSend(ctx context.Context, open *stores, p *inventory.Plan, module
|
||||
fmt.Printf("%s: %s\n", p.ID, p.Note)
|
||||
}()
|
||||
g := state.Gate
|
||||
if g != nil && g.Verdict == inventory.GatePassed {
|
||||
// **A guard: a build that passed its gate is never put back for what came after** (novox/hq issue 335).
|
||||
// Not reached while nextRollout answers a passed gate with the rest to send, and advanceOnce reads it
|
||||
// after a carried module takes over its lead's pass; it is here so that a path added later cannot
|
||||
// overturn a verdict. If it is reached, the plan stops and says why, and the build is not marked failed.
|
||||
// The module is left a stopped rollout (its Why said, sent first and not to the rest), which
|
||||
// `plans retry` takes: it sends the first machine again, and the passed gate then sends the rest.
|
||||
state.Why = "passed its gate; its walk then stopped: " + why
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s passed its gate on %s in tier %d (%s) and is kept; its walk stopped after: %s",
|
||||
module, strings.Join(g.Machines, ", "), p.Tier, g.Why, why)
|
||||
return
|
||||
}
|
||||
if g != nil && slices.Contains(g.Returned, module) {
|
||||
// Put back at once when it broke: its rollback was made then, and is not made again.
|
||||
state.Why = "put back when it broke; its send failed: " + g.Why
|
||||
@@ -1061,6 +1081,15 @@ func nextRollout(s inventory.PlanModule, running []string, together bool, report
|
||||
rest = append(rest, n)
|
||||
}
|
||||
}
|
||||
// **A passed gate is the first machine's verdict, and its later reports are not** (novox/hq issue 335).
|
||||
// Once the build passed there, what that machine reports next is about whatever it was sent after —
|
||||
// another walk's send, a push — and says nothing of this build. On 2026-10-08 a build passed on the
|
||||
// laptop, its send to the rest waited on another walk, that walk sent the laptop a new declaration it did
|
||||
// not report for half an hour, and the plan read the silence as the first machine never applying the
|
||||
// passed build: it marked it failed at its gate and put it back. The rest are sent, as the pass said.
|
||||
if s.Gate != nil && s.Gate.Verdict == inventory.GatePassed {
|
||||
return rolloutStep{send: rest}
|
||||
}
|
||||
var waiting, failed []string
|
||||
for _, n := range s.First {
|
||||
r, said := byNode[n]
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
||||
{From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||
{From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy},
|
||||
{From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
||||
// the build seat's holder follows the controller that defines its worker (hq issue 206)
|
||||
{From: "builder", To: "mesh-controller", Kind: inventory.EdgeWorkerOf},
|
||||
{From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn},
|
||||
}
|
||||
// The runtime image moved: everything on it, and what is built by what is on it.
|
||||
@@ -62,12 +64,15 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
||||
if len(small) != 3 {
|
||||
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
||||
}
|
||||
// The builder packages the controller's source (same tier by that edge) and the controller is
|
||||
// built by the builder (next tier by that one): the builder first, then the controller and the
|
||||
// proxy together — a code dependency in one tier, a runtime dependency across tiers.
|
||||
// The builder and the proxy package the controller's source, which orders nothing. The builder holds
|
||||
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
|
||||
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
|
||||
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
|
||||
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
|
||||
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
|
||||
smallTiers := tiersOf(small, edges)
|
||||
if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 {
|
||||
t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers)
|
||||
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
||||
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
|
||||
}
|
||||
// The builder alone moved: the builder, and nothing it builds.
|
||||
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// TestReplay335 replays novox/hq issue 335 (2026-10-08): dunst's new build passed its gate on the laptop
|
||||
// (healthy 3 times over 2m5s); its send to the rest was refused while another walk's build waited on the
|
||||
// workstation; that walk then sent the laptop a declaration the laptop did not report on; and thirty minutes
|
||||
// after the first send the plan read the laptop's silence as the passed build never applied, marked it failed
|
||||
// at its gate with the pass's own words, and put it back. Written with only what the controller had before
|
||||
// its fix, so it is laid over the commit before.
|
||||
func TestReplay335(t *testing.T) {
|
||||
t.Run("the first machine's later reports", testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports)
|
||||
t.Run("a walk stopped after the pass", testAWalkStoppedAfterItsGatePassedKeepsThePass)
|
||||
}
|
||||
|
||||
// novox/hq issue 335: a build that passed its gate on its first machine is not judged again from that
|
||||
// machine's later reports. On 2026-10-08 the send to the rest waited on another walk, that walk sent the
|
||||
// first machine a declaration it did not report for half an hour, and the plan failed the passed build at
|
||||
// the wait's bound and put it back.
|
||||
func testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports(t *testing.T) {
|
||||
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
|
||||
judged := sentAt.Add(2 * time.Minute)
|
||||
later := sentAt.Add(5 * time.Minute)
|
||||
state := inventory.PlanModule{First: []string{"laptop"}, FirstAt: &sentAt,
|
||||
Gate: &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed,
|
||||
Why: "healthy 3 times over 2m5s", JudgedAt: &judged, Kept: true}}
|
||||
running := []string{"laptop", "workstation"}
|
||||
now := sentAt.Add(30*time.Minute + 9*time.Second)
|
||||
|
||||
for what, reports := range map[string][]inventory.Reported{
|
||||
"no report about what it was sent since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeApplied, Current: false}},
|
||||
"another send failed there since": {{Node: "laptop", At: &later, Outcome: inventory.OutcomeFailed, Current: true}},
|
||||
"no report at all": nil,
|
||||
} {
|
||||
step := nextRollout(state, running, false, reports, now, 30*time.Minute)
|
||||
if step.failed != "" || step.waiting != "" || !reflect.DeepEqual(step.send, []string{"workstation"}) {
|
||||
t.Errorf("%s: %+v, want the rest sent as the pass said", what, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 335: whatever stops a walk after its gate passed, the passed build is not marked failed at
|
||||
// its gate, nor put back.
|
||||
func testAWalkStoppedAfterItsGatePassedKeepsThePass(t *testing.T) {
|
||||
sentAt := time.Date(2026, 10, 8, 16, 44, 45, 0, time.UTC)
|
||||
g := &inventory.PlanGate{Machines: []string{"laptop"}, Verdict: inventory.GatePassed, Why: "healthy 3 times over 2m5s"}
|
||||
state := &inventory.PlanModule{Build: "build-1", First: []string{"laptop"}, FirstAt: &sentAt, Gate: g}
|
||||
p := &inventory.Plan{ID: "plan-1", Modules: map[string]*inventory.PlanModule{"dunst": state}}
|
||||
// No stores: a walk that keeps the pass touches none, and one that reaches for them is putting it back.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("the passed build was taken to be failed and put back: %v", r)
|
||||
}
|
||||
}()
|
||||
failFirstSend(t.Context(), nil, p, "dunst", state, []string{"laptop"}, "laptop did not report it applied within 30m0s",
|
||||
[]string{"workstation"})
|
||||
if g.Verdict != inventory.GatePassed || g.Rollback != "" {
|
||||
t.Fatalf("the passed gate became %q, rollback %q", g.Verdict, g.Rollback)
|
||||
}
|
||||
if strings.Contains(p.Note, "failed its gate") || strings.Contains(p.Note, "put back") ||
|
||||
!strings.Contains(p.Note, "did not report it applied") {
|
||||
t.Fatalf("the note reads %q", p.Note)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 335 review: **a module carried by its lead's send takes over the lead's pass before its own
|
||||
// step is read.** The state is the one a step leaves when the lead's gate passed and the step ended before the
|
||||
// carried module's turn (an error read before it, kept with the plan): the lead passed, the carried module has
|
||||
// no gate of its own yet. Since then another send reached the first machine and it has not reported on it, and
|
||||
// the first send is older than the wait for a first machine's report. Read before the pass is taken over, the
|
||||
// carried module's step said the first machine never applied it, and the passed build was put back.
|
||||
func TestACarriedModuleTakesItsLeadsPassBeforeItsStepIsRead(t *testing.T) {
|
||||
tm := aTierMesh(t, "m01", "m02")
|
||||
ctx := t.Context()
|
||||
inv := tm.open.inventory
|
||||
|
||||
advancePlans(ctx, tm.open)
|
||||
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) {
|
||||
t.Fatalf("sent %v: the first machine once, for the tier", tm.sent)
|
||||
}
|
||||
p := tm.plan(t)
|
||||
lead, carried := p.Modules["m01"], p.Modules["m02"]
|
||||
if lead.Gate == nil || carried.GatedBy != "m01" {
|
||||
t.Fatalf("m02 is not carried by m01's send: lead %+v, carried %+v", lead.Gate, carried)
|
||||
}
|
||||
// The lead passed; the carried module's turn did not come. The first send is past the wait's bound.
|
||||
sent := time.Now().UTC().Add(-planWaitBound - time.Minute)
|
||||
judged := sent.Add(2 * time.Minute)
|
||||
lead.FirstAt, carried.FirstAt, lead.Gate.Since = &sent, &sent, &sent
|
||||
lead.Gate.Verdict, lead.Gate.Why, lead.Gate.JudgedAt, lead.Gate.Kept = inventory.GatePassed,
|
||||
"healthy 3 times over 2m5s", &judged, true
|
||||
carried.Gate = nil
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Another walk's send reached anchor, which has not reported on it.
|
||||
if err := inv.RecordSent(ctx, nodeID(t, tm.open, "anchor"), "d-anchor-elsewhere",
|
||||
map[string]string{"m01": "c2", "m02": "c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
advancePlans(ctx, tm.open)
|
||||
p = tm.plan(t)
|
||||
if p.State == inventory.PlanFailed {
|
||||
t.Fatalf("the plan failed after its gate passed: %s", p.Note)
|
||||
}
|
||||
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}, {"laptop"}}) {
|
||||
t.Fatalf("sent %v: the rest once, as the pass said", tm.sent)
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []string{"m01", "m02"} {
|
||||
if current[m].Commit != "c2" {
|
||||
t.Errorf("%s was put back to %s after its gate passed", m, current[m].Commit)
|
||||
}
|
||||
if failed, _ := inv.GateFailed(ctx, "build-"+m+"-2"); failed {
|
||||
t.Errorf("%s's build was marked failed at its gate after the gate passed", m)
|
||||
}
|
||||
}
|
||||
if g := p.Modules["m02"].Gate; g == nil || g.Verdict != inventory.GatePassed {
|
||||
t.Errorf("m02 did not take over m01's pass: %+v", g)
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,14 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("command names no command")
|
||||
}
|
||||
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
|
||||
// settings verb is where what a verb may not set is refused, and one route is one set of words.
|
||||
// The command refuses places and accesses through any verb as well; this says so before it runs.
|
||||
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
|
||||
return nil, errors.New("settings are set and cleared through the settings verb, not the generic " +
|
||||
"command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
|
||||
"Nothing was done")
|
||||
}
|
||||
// The generic verb is no way round the hand-act log (novox/hq to-be 45 §7): a repair through
|
||||
// it says why, as it would through its own verb.
|
||||
if repair := repairingCommand(argv); repair != "" && !slices.ContainsFunc(argv, isWhyFlag) {
|
||||
|
||||
@@ -245,7 +245,7 @@ func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||
t.Fatalf("a plain line: %v %v", argv, err)
|
||||
}
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
||||
argv, err = argvFor("command", map[string]any{"command": `settings show dnsmasq '{"a": "b c"}' --node ace`})
|
||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Where root creates and owns a module's directories, and which of the machine's paths reach its container,
|
||||
// are said at the controller's terminal alone (novox/hq issue 339): through a verb, a caller who set `places`
|
||||
// to /etc with an owner of its own would have the next push hand it /etc, and one who set `accesses` to /
|
||||
// would have the machine's root mounted into a container.
|
||||
|
||||
// throughVerb runs a verb's call the way the serving controller does: the command line argvFor composes, in
|
||||
// a process that carries the verb in its environment (runVerb), through this binary's own dispatch.
|
||||
func throughVerb(t *testing.T, verb string, args map[string]any) error {
|
||||
t.Helper()
|
||||
argv, err := argvFor(verb, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Setenv(verbVar, verb)
|
||||
defer os.Unsetenv(verbVar)
|
||||
return runLine(t, argv)
|
||||
}
|
||||
|
||||
// atTheTerminal runs a command line the way the operator does at the controller's terminal: no verb.
|
||||
func atTheTerminal(t *testing.T, argv ...string) error {
|
||||
t.Helper()
|
||||
t.Setenv(verbVar, "")
|
||||
os.Unsetenv(verbVar)
|
||||
return runLine(t, argv)
|
||||
}
|
||||
|
||||
func runLine(t *testing.T, argv []string) error {
|
||||
t.Helper()
|
||||
before := os.Args
|
||||
defer func() { os.Args = before }()
|
||||
os.Args = append([]string{"mesh-controller"}, argv...)
|
||||
return run()
|
||||
}
|
||||
|
||||
func TestPlacesAndAccessesAreRefusedThroughEveryVerb(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||
Accesses: []catalogue.Access{{ID: "media", Path: "/storage/media", Mode: "read"}},
|
||||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
|
||||
// Nothing trusts this file: said, so a verb may change what it asks for (an unmarked one counts as
|
||||
// trusted, and only the terminal could).
|
||||
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "trusted": false,
|
||||
"content": "x = ${setting:x}\n"}}})
|
||||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer := func() string {
|
||||
t.Helper()
|
||||
values, _, err := open.inventory.Layer(ctx, "laptop", "notes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
return string(raw)
|
||||
}
|
||||
refused := func(what string, err error) {
|
||||
t.Helper()
|
||||
if err == nil || !strings.Contains(err.Error(), "controller's terminal") ||
|
||||
!strings.Contains(err.Error(), "issue 339") {
|
||||
t.Fatalf("%s: %v", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The attack the issue reports, through each verb route: nothing is kept.
|
||||
attacks := []map[string]any{
|
||||
{"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}, "x": 1},
|
||||
{"accesses": map[string]any{"media": "/srv/elsewhere"}, "x": 1},
|
||||
}
|
||||
for _, values := range attacks {
|
||||
raw, _ := json.Marshal(values)
|
||||
refused("settings verb, one machine", throughVerb(t, "settings",
|
||||
map[string]any{"module": "notes", "node": "laptop", "values": string(raw)}))
|
||||
refused("settings verb, the whole mesh", throughVerb(t, "settings",
|
||||
map[string]any{"module": "notes", "values": string(raw)}))
|
||||
for _, line := range []string{
|
||||
"settings set notes '" + string(raw) + "' --node laptop",
|
||||
"settings set --node laptop notes '" + string(raw) + "'",
|
||||
"settings set notes '" + string(raw) + "'",
|
||||
} {
|
||||
if err := throughVerb(t, "command", map[string]any{"command": line}); err == nil {
|
||||
t.Fatalf("the command verb ran %q", line)
|
||||
}
|
||||
}
|
||||
if got := layer(); got != "null" && got != "{}" {
|
||||
t.Fatalf("a refused call kept a layer: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// At the terminal the same placement is taken.
|
||||
if err := atTheTerminal(t, "settings", "set", "notes",
|
||||
`{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":0}`, "--node", "laptop"); err != nil {
|
||||
t.Fatalf("places at the terminal: %v", err)
|
||||
}
|
||||
// A verb may change another key and keep the placement as it is.
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":1}`}); err != nil {
|
||||
t.Fatalf("another key through the verb: %v", err)
|
||||
}
|
||||
kept := layer()
|
||||
// But not move it, drop it, or clear the layer that holds it.
|
||||
refused("moved through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"places":{"data":{"path":"/srv/other","owner":"1001:1001"}},"x":1}`}))
|
||||
refused("dropped through the verb", throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||||
"values": `{"x":1}`, "replace": "true"}))
|
||||
refused("cleared through the verb", throughVerb(t, "settings",
|
||||
map[string]any{"module": "notes", "node": "laptop", "clear": "true"}))
|
||||
if err := throughVerb(t, "command", map[string]any{"command": "settings clear notes --node laptop"}); err == nil {
|
||||
t.Fatal("the command verb cleared a layer")
|
||||
}
|
||||
if got := layer(); got != kept {
|
||||
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
|
||||
}
|
||||
// Reading through a verb still answers.
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop"}); err != nil {
|
||||
t.Fatalf("reading through the verb: %v", err)
|
||||
}
|
||||
|
||||
// The machine's own trees are refused from anywhere, the terminal too.
|
||||
for _, path := range []string{"/etc", "/etc/sudoers.d", "/", "/home", "/var/lib", "/var/lib/mesh-host/x", "/srv/../etc"} {
|
||||
err := atTheTerminal(t, "settings", "set", "notes",
|
||||
`{"places":{"data":{"path":"`+path+`","owner":"1001:1001"}},"x":1}`, "--node", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Fatalf("a place at %s at the terminal: %v", path, err)
|
||||
}
|
||||
err = atTheTerminal(t, "settings", "set", "notes",
|
||||
`{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"accesses":{"media":"`+path+`"},"x":1}`,
|
||||
"--node", "laptop")
|
||||
if err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Fatalf("an access at %s at the terminal: %v", path, err)
|
||||
}
|
||||
}
|
||||
// A line break in any setting is refused where it is kept, through a verb or at the terminal.
|
||||
for _, x := range []string{`"a\nPATH=/tmp"`, `"a\rb"`, `"a\u0000b"`, `["ok","x\ny"]`, `{"k":"x\ny"}`} {
|
||||
err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "replace": "true",
|
||||
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}},"x":` + x + `}`})
|
||||
if err == nil || !strings.Contains(err.Error(), "line break") {
|
||||
t.Fatalf("a line break in %s: %v", x, err)
|
||||
}
|
||||
}
|
||||
if got := layer(); got != kept {
|
||||
t.Fatalf("a refused call changed the layer: %s, was %s", got, kept)
|
||||
}
|
||||
}
|
||||
|
||||
// What a provider serves is set at the terminal alone (novox/hq issue 339): through the settings verb, a caller
|
||||
// could move a database's port to a listener of its own and collect every consumer's credentials, or point every
|
||||
// login at an issuer of its own.
|
||||
func TestAServedKeyIsRefusedThroughAVerb(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "store", Version: "1",
|
||||
Provides: catalogue.FromAnywhere("database"),
|
||||
Serves: map[string]map[string]any{"database": {"port": 5432.0}},
|
||||
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/store.conf", "mode": "0644",
|
||||
"trusted": false, "content": "x = ${setting:x}\n"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "keycloak", Version: "1",
|
||||
Provides: catalogue.FromAnywhere("oidc-client"),
|
||||
Serves: map[string]map[string]any{"oidc-client": {"issuer": "${setting:issuer}"}},
|
||||
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/kc.conf", "mode": "0644",
|
||||
"trusted": false, "content": "x = ${setting:x}\n"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "power", Version: "1",
|
||||
Resources: []map[string]any{{"id": "logind", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf",
|
||||
"mode": "0644", "trusted": true, "content": "HandleLidSwitch=${setting:lid}\nx=${setting:x}\n"}}})
|
||||
if err := atTheTerminal(t, "settings", "set", "keycloak", `{"issuer":"https://id.example/realms/mesh","x":0}`,
|
||||
"--node", "anchor"); err != nil {
|
||||
t.Fatalf("the issuer at the terminal: %v", err)
|
||||
}
|
||||
if err := atTheTerminal(t, "settings", "set", "power", `{"lid":"suspend","x":0}`, "--node", "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []string{"store", "keycloak", "power"} {
|
||||
if _, err := assign(ctx, open, "anchor", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
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("a served port through the verb", throughVerb(t, "settings", map[string]any{"module": "store",
|
||||
"node": "anchor", "values": `{"port":6543,"x":0}`}))
|
||||
refused("a served port, mesh-wide, through the verb", throughVerb(t, "settings",
|
||||
map[string]any{"module": "store", "values": `{"port":6543}`}))
|
||||
refused("the issuer through the verb", throughVerb(t, "settings", map[string]any{"module": "keycloak",
|
||||
"node": "anchor", "values": `{"issuer":"https://evil.example/realms/mesh","x":0}`}))
|
||||
refused("clearing the issuer through the verb", throughVerb(t, "settings", map[string]any{"module": "keycloak",
|
||||
"node": "anchor", "clear": "true"}))
|
||||
if err := throughVerb(t, "settings", map[string]any{"module": "keycloak", "node": "anchor",
|
||||
"values": `{"issuer":"https://id.example/realms/mesh","x":1}`}); err != nil {
|
||||
t.Fatalf("another key through the verb, the issuer kept: %v", err)
|
||||
}
|
||||
refused("a setting a trusted file asks for, through the verb", throughVerb(t, "settings", map[string]any{
|
||||
"module": "power", "node": "anchor", "values": `{"lid":"ignore","x":0}`}))
|
||||
// And `trusted` is the catalogue's word: it never reaches the machine, whose engine parses strictly.
|
||||
plan := printed(t, func() error { return atTheTerminal(t, "plan", "anchor", "--json") })
|
||||
if strings.Contains(plan, `"trusted"`) {
|
||||
t.Fatal("the declaration carries the catalogue's `trusted`")
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,16 @@ import (
|
||||
//
|
||||
// 04-ISSUES/038 was the first of them (the port). These are the rest.
|
||||
|
||||
// A root certificate, in the shape a certificate authority serves one.
|
||||
// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key
|
||||
// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339).
|
||||
const servedRoot = `-----BEGIN CERTIFICATE-----
|
||||
MIIBeDCCAR2gAwIBAgIQfake0000000000000000000000
|
||||
MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2
|
||||
WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO
|
||||
cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN
|
||||
p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB
|
||||
/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k
|
||||
IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ==
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
|
||||
@@ -913,6 +913,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// such field, and the reason is for a reader of the manifest.
|
||||
delete(copied, SecretsInEnvironment)
|
||||
delete(copied, NamesOnPurpose)
|
||||
delete(copied, TrustedField)
|
||||
// **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a
|
||||
// definition may not carry because it is true of one installation only. Filled from
|
||||
// the same layers a mergeable file takes, and refused when no layer set it.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// The login shell's `execute` runs any command as the operator account, which can become root without a
|
||||
// person, so the operator withheld it on the control-node until a call to it needs a person's approval
|
||||
// (novox/hq ADR 0268). It is withheld per machine by the holder's own setting, so the seat must let a
|
||||
// holder hold it without serving the verb there: `execute` is optional (ADR 0246's mark), and stays so in
|
||||
// a seat row read back from the store, which never keeps the mark.
|
||||
func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) {
|
||||
check := func(t *testing.T) {
|
||||
t.Helper()
|
||||
seat, ok := SeatNamed(LoginShellSeat)
|
||||
if !ok {
|
||||
t.Fatal("node-login-shell is not defined")
|
||||
}
|
||||
if len(seat.Serves) != 1 || seat.Serves[0].Name != "execute" {
|
||||
t.Fatalf("the login shell promises %+v, not execute alone", seat.Serves)
|
||||
}
|
||||
if !seat.Serves[0].Optional {
|
||||
t.Fatal("execute is required, so a holder that withholds it on one machine could not hold the seat there")
|
||||
}
|
||||
withholding := Manifest{Module: "zsh", Version: "1", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}}
|
||||
if err := CanHold(withholding, seat); err != nil {
|
||||
t.Fatalf("a holder serving no execute is refused: %v", err)
|
||||
}
|
||||
serving := withholding
|
||||
serving.Claims = []Claim{{Name: LoginShellSeat, Scope: ScopeNode, Serves: []string{"execute"}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("a holder serving execute is refused: %v", err)
|
||||
}
|
||||
}
|
||||
t.Run("compiled", check)
|
||||
t.Run("read back from the store", func(t *testing.T) {
|
||||
before := seats
|
||||
t.Cleanup(func() { seats = before })
|
||||
UseSeats([]Seat{{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
||||
Serves: []Verb{{Name: "execute"}}}})
|
||||
check(t)
|
||||
})
|
||||
}
|
||||
@@ -1759,6 +1759,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
}
|
||||
problems = append(problems, invokeProblems(m)...)
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -2,6 +2,7 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -44,6 +45,53 @@ type Placement struct {
|
||||
|
||||
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
|
||||
|
||||
// Where no placement and no access may be, from any route, the controller's terminal too (novox/hq issue 339).
|
||||
//
|
||||
// A placed directory is created and owned by the node-engine as root, with the owner the setting names, and
|
||||
// whatever the module writes into it is written as root; an access is mounted into the module's container,
|
||||
// which may run as root. A place at /etc owned by an account a caller names hands that account the machine,
|
||||
// and an access at / mounts the machine's root into a container. So the machine's own trees are refused here,
|
||||
// before anything is kept or composed, and the node-engine refuses them again where it applies.
|
||||
//
|
||||
// systemTrees are refused at and below: the machine's system, the kernel's, the boot loader's, root's home,
|
||||
// what lives only while the machine runs, the spool (cron's tables are there), the container runtimes' data
|
||||
// (every container's filesystem), /opt, and the node-engine's and the mesh's own state. Any home's .ssh is
|
||||
// refused as well, wherever the home is. systemRoots are
|
||||
// refused at, and wherever a path holds one (an ancestor of /var/lib holds it): each is the parent of every
|
||||
// module's or every person's directories, and owning it is owning all of them.
|
||||
var (
|
||||
systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/run", "/var/run", "/var/lock", "/proc", "/sys",
|
||||
"/dev", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host", "/var/spool",
|
||||
"/var/lib/docker", "/var/lib/containers", "/var/lib/containerd", "/opt"}
|
||||
systemRoots = []string{"/", "/var", "/var/lib", "/var/cache", "/var/log", "/var/tmp", "/home",
|
||||
"/mnt", "/media", "/srv", "/tmp", "/storage", "/data", "/services"}
|
||||
)
|
||||
|
||||
// systemPath says why a clean absolute path is the machine's own and never a placement's or an access's, or "".
|
||||
func systemPath(path string) string {
|
||||
// Any home's keys, wherever the home is: a .ssh directory is its account's, and the keys and the list of who
|
||||
// may log in as it are in there.
|
||||
for _, part := range strings.Split(path, "/") {
|
||||
if part == ".ssh" {
|
||||
return path + " is an account's .ssh, which holds its keys and who may log in as it"
|
||||
}
|
||||
}
|
||||
for _, tree := range systemTrees {
|
||||
if path == tree || strings.HasPrefix(path, tree+"/") {
|
||||
return path + " is in " + tree + ", the machine's own or the mesh's state"
|
||||
}
|
||||
if strings.HasPrefix(tree, path+"/") || path == "/" {
|
||||
return path + " holds " + tree + ", the machine's own or the mesh's state"
|
||||
}
|
||||
}
|
||||
for _, root := range systemRoots {
|
||||
if path == root {
|
||||
return path + " is the parent of every module's or every person's directories"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// accessRef is how a module names one of its accesses: ${access:<id>}.
|
||||
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
|
||||
|
||||
@@ -103,7 +151,12 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
|
||||
if !strings.HasPrefix(p.Path, "/") {
|
||||
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
|
||||
}
|
||||
p.Path = strings.TrimRight(p.Path, "/")
|
||||
p.Path = filepath.Clean(p.Path)
|
||||
if why := systemPath(p.Path); why != "" {
|
||||
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine creates and owns a placed "+
|
||||
"directory as root, with the owner the setting names — no placement is ever there "+
|
||||
"(novox/hq issue 339)", m.Module, id, p.Path, why)
|
||||
}
|
||||
out[id] = p
|
||||
}
|
||||
}
|
||||
@@ -147,7 +200,12 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
|
||||
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
|
||||
m.Module, id, body)
|
||||
}
|
||||
out[id] = strings.TrimRight(path, "/")
|
||||
path = filepath.Clean(path)
|
||||
if why := systemPath(path); why != "" {
|
||||
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
|
||||
"module's container — no access is ever there (novox/hq issue 339)", m.Module, id, path, why)
|
||||
}
|
||||
out[id] = path
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
|
||||
@@ -256,8 +256,9 @@ var defaultSeats = append([]Seat{
|
||||
{Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"},
|
||||
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
|
||||
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
|
||||
// the seat exists whether or not zsh's definition is registered. `execute` is the contract any
|
||||
// node may call; the holder places every module's shell code in its slots.
|
||||
// the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller
|
||||
// may call where the machine serves it (optional: ADR 0268); the holder places every module's shell
|
||||
// code in its slots.
|
||||
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
||||
Serves: loginShellVerbs()},
|
||||
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
|
||||
@@ -721,9 +722,6 @@ func uplinkVerbs() []Verb {
|
||||
}
|
||||
}
|
||||
|
||||
// loginShellVerbs is the contract every holder of node-login-shell serves (novox/hq ADR 0176, ADR
|
||||
// 0204): one command, run the way the operator's own terminal would run it, bounded below the
|
||||
// runtime's thirty-second call limit so a hung command answers rather than times the caller out.
|
||||
// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now,
|
||||
// and restore beside the live data — never over it.
|
||||
func backupVerbs() []Verb {
|
||||
@@ -745,9 +743,18 @@ func backupVerbs() []Verb {
|
||||
}
|
||||
}
|
||||
|
||||
// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run
|
||||
// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit
|
||||
// so a hung command answers rather than times the caller out.
|
||||
//
|
||||
// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can
|
||||
// become root without a person, so a machine may withhold it: the control-node does, through the
|
||||
// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark
|
||||
// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its
|
||||
// silence on the bus is not judged — a holder withholding it serves nothing on the seat.
|
||||
func loginShellVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "execute", Description: "Run one command on this machine as the operator account, in a " +
|
||||
{Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " +
|
||||
"non-interactive login shell in its home; answers with what it printed and how it exited.",
|
||||
Input: schema(map[string]string{
|
||||
"command": "the command line, as you would type it",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A setting may name the unit a service resource holds: a module that stops the distribution's own timer
|
||||
// for the pool the operator names cannot write the pool into its definition (novox/hq ADR 0112), and a
|
||||
// unit name with ${setting:…} left in it is a unit no machine has, so the apply fails far from its cause.
|
||||
|
||||
func scrubber() Manifest {
|
||||
return Manifest{Module: "zfs",
|
||||
Settings: map[string]SettingDeclaration{
|
||||
"scrub-cadence": {Kind: KindPreference, Default: "weekly", Why: "what the distribution's timer did"},
|
||||
},
|
||||
Resources: []map[string]any{
|
||||
{"id": "distribution-weekly", "type": "service", "unit": "zfs-scrub-weekly@${setting:scrub-pool}.timer",
|
||||
"state": "stopped", "boot": "disabled"},
|
||||
{"id": "distribution-monthly", "type": "service", "unit": "zfs-scrub-monthly@${setting:scrub-pool}.timer",
|
||||
"state": "stopped", "boot": "disabled"},
|
||||
{"id": "scrub-config", "type": "file", "path": "/etc/zfs-tools/scrub.conf",
|
||||
"content": "pool=${setting:scrub-pool}\ncadence=${setting:scrub-cadence}\n"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestASettingNamesAServicesUnit(t *testing.T) {
|
||||
m := scrubber()
|
||||
layers := WithDefaults(m, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": "storage", "scrub-cadence": "monthly"}}})
|
||||
for i, want := range []string{"zfs-scrub-weekly@storage.timer", "zfs-scrub-monthly@storage.timer"} {
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[i] {
|
||||
r[k] = v
|
||||
}
|
||||
if err := settingInto(r, layers, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["unit"] != want {
|
||||
t.Errorf("composed as %q, not %q", r["unit"], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composed for a machine, the way a push writes it: the operator's pool and cadence reach the units' names
|
||||
// and the file.
|
||||
func TestAComposedMachineGetsTheUnitTheSettingNames(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, scrubber())
|
||||
with := anchorRendering(false)
|
||||
with.Settings["zfs"] = []Layer{{From: "anchor", Values: map[string]any{"scrub-pool": "storage", "scrub-cadence": "monthly"}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["zfs"]; left {
|
||||
t.Fatalf("left out: %s", why)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
for id, want := range map[string]string{"zfs.distribution-weekly": "zfs-scrub-weekly@storage.timer",
|
||||
"zfs.distribution-monthly": "zfs-scrub-monthly@storage.timer"} {
|
||||
if got[id]["unit"] != want {
|
||||
t.Errorf("%s composed as %v, not %s", id, got[id]["unit"], want)
|
||||
}
|
||||
t.Logf("%s: %v", id, got[id]["unit"])
|
||||
}
|
||||
if c := got["zfs.scrub-config"]["content"]; c != "pool=storage\ncadence=monthly\n" {
|
||||
t.Errorf("the file: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A value that would not make a unit's name is refused by name, never written: a space, a slash, a
|
||||
// newline that would begin a directive, or a second suffix.
|
||||
func TestASettingThatMakesNoUnitNameIsRefused(t *testing.T) {
|
||||
m := scrubber()
|
||||
for _, bad := range []string{"", "stor age", "a/b", "storage\nExecStart=/bin/sh", "a@b", "$(x)", "*", `a\\x2d`} {
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": bad}}}, m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), "scrub-pool") || !strings.Contains(err.Error(), "unit") {
|
||||
t.Errorf("%q: %v", bad, err)
|
||||
}
|
||||
if r["unit"] != m.Resources[0]["unit"] {
|
||||
t.Errorf("%q: the unit was changed on refusal: %v", bad, r["unit"])
|
||||
}
|
||||
}
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
if err := settingInto(r, nil, m.Module); err == nil || !strings.Contains(err.Error(), "${setting:scrub-pool}") {
|
||||
t.Errorf("nothing set: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key a unit's name asks for is a destination, so setting it is not called stray, and judging the
|
||||
// settings sees it.
|
||||
func TestASettingAUnitAsksForIsNotStrayAndIsJudged(t *testing.T) {
|
||||
m := scrubber()
|
||||
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": "storage"}}}), "; ")
|
||||
if strings.Contains(stray, "scrub-pool") {
|
||||
t.Errorf("called stray: %s", stray)
|
||||
}
|
||||
if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "scrub-pool") {
|
||||
t.Errorf("a unit's setting nothing sets is not refused when judged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Third review: a setting may name only a template's instance — right after the `@`, before the final
|
||||
// suffix, and the whole instance — so a value can never make the unit another unit, nor another kind.
|
||||
// Refused where the manifest is read, near its author.
|
||||
func TestASettingInAUnitNameIsOnlyATemplatesInstance(t *testing.T) {
|
||||
ok := []string{"zfs-scrub-weekly@${setting:scrub-pool}.timer", "getty@${setting:tty}.service"}
|
||||
bad := []string{
|
||||
"${setting:unit}",
|
||||
"${setting:name}.timer",
|
||||
"zfs-scrub-${setting:cadence}@storage.timer",
|
||||
"zfs-scrub@${setting:pool}-x.timer",
|
||||
"zfs-scrub@x-${setting:pool}.timer",
|
||||
"zfs-scrub@${setting:pool}.${setting:kind}",
|
||||
"zfs-scrub@${setting:pool}",
|
||||
"zfs-scrub@${setting:a}${setting:b}.timer",
|
||||
}
|
||||
for _, unit := range append(ok, bad...) {
|
||||
m := Manifest{Module: "zfs", Resources: []map[string]any{{"id": "t", "type": "service", "unit": unit, "state": "stopped"}}}
|
||||
err := parsed(t, m)
|
||||
refused := err != nil && strings.Contains(err.Error(), "instance")
|
||||
want := false
|
||||
for _, b := range bad {
|
||||
want = want || b == unit
|
||||
}
|
||||
if refused != want {
|
||||
t.Errorf("%s: refused %v, want %v (%v)", unit, refused, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInstanceValueMayNotLeadWithADashNorBeLong(t *testing.T) {
|
||||
m := scrubber()
|
||||
for _, bad := range []string{"-storage", "--help", strings.Repeat("a", 65)} {
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": bad}}}, m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), "scrub-pool") {
|
||||
t.Errorf("%q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": strings.Repeat("a", 64)}}}, m.Module); err != nil {
|
||||
t.Errorf("64 characters: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ 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 is filled from the module's settings layers — the mesh's,
|
||||
// `${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,
|
||||
// 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
|
||||
@@ -45,6 +45,9 @@ func settingsUsed(content string) []string {
|
||||
// the defaults under the layers with WithDefaults. A value that is not a string is written the way a program would read
|
||||
// it (a number without a trailing .000000, a boolean as true/false).
|
||||
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"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
@@ -68,6 +71,67 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// unitPart is what a setting may put into a unit's name: the characters systemd allows in a unit name,
|
||||
// less the instance's `@` and the escape's `\`, and at least one of them. Anything else — a space, a slash,
|
||||
// a newline that would begin a directive in the unit file the name ends up in — is refused, never written.
|
||||
var unitPart = regexp.MustCompile(`^[A-Za-z0-9:_.][A-Za-z0-9:_.-]{0,63}$`)
|
||||
|
||||
// unitInstance is the one place a setting may stand in a unit's name: the whole instance of a template,
|
||||
// after its `@` and before its suffix — `zfs-scrub-weekly@${setting:scrub-pool}.timer` — so a value can
|
||||
// make the unit another instance of the same template and nothing else (third review of mesh-catalog #147).
|
||||
var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`)
|
||||
|
||||
// UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting
|
||||
// anywhere but as a template's whole instance.
|
||||
func UnitSettingProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "service" {
|
||||
continue
|
||||
}
|
||||
unit, _ := r["unit"].(string)
|
||||
if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") {
|
||||
continue
|
||||
}
|
||||
if !unitInstance.MatchString(unit) {
|
||||
problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+
|
||||
"instance; a setting may stand only as the whole instance, after the @ and before the suffix "+
|
||||
"(name@${setting:key}.timer)", m.Module, r["id"], unit))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// settingIntoUnit fills ${setting:…} in a service resource's unit name: a module that holds the
|
||||
// distribution's timer for the pool the operator names cannot write the pool into its definition
|
||||
// (novox/hq ADR 0112). Refused, with the key and why, when nothing sets it or the value would not make a
|
||||
// unit's name; the resource is left as it was.
|
||||
func settingIntoUnit(resource map[string]any, layers []Layer, module string) error {
|
||||
unit, ok := resource["unit"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, key := range settingsUsed(unit) {
|
||||
value, set := settingValue(layers, key)
|
||||
if !set {
|
||||
return fmt.Errorf(
|
||||
"%s has a service whose unit 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, key, key, module, key, orNoSettings(layers))
|
||||
}
|
||||
v := plainly(value)
|
||||
if !unitPart.MatchString(v) {
|
||||
return fmt.Errorf("%s: the setting %q is %q, which cannot be the instance of the unit %s: an instance "+
|
||||
"takes letters, digits, ':', '_', '.' and '-', does not begin with '-' (a systemctl option), and is "+
|
||||
"at most 64 characters", module, key, v, unit)
|
||||
}
|
||||
unit = strings.ReplaceAll(unit, "${setting:"+key+"}", v)
|
||||
}
|
||||
resource["unit"] = unit
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingValue(layers []Layer, key string) (any, bool) {
|
||||
var value any
|
||||
set := false
|
||||
@@ -106,11 +170,15 @@ func settingKeysUsedBy(m Manifest) map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
continue
|
||||
}
|
||||
if content, ok := r["content"].(string); ok {
|
||||
note(content)
|
||||
switch fmt.Sprint(r["type"]) {
|
||||
case "file":
|
||||
if content, ok := r["content"].(string); ok {
|
||||
note(content)
|
||||
}
|
||||
case "service":
|
||||
if unit, ok := r["unit"].(string); ok {
|
||||
note(unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
inValues := func(values map[string]any) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -86,6 +90,7 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
|
||||
out["content"] = string(rendered) + "\n"
|
||||
delete(out, "merge")
|
||||
delete(out, "protected")
|
||||
delete(out, TrustedField)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -209,6 +214,105 @@ func deepCopy(in map[string]any) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// settingsHoldOneLine refuses a line break, a carriage return, any other line end (lineEnds) or a NUL in any
|
||||
// string of any setting, for every
|
||||
// module, at any depth, keys as well as values, in an object or a list (novox/hq issue 339). A value is
|
||||
// substituted into env and configuration files the node-engine writes as root — an app's env file, a logind
|
||||
// drop-in — and a line break there is a line of the caller's own: a directive, an assignment, a section. A NUL
|
||||
// ends a string early wherever C reads it. Judged where a layer is kept and again where it is composed, so a
|
||||
// layer that holds one, however it got into the store, is said with its key. What must hold lines is a file
|
||||
// of the module's own, never a setting — with one exception, the certificate authority's root as certificates
|
||||
// (certificates), because that is how step-ca serves it.
|
||||
func settingsHoldOneLine(module string, layers []Layer) error {
|
||||
for _, layer := range layers {
|
||||
for _, key := range sortedKeysAny(layer.Values) {
|
||||
if module == rootModule && key == rootSetting {
|
||||
if text, ok := layer.Values[key].(string); ok && certificates(text) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if at := lineBreakIn(layer.Values[key], key); at != "" {
|
||||
return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return, another line end or a NUL, which a "+
|
||||
"file it is written into would read as a line of its own; a setting is one line (novox/hq "+
|
||||
"issue 339)", module, at, layer.From)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lineEnds are every character a reader may take as the end of a line: \n and \r, vertical tab and form feed,
|
||||
// NEL, and the Unicode line and paragraph separators; and NUL, which ends a string early wherever C reads it.
|
||||
const lineEnds = "\n\r\v\f\x00\u0085\u2028\u2029"
|
||||
|
||||
// rootSetting is the one setting that may hold lines: the certificate authority's root, which step-ca serves to
|
||||
// its consumers as the setting `root` and which they write into a bundle file (the co-located path, issue 038).
|
||||
const (
|
||||
rootModule = "step-ca"
|
||||
rootSetting = "root"
|
||||
)
|
||||
|
||||
// certificates says whether a value is one or more PEM CERTIFICATE blocks and nothing else: each decodes with
|
||||
// encoding/pem, carries no headers, and parses with x509.ParseCertificate; nothing but a final line break
|
||||
// surrounds them, and every line ends with \n alone.
|
||||
func certificates(v string) bool {
|
||||
if strings.ContainsAny(v, "\r\v\f\x00\u0085\u2028\u2029") {
|
||||
return false
|
||||
}
|
||||
rest := []byte(v)
|
||||
found := 0
|
||||
for len(rest) > 0 {
|
||||
if !bytes.HasPrefix(rest, []byte("-----BEGIN ")) {
|
||||
return false
|
||||
}
|
||||
block, after := pem.Decode(rest)
|
||||
if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) > 0 {
|
||||
return false
|
||||
}
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return false
|
||||
}
|
||||
found++
|
||||
rest = after
|
||||
}
|
||||
return found > 0
|
||||
}
|
||||
|
||||
// lineBreakIn is where the first string under v holding \n, \r or NUL is, or "".
|
||||
func lineBreakIn(v any, at string) string {
|
||||
if strings.ContainsAny(at, lineEnds) {
|
||||
return strconv.Quote(at)
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if strings.ContainsAny(t, lineEnds) {
|
||||
return at
|
||||
}
|
||||
case map[string]any:
|
||||
for _, k := range sortedKeysAny(t) {
|
||||
if found := lineBreakIn(t[k], at+"."+k); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for i, e := range t {
|
||||
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedKeysAny(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// UnusedSettings names settings that reach nothing.
|
||||
//
|
||||
// Somebody who sets a key on a module with nothing mergeable, or misspells one, has changed
|
||||
@@ -405,6 +509,9 @@ 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 := settingsHoldOneLine(m.Module, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
// With no layers too: a definition may ask for a setting nobody made — an access placed by
|
||||
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
|
||||
if _, err := GivenPorts(m, layers); err != nil {
|
||||
|
||||
@@ -29,9 +29,11 @@ const MountsSeat = "node-mounts"
|
||||
func nfsServerVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
|
||||
"read-only, the owner every client is mapped to (uid and gid), the clients it is exported to (the " +
|
||||
"private network's range), and whether the kernel holds it now. Also the exports found that are " +
|
||||
"not the mesh's: a dataset's sharenfs property, a line in /etc/exports.",
|
||||
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
|
||||
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
|
||||
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
|
||||
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
|
||||
"/etc/exports.",
|
||||
Input: schema(map[string]string{}, nil),
|
||||
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
|
||||
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
|
||||
@@ -39,11 +41,12 @@ func nfsServerVerbs() []Verb {
|
||||
Input: schema(map[string]string{}, nil),
|
||||
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
|
||||
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
|
||||
"NFS service is up; with an address, also whether that address is inside the private network's " +
|
||||
"range the share is exported to. What a machine mounting the share asks before it mounts.",
|
||||
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
|
||||
"own private address, when the server grants it the share and the node asks for it. What a machine " +
|
||||
"mounting the share asks before it mounts.",
|
||||
Input: schema(map[string]string{
|
||||
"share": "the share, by its name",
|
||||
"address": "a client's address on the private network (optional)",
|
||||
"address": "a node's private address, to ask whether the share is exported to it (optional)",
|
||||
}, []string{"share"}),
|
||||
Replaces: []string{"showmount -e"}},
|
||||
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
|
||||
|
||||
@@ -159,3 +159,21 @@ func TestReloadSaysWhatItDoes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A share is exported to each node the server grants it to and that asks for it, at that node's own
|
||||
// address (novox/hq ADR 0263 rule 5) — never to the private network's whole range. The verbs that
|
||||
// describe the export say so, and do not promise the range.
|
||||
func TestTheExportVerbsDescribePerNodeAddresses(t *testing.T) {
|
||||
s, _ := SeatNamed(NFSServerSeat)
|
||||
for _, v := range s.Serves {
|
||||
if v.Name != "exports" && v.Name != "test" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v.Description, "private network's range") {
|
||||
t.Errorf("%s still describes the export as the private network's range: %s", v.Name, v.Description)
|
||||
}
|
||||
if !strings.Contains(v.Description, "address") {
|
||||
t.Errorf("%s does not say the export is to each node's address: %s", v.Name, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Which settings are the controller's terminal's alone (novox/hq issue 339).
|
||||
//
|
||||
// A setting is the operator's word on how a module is configured, and the `settings` verb writes it for any
|
||||
// caller allowed to call verbs — agents among them. Most settings change only the module itself. Some change
|
||||
// what root or another module trusts, and those are said at the terminal alone, never through a verb:
|
||||
//
|
||||
// 1. `places` and `accesses`: where the node-engine creates and, as root, owns a module's directories, and
|
||||
// which of the machine's paths are mounted into its container.
|
||||
// 2. **Every key a provider serves**, and every setting a served value asks for. A setting overrides a served
|
||||
// key (Settle), and what is served is what every consumer of the provision connects to and believes: a
|
||||
// database's port, an object store's scheme, a registry's port, an identity provider's issuer and token
|
||||
// path. Through a verb, any caller could point every consumer at a listener of its own and collect the
|
||||
// credentials they present.
|
||||
// 3. **Every setting a file asks for, unless the file says `"trusted": false`.** A file root or a consumer trusts —
|
||||
// a logind drop-in, an env file that says which uid a container runs as, a script run as root — must not
|
||||
// change through a verb, and the safe reading of a file that says nothing is that it is one of them (fail
|
||||
// closed). `"trusted": false` is the opt-out, for a file nothing trusts: a person's own notifier settings.
|
||||
// `module check` lists the files that say nothing, so an author can opt one out where that is true.
|
||||
//
|
||||
// Derived from the manifest, never listed by hand, so a provider or a trusted file added tomorrow is covered.
|
||||
|
||||
// TrustedField is the key a file resource carries to say whether the settings it asks for are trusted: true, or
|
||||
// absent, makes each a terminal key; false says, out loud, that none changes what root or a consumer trusts. Said in the
|
||||
// catalogue, never on the machine: the composer takes it out before the node-engine, which parses strictly.
|
||||
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.
|
||||
func TerminalKeys(m Manifest) []string {
|
||||
keys := map[string]bool{}
|
||||
for _, served := range m.Serves {
|
||||
for key, value := range served {
|
||||
keys[key] = true
|
||||
if s, ok := value.(string); ok {
|
||||
for _, asked := range settingsUsed(s) {
|
||||
keys[asked] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
continue
|
||||
}
|
||||
if trusted, said := r[TrustedField].(bool); said && !trusted {
|
||||
continue
|
||||
}
|
||||
if content, ok := r["content"].(string); ok {
|
||||
for _, asked := range settingsUsed(content) {
|
||||
keys[asked] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(keys, PlacesSetting)
|
||||
delete(keys, AccessesSetting)
|
||||
rest := make([]string, 0, len(keys))
|
||||
for k := range keys {
|
||||
rest = append(rest, k)
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return append([]string{PlacesSetting, AccessesSetting}, rest...)
|
||||
}
|
||||
|
||||
// UnsaidTrust is every file of a module that asks for a setting and does not say whether it is trusted, by id:
|
||||
// each counts as trusted, and is listed so an author can opt out a file nothing trusts.
|
||||
func UnsaidTrust(m Manifest) []string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
continue
|
||||
}
|
||||
content, _ := r["content"].(string)
|
||||
if len(settingsUsed(content)) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, said := r[TrustedField]; !said {
|
||||
out = append(out, fmt.Sprint(r["id"]))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but
|
||||
// a file. Refused at registration and by `module check`.
|
||||
func TrustProblems(m Manifest) []string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
v, said := r[TrustedField]
|
||||
if !said {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+
|
||||
"asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], TrustedField))
|
||||
continue
|
||||
}
|
||||
if _, ok := v.(bool); !ok {
|
||||
out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)",
|
||||
m.Module, r["id"], TrustedField, v))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// No placement and no access at the machine's own system or the mesh's state, however it is spelled (novox/hq
|
||||
// issue 339); a module's own place elsewhere is taken.
|
||||
func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) {
|
||||
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
|
||||
Accesses: []Access{{ID: "media"}}}
|
||||
for _, path := range []string{"/", "/etc", "/etc/", "/etc/sudoers.d", "/usr/bin", "/root", "/var", "/var/lib",
|
||||
"/home", "/home/", "/run", "/run/user/1000", "/var/lib/mesh/x", "/var/lib/mesh-host", "/var/lib/mesh-host/identity",
|
||||
"/srv/../etc", "//etc", "/proc/1", "/sys", "/dev", "/boot/efi", "/bin", "/sbin", "/lib", "/lib64"} {
|
||||
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
|
||||
if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("a place at %s: %v", path, err)
|
||||
}
|
||||
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
|
||||
if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("an access at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/services/media/movies",
|
||||
"/var/lib/notes/data", "/home/restic/repo", "/var/lib/mesh-store"} {
|
||||
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
|
||||
if got, err := Places(m, layers); err != nil || got["data"].Path != path {
|
||||
t.Errorf("a place at %s: %v %v", path, got, err)
|
||||
}
|
||||
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
|
||||
if got, err := AccessPlaces(m, layers); err != nil || got["media"] != path {
|
||||
t.Errorf("an access at %s: %v %v", path, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A line break, a carriage return or a NUL in any string of any setting is refused, at any depth, keys too —
|
||||
// where a layer is judged, which is both where it is kept and where it is composed (novox/hq issue 339).
|
||||
func TestASettingHoldsOneLine(t *testing.T) {
|
||||
m := Manifest{Module: "mailu"}
|
||||
for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", []any{"ok", "x\ny"},
|
||||
map[string]any{"k": []any{"ok", map[string]any{"deep": "x\ny"}}}, map[string]any{"k\nx": "v"}} {
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "line break") {
|
||||
t.Errorf("%q: %v", v, err)
|
||||
}
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v\nPATH": "x"}}}, false); err == nil {
|
||||
t.Error("a line break in a key was taken")
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": "one line", "n": 3.0,
|
||||
"l": []any{"a", "b"}}}}, false); err != nil {
|
||||
t.Errorf("one line each: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Lines are allowed in one setting only: step-ca's `root`, as certificates that encoding/pem decodes and
|
||||
// x509.ParseCertificate parses (novox/hq issue 339). Any other module or key, another label, or a block that is
|
||||
// not a certificate is refused like any other line break.
|
||||
func TestOnlyTheAuthoritysRootMayHoldLines(t *testing.T) {
|
||||
ca := Manifest{Module: "step-ca"}
|
||||
judge := func(m Manifest, key, v string) error {
|
||||
return JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{key: v}}}, false)
|
||||
}
|
||||
for _, ok := range []string{servedRoot, servedRoot + servedRoot, strings.TrimSuffix(servedRoot, "\n")} {
|
||||
if err := judge(ca, "root", ok); err != nil {
|
||||
t.Errorf("the authority's root: %v", err)
|
||||
}
|
||||
}
|
||||
body := strings.TrimSuffix(strings.TrimPrefix(servedRoot, "-----BEGIN CERTIFICATE-----\n"), "-----END CERTIFICATE-----\n")
|
||||
for name, bad := range map[string]string{
|
||||
"a line after it": servedRoot + "PATH=/tmp\n",
|
||||
"a line before it": "PATH=\n" + servedRoot,
|
||||
"another label": "-----BEGIN PRIVATE KEY-----\n" + body + "-----END PRIVATE KEY-----\n",
|
||||
"headers": "-----BEGIN CERTIFICATE-----\nProc-Type: 4,ENCRYPTED\n\n" + body + "-----END CERTIFICATE-----\n",
|
||||
"base64 that is no cert": "-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQfake000000000000000000000000\n-----END CERTIFICATE-----\n",
|
||||
"carriage returns": strings.ReplaceAll(servedRoot, "\n", "\r\n"),
|
||||
} {
|
||||
if err := judge(ca, "root", bad); err == nil {
|
||||
t.Errorf("%s was taken", name)
|
||||
}
|
||||
}
|
||||
if err := judge(ca, "other", servedRoot); err == nil {
|
||||
t.Error("a certificate in another key of the authority was taken")
|
||||
}
|
||||
if err := judge(Manifest{Module: "mailu"}, "root", servedRoot); err == nil {
|
||||
t.Error("a certificate in another module's setting was taken")
|
||||
}
|
||||
if err := JudgeSettings(ca, []Layer{{From: "anchor", Values: map[string]any{"root": []any{servedRoot}}}}, false); err == nil {
|
||||
t.Error("a certificate below the top of the setting was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Every character a reader takes as the end of a line is refused, not only \n and \r: vertical tab, form feed,
|
||||
// NEL and the Unicode line and paragraph separators (novox/hq issue 339).
|
||||
func TestEveryLineEndIsRefused(t *testing.T) {
|
||||
m := Manifest{Module: "mailu"}
|
||||
for _, v := range []string{"a\vb", "a\fb", "a\u0085b", "a\u2028b", "a\u2029b"} {
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "line break") {
|
||||
t.Errorf("%q: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The review's additions: the spool, the container runtimes' data, /opt, and any home's .ssh.
|
||||
func TestTheRuntimesDataAndAnyHomesSSHAreTheMachinesOwn(t *testing.T) {
|
||||
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
|
||||
Accesses: []Access{{ID: "media"}}}
|
||||
for _, path := range []string{"/var/spool", "/var/spool/cron", "/var/lib/docker", "/var/lib/docker/volumes",
|
||||
"/var/lib/containers/storage", "/var/lib/containerd", "/var/lib/containerd/io.containerd.snapshotter.v1", "/opt", "/opt/app", "/home/alice/.ssh", "/home/alice/.ssh/keys",
|
||||
"/srv/backup/.ssh", "/root/.ssh"} {
|
||||
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
|
||||
if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("a place at %s: %v", path, err)
|
||||
}
|
||||
layers = []Layer{{From: "laptop", Values: map[string]any{AccessesSetting: map[string]any{"media": path}}}}
|
||||
if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("an access at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/var/lib/dockerish", "/home/alice/ssh", "/home/alice/.sshd-notes", "/storage/media"} {
|
||||
layers := []Layer{{From: "laptop", Values: map[string]any{PlacesSetting: map[string]any{"data": path}}}}
|
||||
if _, err := Places(m, layers); err != nil {
|
||||
t.Errorf("a place at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What a provider serves its consumers is the terminal's (novox/hq issue 339): any key under its `serves`, and any
|
||||
// setting a served value asks for, is set at the terminal alone — a verb that could change a port could point every
|
||||
// consumer at a listener of the caller's own. So is any setting a file marked `trusted` asks for.
|
||||
func TestTerminalKeysAreDerived(t *testing.T) {
|
||||
postgres := Manifest{Module: "postgres", Serves: map[string]map[string]any{"postgres-database": {"port": 5432.0}}}
|
||||
keycloak := Manifest{Module: "keycloak", Serves: map[string]map[string]any{"oidc-client": {
|
||||
"issuer": "${setting:issuer}", "token-path": "/protocol/openid-connect/token"}}}
|
||||
power := Manifest{Module: "power", Resources: []map[string]any{
|
||||
{"id": "logind", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf", "trusted": true,
|
||||
"content": "HandleLidSwitch=${setting:handle-lid-switch}\n"},
|
||||
{"id": "note", "type": "file", "path": "/var/lib/power/note", "trusted": false, "content": "${setting:greeting}\n"},
|
||||
// Unmarked counts as trusted (fail closed): only `"trusted": false` lets a verb change what a file asks for.
|
||||
{"id": "unmarked", "type": "file", "path": "/etc/power/unmarked", "content": "${setting:unmarked}\n"}}}
|
||||
for _, c := range []struct {
|
||||
m Manifest
|
||||
want string
|
||||
}{
|
||||
{postgres, "places,accesses,port"},
|
||||
{keycloak, "places,accesses,issuer,token-path"},
|
||||
{power, "places,accesses,handle-lid-switch,unmarked"},
|
||||
{Manifest{Module: "plain"}, "places,accesses"},
|
||||
} {
|
||||
if got := strings.Join(TerminalKeys(c.m), ","); got != c.want {
|
||||
t.Errorf("%s: %s; want %s", c.m.Module, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a
|
||||
// boolean, on a file alone, and a file asking for a setting without it is named.
|
||||
func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) {
|
||||
m := Manifest{Module: "power", Resources: []map[string]any{
|
||||
{"id": "said", "type": "file", "path": "/etc/a", "trusted": true, "content": "${setting:a}"},
|
||||
{"id": "unsaid", "type": "file", "path": "/etc/b", "content": "${setting:b}"},
|
||||
{"id": "no-setting", "type": "file", "path": "/etc/c", "content": "plain"}}}
|
||||
if got := strings.Join(UnsaidTrust(m), ","); got != "unsaid" {
|
||||
t.Errorf("unsaid: %s; want unsaid", got)
|
||||
}
|
||||
for _, bad := range []map[string]any{
|
||||
{"id": "x", "type": "file", "path": "/etc/x", "trusted": "yes", "content": "${setting:a}"},
|
||||
{"id": "y", "type": "directory", "trusted": true},
|
||||
} {
|
||||
if problems := TrustProblems(Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 {
|
||||
t.Errorf("%v was taken", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,27 @@ type InMemory struct {
|
||||
values map[string]Entry
|
||||
revision uint64
|
||||
events []Event
|
||||
// Fail, when set, is what every read and write answers: a store that is away.
|
||||
Fail error
|
||||
// fail, when set, is what every read and write answers: a store that is away. It is set only
|
||||
// through SetFail, under the lock, because the keeper's telling goroutine reads it while a test
|
||||
// takes the store away.
|
||||
fail error
|
||||
}
|
||||
|
||||
// NewInMemory is an empty store.
|
||||
func NewInMemory() *InMemory { return &InMemory{values: map[string]Entry{}} }
|
||||
|
||||
// SetFail makes every read and write answer err from now on, or none when err is nil.
|
||||
func (m *InMemory) SetFail(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.fail = err
|
||||
}
|
||||
|
||||
func (m *InMemory) Get(_ context.Context, key string) (Entry, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return Entry{}, false, m.Fail
|
||||
if m.fail != nil {
|
||||
return Entry{}, false, m.fail
|
||||
}
|
||||
e, ok := m.values[key]
|
||||
return e, ok, nil
|
||||
@@ -36,8 +45,8 @@ func (m *InMemory) Get(_ context.Context, key string) (Entry, bool, error) {
|
||||
func (m *InMemory) Create(_ context.Context, key string, value []byte) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return m.Fail
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
if _, ok := m.values[key]; ok {
|
||||
return ErrMoved
|
||||
@@ -50,8 +59,8 @@ func (m *InMemory) Create(_ context.Context, key string, value []byte) error {
|
||||
func (m *InMemory) Update(_ context.Context, key string, value []byte, revision uint64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return m.Fail
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
if e, ok := m.values[key]; !ok || e.Revision != revision {
|
||||
return ErrMoved
|
||||
@@ -64,8 +73,8 @@ func (m *InMemory) Update(_ context.Context, key string, value []byte, revision
|
||||
func (m *InMemory) Delete(_ context.Context, key string, revision uint64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return m.Fail
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
if e, ok := m.values[key]; !ok || e.Revision != revision {
|
||||
return ErrMoved
|
||||
@@ -77,8 +86,8 @@ func (m *InMemory) Delete(_ context.Context, key string, revision uint64) error
|
||||
func (m *InMemory) All(context.Context) (map[string]Entry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return nil, m.Fail
|
||||
if m.fail != nil {
|
||||
return nil, m.fail
|
||||
}
|
||||
out := make(map[string]Entry, len(m.values))
|
||||
for k, v := range m.values {
|
||||
@@ -90,8 +99,8 @@ func (m *InMemory) All(context.Context) (map[string]Entry, error) {
|
||||
func (m *InMemory) Append(_ context.Context, e Event) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return m.Fail
|
||||
if m.fail != nil {
|
||||
return m.fail
|
||||
}
|
||||
m.events = append(m.events, e)
|
||||
return nil
|
||||
@@ -100,8 +109,8 @@ func (m *InMemory) Append(_ context.Context, e Event) error {
|
||||
func (m *InMemory) Since(_ context.Context, since time.Time) ([]Event, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Fail != nil {
|
||||
return nil, m.Fail
|
||||
if m.fail != nil {
|
||||
return nil, m.fail
|
||||
}
|
||||
var out []Event
|
||||
for _, e := range m.events {
|
||||
@@ -118,14 +127,22 @@ type Told struct {
|
||||
mu sync.Mutex
|
||||
Events []Event
|
||||
Names []string
|
||||
Fail error
|
||||
// fail, when set, is what every publish answers; set only through SetFail, under the lock.
|
||||
fail error
|
||||
}
|
||||
|
||||
// SetFail makes every publish answer err from now on, or none when err is nil.
|
||||
func (t *Told) SetFail(err error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.fail = err
|
||||
}
|
||||
|
||||
func (t *Told) PublishSeatEvent(_ context.Context, seat, event string, body []byte) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.Fail != nil {
|
||||
return t.Fail
|
||||
if t.fail != nil {
|
||||
return t.fail
|
||||
}
|
||||
if seat != Seat {
|
||||
return errors.New("told under the wrong seat: " + seat)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A test may take the store away while the keeper is still telling.** The keeper keeps every
|
||||
// transition from a goroutine of its own, so the memory store's failure is read there while a test
|
||||
// switches it: it is switched under the store's lock, or the race detector fails the suite at random
|
||||
// (it once failed TestAnUnreadableStoreClearsNothing). A hundred transitions still being kept while
|
||||
// the failure is switched a hundred times makes the race certain, not rare, when the lock is skipped.
|
||||
func TestTheStoreIsTakenAwayWhileTheKeeperIsTelling(t *testing.T) {
|
||||
k, store, told, _ := keeper(t)
|
||||
ctx := t.Context()
|
||||
const n = 100
|
||||
for i := range n {
|
||||
if _, err := k.Observe(ctx, silent(fmt.Sprintf("m%d", i))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for range n {
|
||||
store.SetFail(errors.New("the bus is away"))
|
||||
store.SetFail(nil)
|
||||
}
|
||||
told.SetFail(errors.New("no responders"))
|
||||
told.SetFail(nil)
|
||||
settled(t, told, n)
|
||||
}
|
||||
@@ -199,15 +199,17 @@ func TestAnUnreadableStoreClearsNothing(t *testing.T) {
|
||||
if _, err := k.Observe(ctx, silent("ace")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Fail = errors.New("the bus is away")
|
||||
store.SetFail(errors.New("the bus is away"))
|
||||
if err := k.Reconcile(ctx, "S1", nil); err == nil {
|
||||
t.Fatal("reconciled against a store it could not read")
|
||||
}
|
||||
if _, err := k.Open(ctx); err == nil {
|
||||
t.Fatal("an unreadable store answered as read")
|
||||
}
|
||||
store.Fail = nil
|
||||
store.SetFail(nil)
|
||||
store.mu.Lock()
|
||||
store.values["machine.g14.silent"] = Entry{Value: []byte("{not a condition"), Revision: 99}
|
||||
store.mu.Unlock()
|
||||
if _, err := k.Open(ctx); err == nil || !strings.Contains(err.Error(), "machine.g14.silent") {
|
||||
t.Fatalf("an unreadable condition was left out rather than said: %v", err)
|
||||
}
|
||||
@@ -362,16 +364,15 @@ func TestTheEventShapeIsTheContract(t *testing.T) {
|
||||
|
||||
// **A transition the bus will not take is offered again**, and said lost only after TellFor.
|
||||
func TestATransitionIsOfferedAgainWhileTheBusIsAway(t *testing.T) {
|
||||
store, told, c := NewInMemory(), &Told{Fail: errors.New("no responders")}, newClock()
|
||||
store, told, c := NewInMemory(), &Told{}, newClock()
|
||||
told.SetFail(errors.New("no responders"))
|
||||
k := NewKeeper(t.Context(), Options{Store: store, History: store, Teller: told, Now: c.now})
|
||||
defer k.Close(context.Background())
|
||||
if _, err := k.Observe(t.Context(), silent("ace")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
told.mu.Lock()
|
||||
told.Fail = nil
|
||||
told.mu.Unlock()
|
||||
told.SetFail(nil)
|
||||
said := settled(t, told, 1)
|
||||
if said[0].Key != "machine.ace.silent" || k.Unsaid() != 0 {
|
||||
t.Fatalf("said %+v, unsaid %d", said, k.Unsaid())
|
||||
|
||||
@@ -446,6 +446,14 @@ const KindUnit = "unit"
|
||||
// starting ReasonRelogin when only a new login is missing.
|
||||
const KindAccount = "account"
|
||||
|
||||
// KindDirectory is a directory of a module that the node-engine uses as found (novox/hq issue 339): there before
|
||||
// the mesh, with another owner or mode than declared, and left so until a person hands it over at the machine
|
||||
// (`mesh-host hand-over`). Stated unhealthy with a reason that starts ReasonUsedAsFound.
|
||||
const KindDirectory = "directory"
|
||||
|
||||
// ReasonUsedAsFound starts the reason of a directory used as found.
|
||||
const ReasonUsedAsFound = "used as found:"
|
||||
|
||||
// ReasonRelogin starts the reason of an account whose running session began before it was put in a group
|
||||
// (ADR 0252): the build did what it should, and a person has one step left (novox/hq ADR 0254).
|
||||
const ReasonRelogin = "relogin needed"
|
||||
|
||||
Reference in New Issue
Block a user