Compare commits

..
2 Commits
Author SHA1 Message Date
jochen f0a62c1b47 Store optional requirements beside a plain requires list (hq issue 166, review of #281)
mesh/merge-gate head f0a62c1b473f merged into trunk 454a3db69a1e — fail: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; head f…
mesh/repo-check head f0a62c1b473f merged into trunk 454a3db69a1e — fail: head f0a62c1b473f does not merge cleanly into trunk 454a3db69a1e: in conflict test…
mesh/delivery rejected: the gate failed or could not run, or the repository's own check failed
A controller older than optional requirements reads `requires` as names, and Inventory.Catalogue
stops at the first manifest it cannot read: the long form stored inside `requires` would have left
a rollback below this controller composing no machine. Stored, `requires` stays plain and the
optional names sit in `optional-requires`, a key an older controller sets aside, leaving out only
the module carrying it. A refusal of a hard requirement now names a module that wants it hard.
2026-10-13 01:16:32 +02:00
jochen bc81029a03 Let a requirement be optional, binding only a provider that already runs (hq issue 166, ADR 0330)
mesh/merge-gate out of date: judged against trunk 56d6313262e8, the trunk is now cbac703e817d; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 56d6313262e8, the trunk is now cbac703e817d; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
Making every soft dependency a provision made it hard: a mesh without influxdb could not assign
grafana, and one with influxdb in its catalogue would install it for grafana. A requirement may now
be written {"name": ..., "optional": true}. It resolves as a hard one when a provider is assigned or
reachable, and is otherwise absent: never refused, nothing installed for it. Its binding, credential
and only-when-bound files are left out, its ${bound:...|default:...} placeholders take their
defaults, the module check refuses one that could render empty, and plan and node list it unbound.
2026-10-13 00:39:58 +02:00
42 changed files with 937 additions and 766 deletions
+1 -6
View File
@@ -64,11 +64,6 @@ type actor struct {
// the highest epoch issued, and what was said of it; zero when it was not (S12).
reset time.Time
resetSaid string
// renewEvery and poll, when set, are how often the serving controller renews the lease it holds and
// looks again at one another holds, in place of the lease's own: a test's, so two controllers hand the
// lease over in a second rather than in a renewal of five (novox/hq issue 591). A renewal is given
// the lease's own RenewEvery to answer however often it is made, so a slow bus does not lose it.
renewEvery, poll time.Duration
}
// theLease is this process's standing to the lease.
@@ -228,7 +223,7 @@ func (a *actor) serveUnderTheLease(ctx context.Context, inv *inventory.Inventory
}
say := func(format string, args ...any) { fmt.Printf(format+"\n", args...) }
l, err := lease.Open(ctx, api, broker.LeaseBucket, lease.Options{Holder: holderOf(instance),
RenewEvery: a.renewEvery, RenewWithin: lease.RenewEvery, Poll: a.poll, Floor: inv.HighestEpoch, Say: say, Health: controllerHealth, Moved: func(was, floor uint64) {
Floor: inv.HighestEpoch, Say: say, Health: controllerHealth, Moved: func(was, floor uint64) {
a.mu.Lock()
defer a.mu.Unlock()
a.reset = time.Now()
-10
View File
@@ -75,16 +75,6 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
failed += len(wrong)
faulted[m.Module] = true
}
// A module that declares a setting names the keys of its mergeable files (novox/hq ADR 0326 §3,
// issue 574): judged where a definition is written, as at registration, never where a stored
// build is read again.
if wrong := catalogue.WrittenSettingsProblems(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)
-14
View File
@@ -429,20 +429,6 @@ func checkedOf(result link.BuildResult) link.Checked {
l.Verdict = "error"
}
}
// **A check run again says so** (novox/hq issue 594). The summaries are the lines the pull request
// shows, clipped by the forge to a few words, so they get only a short marker after the verdict's own
// words; the report gets the whole sentence. Layers copied, so the build's own outcome is left as it came.
if mark := link.RunMarker(result.Delivery); mark != "" {
c.Summary += " " + mark
for _, l := range []**link.CheckLayer{&c.Gate, &c.RepoCheck} {
if *l != nil {
layer := **l
layer.Summary += " " + mark
*l = &layer
}
}
c.Report = link.Restarted(result.Delivery) + "\n\n" + c.Report
}
if len(c.Report) > maxCheckReport {
c.Report = "…" + c.Report[len(c.Report)-maxCheckReport:]
}
+4 -11
View File
@@ -55,14 +55,8 @@ func TestTwoControllersOneActs(t *testing.T) {
inv := inventory.ForTest(t)
ctx := t.Context()
// Each renews five times and looks again ten times as often as a serving controller does, so the
// handover and the loss below are seen in a second, not in a renewal of five (novox/hq issue 591): what
// is checked is the order of events, which does not change with the interval. Each renewal still has a
// serving controller's five seconds to answer (acting.go), so a slow bus does not lose A the lease.
const renewEvery, poll = lease.RenewEvery / 5, lease.Poll / 10
// Controller A takes the lease.
a := &actor{renewEvery: renewEvery, poll: poll}
a := &actor{}
aCtx, stopA := context.WithCancel(ctx)
defer stopA()
lostA, err := a.serveUnderTheLease(aCtx, inv, url)
@@ -75,7 +69,7 @@ func TestTwoControllersOneActs(t *testing.T) {
}
// Controller B starts while A holds it, and waits — acting on nothing meanwhile.
b := &actor{renewEvery: renewEvery, poll: poll}
b := &actor{}
bCtx, stopB := context.WithCancel(ctx)
defer stopB()
tookB := make(chan (<-chan struct{}), 1)
@@ -88,11 +82,10 @@ func TestTwoControllersOneActs(t *testing.T) {
}
tookB <- lost
}()
// B looks fifteen times, across one of A's renewals at least.
select {
case <-tookB:
t.Fatal("B took the lease while A held it")
case <-time.After(15 * poll):
case <-time.After(3 * time.Second):
}
if _, err := b.epoch(ctx); !errors.Is(err, lease.ErrNotHeld) {
t.Fatalf("B, waiting, may act: %v", err)
@@ -132,7 +125,7 @@ func TestTwoControllersOneActs(t *testing.T) {
}
select {
case <-lostB:
case <-time.After(2 * renewEvery):
case <-time.After(2 * lease.RenewEvery):
t.Fatal("B was not told it lost the lease")
}
if _, err := b.epoch(ctx); !errors.Is(err, lease.ErrNotHeld) {
+9 -6
View File
@@ -237,7 +237,15 @@ func moduleCommand(ctx context.Context, args []string) error {
says = append(says, "provides "+describeOffers(m.Provides))
}
if len(m.Requires) > 0 {
says = append(says, "requires "+strings.Join(m.Requires, ", "))
// An optional requirement says so (novox/hq ADR 0330): it is not refused when absent.
required := make([]string, 0, len(m.Requires))
for _, r := range m.Requires {
if m.IsOptional(r) {
r += " (optional)"
}
required = append(required, r)
}
says = append(says, "requires "+strings.Join(required, ", "))
}
for _, c := range m.Claims {
says = append(says, "claims "+c.At()+"/"+c.Name)
@@ -1051,11 +1059,6 @@ func namesNoInstallation(m catalogue.Manifest) error {
if problems := catalogue.TrustProblems(m); len(problems) > 0 {
return fmt.Errorf("%s", strings.Join(problems, "; "))
}
// And a module that declares a setting names the keys of its mergeable files (novox/hq ADR 0326
// §3, issue 574): judged where a definition is written, never where a stored build is read again.
if problems := catalogue.WrittenSettingsProblems(m); len(problems) > 0 {
return fmt.Errorf("%s", strings.Join(problems, "; "))
}
named := catalogue.InstallationProblems(m)
if len(named) == 0 {
return nil
+7
View File
@@ -929,6 +929,13 @@ func showNode(ctx context.Context, stored *stores, name string) error {
for _, line := range notAppliedLines(name, leftOutOf(plan, settings, node.Adopted)) {
fmt.Println(line)
}
// And which optional requirements of its modules nothing assigned answers (novox/hq ADR 0330 §7).
if lines := unboundLines(plan); len(lines) > 0 {
fmt.Println()
for _, line := range lines {
fmt.Println(line)
}
}
}
return nil
}
+11
View File
@@ -1315,6 +1315,14 @@ func planCommand(ctx context.Context, args []string) error {
fmt.Printf("%s is assigned nothing\n", args[0])
return nil
}
// Which optional requirements are unbound (novox/hq ADR 0330 §7): beside the document on the
// error stream where the answer is the declaration itself, which carries no such field — the
// node-engine refuses an envelope key it does not know.
if *asDiff || *asJSON {
for _, line := range unboundLines(plan) {
fmt.Fprintf(os.Stderr, "%s:%s\n", args[0], line)
}
}
if *asDiff {
declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil {
@@ -1396,6 +1404,9 @@ func planCommand(ctx context.Context, args []string) error {
for _, n := range plan.Needs {
fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For)
}
for _, line := range unboundLines(plan) {
fmt.Println(line)
}
declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil {
return err
+2 -3
View File
@@ -30,9 +30,8 @@ var maxPlanWait = link.LongestAnswer - planWaitStart
// answered that the call is still running, and `calls` with its id says what came of it.
const planWaitStart = 5 * time.Second
// planWaitEvery is how often a wait reads the plan again. A variable so a test reads it more often
// (novox/hq issue 591).
var planWaitEvery = time.Second
// planWaitEvery is how often a wait reads the plan again.
const planWaitEvery = time.Second
// The things a wait waits until.
var planWaitUntil = []string{"moved", "tier", "done"}
+2 -8
View File
@@ -138,12 +138,6 @@ func TestThePlansVerbComposesAWait(t *testing.T) {
func TestAPlanFollowedFromTheStoreAnswersWhenItMoves(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
// Read every 50ms rather than every second, so the wait answers as soon as the plan moves and the bound
// below passes in a fraction of a second (novox/hq issue 591); the move still waits 1.5s, long enough
// for the wait to have read the plan before it.
was := planWaitEvery
planWaitEvery = 50 * time.Millisecond
t.Cleanup(func() { planWaitEvery = was })
now := time.Now().UTC()
walk := inventory.Plan{ID: "plan-229", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c229c229",
Created: now, State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}},
@@ -175,11 +169,11 @@ func TestAPlanFollowedFromTheStoreAnswersWhenItMoves(t *testing.T) {
if got.Came != "tier" || got.Plan.Tier != 1 || got.Plan.State != inventory.PlanRolling {
t.Fatalf("the wait answered %+v", got)
}
if waited := time.Since(began); waited > 3*time.Second {
if waited := time.Since(began); waited > 10*time.Second {
t.Errorf("it answered after %s, not when the plan moved", waited)
}
out = printed(t, func() error {
return plansCommand(ctx, []string{"wait", walk.ID, "--bound", "300ms"})
return plansCommand(ctx, []string{"wait", walk.ID, "--bound", "2"})
})
if err := json.Unmarshal([]byte(out), &got); err != nil || got.Came != "bound" || got.Until != "moved" {
t.Fatalf("a plan that did not move answered %+v (%v)", got, err)
+1 -8
View File
@@ -141,9 +141,6 @@ type queuedAnswer struct {
// Position is its place among the waiting, 1 being handed out next; 0 for an ask not waiting.
Position int `json:"position,omitempty"`
Seq uint64 `json:"seq"`
// Delivery is the how-manyth time an ask in flight was handed out; more than 1 is a restart
// (novox/hq issue 594).
Delivery int `json:"delivery,omitempty"`
}
// aCommitRef is a ref that names a commit by its full hash (SHA-1 or SHA-256).
@@ -167,7 +164,7 @@ func queueAnswerOf(q link.Queue, now time.Time) queueAnswer {
}
return queuedAnswer{ID: a.ID, State: a.State, Kind: kind, Repository: link.WithoutPassword(a.Repository), Path: a.Path,
Ref: a.Ref, Commit: commit, Machine: a.On, WasOn: a.Was, AskedAt: a.AskedAt, Since: since,
Position: position, Seq: a.Seq, Delivery: a.Delivery}
Position: position, Seq: a.Seq}
}
for _, a := range running {
out.Asks = append(out.Asks, one(a, 0))
@@ -210,10 +207,6 @@ func queueText(q link.Queue, now time.Time) string {
switch {
case a.On != "":
where = fmt.Sprintf("on %s for %s", a.On, now.Sub(a.Started).Round(time.Second))
// Started again after an earlier taker left it (novox/hq issue 594).
if a.Delivery > 1 {
where += fmt.Sprintf(", handed out %d times", a.Delivery)
}
case a.Was != "":
where = fmt.Sprintf("handed back by %s, to be handed out again", a.Was)
}
-56
View File
@@ -1,56 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/link"
)
// **A check that ran again says so on the pull request** (novox/hq issue 594): a build agent replaced
// mid-check leaves the ask to be handed out again, and the verdict that finally comes is from a second
// run. Each layer's summary — what the forge shows as the check's line — names the restart.
func TestARestartedCheckSaysSoOnEveryLayer(t *testing.T) {
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 279}
gate := &link.CheckLayer{Verdict: "pass", Summary: "every node composes"}
repo := &link.CheckLayer{Verdict: "pass", Summary: "merge-check.sh passed"}
c := checkedOf(link.BuildResult{ID: "b", Ref: "abc", On: "shanks", Checked: asked, Delivery: 2,
Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes", Gate: gate, RepoCheck: repo}})
for name, said := range map[string]string{"summary": c.Summary, "gate": c.Gate.Summary,
"repository check": c.RepoCheck.Summary} {
// The verdict's own words first, then the marker: the forge clips a status to a few words.
if !strings.HasSuffix(said, " (run 2)") || strings.HasPrefix(said, "(run") {
t.Errorf("the %s says %q: a check run twice reads as run once, or its marker pushes its words out", name, said)
}
}
if !strings.HasPrefix(c.Report, "restarted: handed out 2 times") {
t.Errorf("the report does not open with the restart: %q", c.Report)
}
if gate.Summary != "every node composes" {
t.Errorf("the build's own outcome was changed under it: %q", gate.Summary)
}
// Taken once, nothing is added.
once := checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Delivery: 1,
Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes"}})
if strings.Contains(once.Summary, "(run") || strings.Contains(once.Report, "restarted") {
t.Errorf("a check run once says %q", once.Summary)
}
}
// And the queue says it of the ask in flight.
func TestTheQueueSaysAnAskInFlightRestarted(t *testing.T) {
now := time.Date(2026, 10, 13, 0, 16, 0, 0, time.UTC)
q := link.Queue{Seat: link.TheBuildMachine, Delivered: 1, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{
{Seq: 1, ID: "build-279", Repository: "ssh://forge/novox/mesh-controller", AskedAt: now.Add(-7 * time.Minute),
State: link.AskInFlight, On: "shanks", Started: now.Add(-time.Minute), Delivery: 2},
}}
text := queueText(q, now)
if !strings.Contains(text, "handed out 2 times") {
t.Fatalf("the queue does not say the ask was handed out again:\n%s", text)
}
if a := queueAnswerOf(q, now).Asks[0]; a.Delivery != 2 {
t.Errorf("the queue's data says delivery %d, want 2", a.Delivery)
}
}
-8
View File
@@ -26,14 +26,6 @@ import (
// shape fails the suite, naming its source. The keeper would say such a summary in words at run time;
// this is where the producer is made to say it rightly in the first place.
func TestMain(m *testing.M) {
// **A command line this suite runs ends when it is done** (novox/hq issue 591). Many tests run this test
// binary as a verb's command line, and a binary built with the race detector sleeps a second before it
// exits (GORACE's atexit_sleep_ms), which was more than the rest of each such test. The children still
// run under the race detector and still fail on a race they find; they only stop waiting at their end.
// Set before any child starts, and only where nobody chose GORACE.
if _, chosen := os.LookupEnv("GORACE"); !chosen {
_ = os.Setenv("GORACE", "atexit_sleep_ms=0")
}
// The process a mesh-cli test runs as a command line: it says the verb and the caller it was given, and
// ends (meshcli_test.go).
// The process a mesh-cli test runs as `secret accept`: it reads its value exactly as `secret accept` does
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"fmt"
"github.com/novox/mesh-controller/internal/catalogue"
)
// unboundLines say which optional requirements of a machine's modules nothing assigned answers
// (novox/hq ADR 0330 §7): left unbound rather than refused, with nothing installed for them, so the
// module runs without — and said, because a data source that never appears is otherwise a silence.
// Each binds without anybody's act on the first composition after a provider is assigned.
func unboundLines(plan catalogue.Resolution) []string {
lines := make([]string, 0, len(plan.Absent))
for _, a := range plan.Absent {
lines = append(lines, fmt.Sprintf(
" unbound %s, optional for %s — nothing assigned provides it; it binds once a provider is "+
"assigned (novox/hq ADR 0330)", a.Requirement, a.Consumer))
}
return lines
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A machine running grafana without influxdb says so (novox/hq ADR 0330 §7, issue 166): `plan` and
// `node` list the optional requirement nothing assigned answers, and the requirement is left
// absent rather than refused — influxdb is in the catalogue and is not installed for it.
func TestPlanAndNodeListAnOptionalRequirementLeftUnbound(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "influxdb", Version: "1",
Provides: catalogue.FromAnywhere("influxdb-api")})
register(t, open, catalogue.Manifest{Module: "grafana", Version: "1",
Requires: []string{"influxdb-api"}, OptionalRequires: map[string]bool{"influxdb-api": true},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/grafana", "mode": "0700"}}})
if _, err := assign(ctx, open, "laptop", "grafana"); err != nil {
t.Fatalf("grafana could not be assigned on a mesh without influxdb: %v", err)
}
plan, _, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatal(err)
}
for _, m := range plan.Modules {
if m.Module == "influxdb" {
t.Fatal("influxdb was installed for grafana's optional requirement (ADR 0330 §3)")
}
}
const said = "unbound influxdb-api, optional for grafana"
if shown := printed(t, func() error { return planCommand(ctx, []string{"laptop"}) }); !strings.Contains(shown, said) {
t.Errorf("plan laptop does not say influxdb-api is unbound:\n%s", shown)
}
if shown := printed(t, func() error { return showNode(ctx, open, "laptop") }); !strings.Contains(shown, said) {
t.Errorf("node show laptop does not say influxdb-api is unbound:\n%s", shown)
}
// And once influxdb is assigned somewhere, grafana binds to it with no act on grafana.
if _, err := assign(ctx, open, "anchor", "influxdb"); err != nil {
t.Fatal(err)
}
plan, _, err = planFor(ctx, open, "laptop")
if err != nil {
t.Fatal(err)
}
if len(plan.Absent) != 0 {
t.Fatalf("still absent after influxdb was assigned: %+v", plan.Absent)
}
var bound bool
for _, n := range plan.Needs {
bound = bound || n.For == "grafana" && n.Name == "influxdb-api" && n.From == "anchor"
}
if !bound {
t.Fatalf("grafana was not bound to influxdb on anchor: %+v", plan.Needs)
}
}
@@ -1,46 +0,0 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// ADR 0326 §3 is judged where a definition is written (novox/hq issue 574): `module check`, and
// namesNoInstallation, which `module add`, a build taken in and the merge gate's registration run.
func TestWhereADefinitionIsWrittenADeclaringModuleNamesItsFilesKeys(t *testing.T) {
const silent = `{"module":"mail-client","version":"1",
"settings":{"prefs":{"kind":"preference","default":"// none","why":"its own defaults until a machine names one"}},
"resources":[
{"id":"user-js","type":"file","path":"/var/lib/mail-client/user.js","mode":"0644","content":"${setting:prefs}\n"},
{"id":"accounts","type":"file","path":"/var/lib/mail-client/accounts.json","mode":"0600","merge":"json",
"content":"{}\n"%s}]}`
dir := t.TempDir()
bad := filepath.Join(dir, "bad.json")
good := filepath.Join(dir, "good.json")
os.WriteFile(bad, []byte(strings.Replace(silent, "%s", "", 1)), 0o600)
os.WriteFile(good, []byte(strings.Replace(silent, "%s", `,"takes-settings":["accounts"]`, 1)), 0o600)
var out bytes.Buffer
if err := moduleCheck([]string{bad}, &out); err == nil ||
!strings.Contains(out.String(), "mergeable accounts names no takes-settings") {
t.Fatalf("module check passed a declaring module's silent mergeable file: %v\n%s", err, out.String())
}
out.Reset()
if err := moduleCheck([]string{good}, &out); err != nil {
t.Fatalf("the file naming its keys was refused: %v\n%s", err, out.String())
}
raw, _ := os.ReadFile(bad)
m, err := catalogue.ParseManifest(raw)
if err != nil {
t.Fatalf("reading it is not where it is judged: %v", err)
}
if err := namesNoInstallation(m); err == nil || !strings.Contains(err.Error(), "names no takes-settings") {
t.Fatalf("registration passed it: %v", err)
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ func theCarriedAccounts(t *testing.T) string {
// the commit the mesh runs, or as captured in testdata/beside (internal/beside, novox/hq issue 432).
func theTemplate(t *testing.T) []map[string]any {
t.Helper()
path := filepath.Join(beside.Dir(t, "mesh-host"), "examples", "foundation-first-node.lock")
path := filepath.Join(beside.Dir(t, "mesh-host"), "examples", "foundation-first-node-nats.lock")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
-7
View File
@@ -226,13 +226,6 @@ func build(ctx context.Context, run Runner, publish Publisher,
say("manifest", "INVALID: %v", err)
return Result{}, err
}
// What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is
// where a changed definition is first read, so it is refused here before anything is made of it.
if problems := catalogue.WrittenSettingsProblems(manifest); len(problems) > 0 {
err := fmt.Errorf("%s", strings.Join(problems, "; "))
say("manifest", "INVALID: %v", err)
return Result{}, err
}
say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest))
// What it is made from, for its source fingerprint: the module's own tree first.
+35 -23
View File
@@ -26,21 +26,10 @@ import (
// serves — whose keys are agreed by the requirement's name, not by this file.
// bound is where a module says a value from one of its bindings belongs:
// ${bound:<provision>.<key>}.
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)\}`)
// boundUsed are the (provision, key) pairs a file's content asks for, first appearance first.
func boundUsed(content string) [][2]string {
var used [][2]string
seen := map[string]bool{}
for _, m := range bound.FindAllStringSubmatch(content, -1) {
if key := m[1] + ":" + m[2]; !seen[key] {
seen[key] = true
used = append(used, [2]string{m[1], m[2]})
}
}
return used
}
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
// absent requirement is never rendered as an empty string.
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
// knownFor is everything a module may name from one of its bindings.
//
@@ -125,7 +114,11 @@ func plainly(value any) string {
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
// somewhere that names neither the module nor the mesh.
func boundInto(resource map[string]any, known map[string]map[string]string, module string) error {
//
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
// that already; this is the same rule where the file is made, for a manifest registered before it.
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
@@ -133,24 +126,43 @@ func boundInto(resource map[string]any, known map[string]map[string]string, modu
if !ok {
return nil
}
for _, pair := range boundUsed(content) {
provision, key := pair[0], pair[1]
var refused error
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
if refused != nil {
return token
}
m := bound.FindStringSubmatch(token)
provision, key, fallback := m[1], m[2], m[3]
values, has := known[provision]
if !has {
return fmt.Errorf(
if absent[provision] {
if fallback != "" {
return fallback
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
return token
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
return token
}
value, said := values[key]
if !said {
return fmt.Errorf(
refused = fmt.Errorf(
"%s asks its %s binding for %q, and what answers it says %s",
module, provision, key, orNothing(namesOfKeys(values)))
return token
}
resource["content"] = strings.ReplaceAll(
content, fmt.Sprintf("${bound:%s:%s}", provision, key), value)
content = resource["content"].(string)
return value
})
if refused != nil {
return refused
}
resource["content"] = filled
return nil
}
+6 -1
View File
@@ -850,6 +850,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// An account depends on nothing the mesh computes.
accounts, rest := accountsFirst(resources)
resources = append(append(accounts, first...), rest...)
// **A file delivered only while its optional requirement is bound is left out while it is
// absent** (novox/hq ADR 0330 §5), and the field is dropped from the rest: the node-engine
// parses strictly and knows no such field.
absent := r.AbsentFor(m.Module)
resources = withoutAbsent(resources, absent)
// No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
// container got the whole roster as `--add-host` entries at creation, and a name that
@@ -1013,7 +1018,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// **Substituted here, not on the machine.** A bound value is not secret — the mesh
// holds it in the clear — so there is nothing for the host to be the only witness of,
// and sending it already filled in means the host learns no new field.
if err := boundInto(copied, known, m.Module); err != nil {
if err := boundInto(copied, known, m.Module, absent); err != nil {
return nil, err
}
// And what the module could not have written: the machine it turned out to be
+46 -1
View File
@@ -385,6 +385,10 @@ type Manifest struct {
// Requires are names that must be provided by something assigned to the same node.
Requires []string `json:"requires,omitempty"`
// OptionalRequires is which of Requires the module can run without (novox/hq ADR 0330): filled
// by UnmarshalJSON from the long form of a `requires` entry, {name, optional}, or from the stored
// form's `optional-requires` list; never written by hand.
OptionalRequires map[string]bool `json:"-"`
// Emits are the events this module publishes, named **locally**: `order.placed`, not a subject
// and not a routing key. The mesh derives where it lands (design 29 §1), so reorganising the
@@ -1354,6 +1358,35 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
}
delete(keys, "contributes")
}
var optionalRequires map[string]bool
if requires, ok := keys["requires"]; ok && string(requires) != "null" {
names, optional, err := readRequires(requires)
if err != nil {
return err
}
plainNames, err := json.Marshal(names)
if err != nil {
return err
}
keys["requires"] = plainNames
optionalRequires = optional
}
// The stored form: `requires` plain, and which are optional beside it (OptionalRequiresKey).
if stored, ok := keys[OptionalRequiresKey]; ok {
if string(stored) != "null" {
optional, err := readOptionalRequires(stored)
if err != nil {
return err
}
for name := range optional {
if optionalRequires == nil {
optionalRequires = map[string]bool{}
}
optionalRequires[name] = true
}
}
delete(keys, OptionalRequiresKey)
}
rest, err := json.Marshal(keys)
if err != nil {
return err
@@ -1385,6 +1418,7 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
}
*m = Manifest(fields)
m.unknown = unknown
m.OptionalRequires = optionalRequires
if len(plain) > 0 {
m.Secrets = plain
}
@@ -1407,13 +1441,22 @@ func (m Manifest) MarshalJSON() ([]byte, error) {
if err != nil {
return nil, err
}
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 {
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 {
return raw, nil
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, err
}
if optional := optionalRequiresList(m); len(optional) > 0 {
// Beside `requires`, never inside it: an older controller reads `requires` as plain names, and one
// manifest it cannot read stops the whole catalogue (OptionalRequiresKey).
listed, err := json.Marshal(optional)
if err != nil {
return nil, err
}
keys[OptionalRequiresKey] = listed
}
if len(m.ContributesMany) > 0 {
mergedContributes := map[string]any{}
for to, values := range m.Contributes {
@@ -1693,6 +1736,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, LogProblems(m)...)
// And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262).
problems = append(problems, SettingProblems(m)...)
// And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330).
problems = append(problems, OptionalProblems(m)...)
// And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326).
problems = append(problems, TakenSettingsProblems(m)...)
wellFormed := true
+282
View File
@@ -0,0 +1,282 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
)
// A requirement a module can run without (novox/hq ADR 0330, issue 166).
//
// **Hard stays the default.** A requirement written as a bare name is hard, exactly as before: it
// resolves, or the assignment is refused, and a single catalogue candidate is installed for it. One
// written `{"name": "influxdb-api", "optional": true}` resolves the same way when a provider is
// already assigned in the mesh or reachable from the consumer (§2), and is otherwise **absent**: not
// refused, and nothing is installed for it, on this machine or elsewhere (§3).
//
// **An absent requirement is never rendered as an empty value** (§4–§6). Its binding, its pair
// credential and its grant are not composed, so a file that names one of them is delivered only while
// the requirement is bound (`only-when-bound`), and a `${bound:…}` placeholder of it outside such a
// file names the value it takes while absent: `${bound:<provision>:<key>|default:<value>}`. The
// module check refuses anything else, so the gap is found when the manifest is written, not on a
// machine where it reads as a blank.
// OnlyWhenBound is a resource's field naming the optional requirement it is delivered only while
// bound (ADR 0330 §5). Said in the catalogue and dropped before the declaration is sent: the
// node-engine parses strictly and knows no such field.
const OnlyWhenBound = "only-when-bound"
// requirementForm is one entry of `requires` in its long form.
type requirementForm struct {
Name string `json:"name"`
Optional bool `json:"optional,omitempty"`
}
// readRequires reads `requires` in both of its forms — a bare name, or {name, optional} — into the
// names and which of them are optional.
func readRequires(raw json.RawMessage) ([]string, map[string]bool, error) {
var entries []json.RawMessage
if err := json.Unmarshal(raw, &entries); err != nil {
return nil, nil, fmt.Errorf("requires: a list of names, or of {name, optional}: %w", err)
}
var names []string
optional := map[string]bool{}
for i, e := range entries {
var plain string
if err := json.Unmarshal(e, &plain); err == nil {
names = append(names, plain)
continue
}
var full requirementForm
dec := json.NewDecoder(bytes.NewReader(e))
dec.DisallowUnknownFields()
if err := dec.Decode(&full); err != nil {
return nil, nil, fmt.Errorf("requires[%d]: a name, or {name, optional} (novox/hq ADR 0330): %w", i, err)
}
if strings.TrimSpace(full.Name) == "" {
return nil, nil, fmt.Errorf("requires[%d] names no requirement: {name, optional}", i)
}
names = append(names, full.Name)
if full.Optional {
optional[full.Name] = true
}
}
if len(optional) == 0 {
optional = nil
}
return names, optional, nil
}
// OptionalRequiresKey is where a stored manifest keeps which of its requirements are optional: beside
// `requires`, which stays a plain list of names (mesh-controller #281's review). A controller older than
// optional requirements reads `requires` as names; the long form inside it failed that read, and with it
// the whole catalogue, so a rollback below this controller would have composed no machine. An older
// controller sets this unknown key aside instead and leaves only the module that carries it out. A
// manifest may be written either way; it is stored this way.
const OptionalRequiresKey = "optional-requires"
// readOptionalRequires reads the stored form's list of optional requirements.
func readOptionalRequires(raw json.RawMessage) (map[string]bool, error) {
var names []string
if err := json.Unmarshal(raw, &names); err != nil {
return nil, fmt.Errorf("%s: a list of the names in `requires` that are optional: %w", OptionalRequiresKey, err)
}
out := map[string]bool{}
for _, n := range names {
out[n] = true
}
return out, nil
}
// optionalRequiresList is the stored form's list: the optional requirements, sorted.
func optionalRequiresList(m Manifest) []string {
var out []string
for name, optional := range m.OptionalRequires {
if optional {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// IsOptional is whether this module requires the name optionally (ADR 0330 §1). A name it contributes
// to without requiring, or requires as a bare name, is hard.
func (m Manifest) IsOptional(requirement string) bool { return m.OptionalRequires[requirement] }
// wantsHard is whether this module wants the name as a hard requirement: required without
// `optional`, or contributed to.
func (m Manifest) wantsHard(name string) bool {
for _, w := range m.Wants() {
if w == name {
return !m.IsOptional(name)
}
}
return false
}
// Absent is one optional requirement of one consumer that nothing assigned answers (ADR 0330 §3):
// left unbound rather than refused, and listed by `plan` and `node` (§7).
type Absent struct {
// Consumer is the module that requires it optionally.
Consumer string `json:"consumer"`
// Requirement is what it requires.
Requirement string `json:"requirement"`
}
// AbsentFor is the optional requirements of one module this resolution leaves unbound.
func (r Resolution) AbsentFor(module string) map[string]bool {
out := map[string]bool{}
for _, a := range r.Absent {
if a.Consumer == module {
out[a.Requirement] = true
}
}
return out
}
// absentOf is, for each requirement left absent, every module of the set that requires it
// optionally, sorted by consumer then requirement.
func absentOf(names map[string]bool, order []string, catalogue map[string]Manifest) []Absent {
var out []Absent
for _, module := range order {
m := catalogue[module]
for _, r := range m.Requires {
if names[r] && m.IsOptional(r) {
out = append(out, Absent{Consumer: module, Requirement: r})
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Consumer != out[j].Consumer {
return out[i].Consumer < out[j].Consumer
}
return out[i].Requirement < out[j].Requirement
})
return out
}
// withoutAbsent drops the resources delivered only while a requirement is bound whose requirement is
// absent here (ADR 0330 §5), and the field itself from every resource that keeps it.
func withoutAbsent(resources []map[string]any, absent map[string]bool) []map[string]any {
out := make([]map[string]any, 0, len(resources))
for _, r := range resources {
when, has := r[OnlyWhenBound]
if !has {
out = append(out, r)
continue
}
if absent[fmt.Sprint(when)] {
continue
}
kept := make(map[string]any, len(r))
for k, v := range r {
if k != OnlyWhenBound {
kept[k] = v
}
}
out = append(out, kept)
}
return out
}
// secretNamesOf is every `${secret:…}` name that carries a credential of this requirement: its own
// name, and each local name it keeps several under (ADR 0094).
func secretNamesOf(m Manifest, requirement string) []string {
names := []string{requirement}
for local := range m.SecretsMany[requirement] {
names = append(names, local)
}
sort.Strings(names)
return names
}
// OptionalProblems is what the module check refuses of a manifest's optional requirements and its
// only-when-bound resources (ADR 0330 §4–§6).
func OptionalProblems(m Manifest) []string {
var problems []string
required := map[string]bool{}
for _, r := range m.Requires {
if required[r] && m.IsOptional(r) {
problems = append(problems, fmt.Sprintf("%s requires %q twice; say it once, optional or not", m.Module, r))
}
required[r] = true
}
for _, r := range optionalRequiresList(m) {
if !required[r] {
problems = append(problems, fmt.Sprintf(
"%s says %q is optional in %s and does not require it", m.Module, r, OptionalRequiresKey))
}
}
// A contribution is to whoever answers the requirement, so it goes with it: contributing to one
// that is optional is allowed, and composed only while it is bound.
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
when, marked := r[OnlyWhenBound]
on := ""
if marked {
name, ok := when.(string)
switch {
case !ok || name == "":
problems = append(problems, fmt.Sprintf(
"%s's resource %s says %s %v; it names one requirement the module requires optionally",
m.Module, id, OnlyWhenBound, when))
continue
case fmt.Sprint(r["type"]) != "file":
problems = append(problems, fmt.Sprintf(
"%s's resource %s is a %v and says %s: only a file is left out while a requirement is "+
"absent (novox/hq ADR 0330 §5)", m.Module, id, r["type"], OnlyWhenBound))
continue
case !required[name]:
problems = append(problems, fmt.Sprintf(
"%s's resource %s is delivered only when %q is bound, and %s does not require it",
m.Module, id, name, m.Module))
continue
case !m.IsOptional(name):
problems = append(problems, fmt.Sprintf(
"%s's resource %s is delivered only when %q is bound, which is a hard requirement and "+
"always bound: mark the requirement optional, or drop %s", m.Module, id, name, OnlyWhenBound))
continue
}
on = name
}
if fmt.Sprint(r["type"]) != "file" {
continue
}
content, _ := r["content"].(string)
for _, use := range bound.FindAllStringSubmatch(content, -1) {
provision, key, hasDefault := use[1], use[2], use[3] != ""
if !m.IsOptional(provision) {
if hasDefault {
problems = append(problems, fmt.Sprintf(
"%s's resource %s gives ${bound:%s:%s} a default, and %q is a hard requirement, always "+
"bound: a default it never renders only misleads a reader", m.Module, id, provision, key, provision))
}
continue
}
if !hasDefault && on != provision {
problems = append(problems, fmt.Sprintf(
"%s's resource %s says ${bound:%s:%s}, and %q is optional: while it is absent this would "+
"be empty. Name its value then — ${bound:%s:%s|default:<value>} — or deliver the file "+
"only while it is bound, with \"%s\": %q (novox/hq ADR 0330 §4)",
m.Module, id, provision, key, provision, provision, key, OnlyWhenBound, provision))
}
}
for _, provision := range m.Requires {
if !m.IsOptional(provision) || on == provision {
continue
}
for _, name := range secretNamesOf(m, provision) {
if strings.Contains(content, "${secret:"+name+"}") {
problems = append(problems, fmt.Sprintf(
"%s's resource %s says ${secret:%s}, the credential of %q, which is optional and not "+
"delivered while absent: deliver the file only while it is bound, with \"%s\": %q "+
"(novox/hq ADR 0330 §6)", m.Module, id, name, provision, OnlyWhenBound, provision))
}
}
}
}
return problems
}
@@ -0,0 +1,301 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// A requirement a module can run without (novox/hq ADR 0330, issue 166). The shape of the issue:
// grafana runs without a data source and requires `influxdb-api` softly; a mesh without influxdb
// could not assign grafana at all, and one with influxdb in its catalogue would have had it installed
// for grafana.
func optionalShelf() map[string]Manifest {
grafana := Manifest{Module: "grafana", Version: "1", Requires: []string{"influxdb-api", "shell"},
OptionalRequires: map[string]bool{"influxdb-api": true, "shell": true}}
return shelf(
Manifest{Module: "influxdb", Version: "1", Provides: FromAnywhere("influxdb-api")},
Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")},
grafana,
// What wants the same names hard, for the cases where one consumer's optional is another's hard.
Manifest{Module: "telegraf", Version: "1", Requires: []string{"influxdb-api"}},
Manifest{Module: "editor", Version: "1", Requires: []string{"shell"}},
Manifest{Module: "suite", Version: "1", Requires: []string{"editor"}},
)
}
func TestARequirementIsHardUnlessMarkedOptional(t *testing.T) {
m := mustParse(t, `{"module":"grafana","version":"1",
"requires":["route",{"name":"influxdb-api","optional":true},{"name":"oidc-client"}]}`)
if strings.Join(m.Requires, " ") != "route influxdb-api oidc-client" {
t.Fatalf("the requirements read as %v", m.Requires)
}
if m.IsOptional("route") || m.IsOptional("oidc-client") {
t.Error("a requirement without `optional` read as optional; hard is the default (ADR 0330 §1)")
}
if !m.IsOptional("influxdb-api") {
t.Error("a requirement marked optional read as hard")
}
// And written back as it was read, so a manifest through the store keeps its form.
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
var again Manifest
if err := json.Unmarshal(raw, &again); err != nil {
t.Fatal(err)
}
if !again.IsOptional("influxdb-api") || again.IsOptional("route") || len(again.Requires) != 3 {
t.Fatalf("written and read back, it says %v optional %v (%s)", again.Requires, again.OptionalRequires, raw)
}
if strings.Contains(string(raw), `"name":`) {
t.Errorf("a requirement was stored in the long form: %s", raw)
}
}
// The stored form reads on a controller older than optional requirements (review of mesh-controller
// #281). Inventory.Catalogue stops at the first manifest it cannot read, so a long-form entry inside
// `requires` would have stopped every plan after a rollback. Stored, `requires` is plain names, as main's
// manifest type reads it, and which are optional sits beside it in a key such a controller sets aside.
func TestTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) {
m := mustParse(t, `{"module":"grafana","version":"1",
"requires":["route",{"name":"influxdb-api","optional":true}]}`)
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
// main's field, exactly: `Requires []string`.
var older struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(raw, &older); err != nil {
t.Fatalf("a controller reading requires as names cannot read the stored form: %v\n%s", err, raw)
}
if strings.Join(older.Requires, " ") != "route influxdb-api" {
t.Fatalf("it reads the requirements as %v", older.Requires)
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
t.Fatal(err)
}
if string(keys[OptionalRequiresKey]) != `["influxdb-api"]` {
t.Fatalf("which are optional is stored as %s", keys[OptionalRequiresKey])
}
// And this controller reads the stored form back the same, leniently and strictly.
var back Manifest
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
strict, err := ParseManifest(raw)
if err != nil {
t.Fatal(err)
}
for _, got := range []Manifest{back, strict} {
if !got.IsOptional("influxdb-api") || got.IsOptional("route") || len(got.Requires) != 2 {
t.Fatalf("read back as %v optional %v", got.Requires, got.OptionalRequires)
}
}
// A stored list naming something the module does not require is refused.
if _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil {
t.Error("an optional requirement the module does not require passed the check")
}
}
func TestARequirementsLongFormRefusesWhatItDoesNotKnow(t *testing.T) {
for _, raw := range []string{
`{"module":"x","version":"1","requires":[{"name":"influxdb-api","optinal":true}]}`,
`{"module":"x","version":"1","requires":[{"optional":true}]}`,
} {
if _, err := ParseManifest([]byte(raw)); err == nil {
t.Errorf("accepted %s", raw)
}
}
}
func TestAnOptionalRequirementWithNoProviderIsAbsentAndNothingIsInstalled(t *testing.T) {
// Exactly one catalogue module could answer each — the case where a hard requirement installs it.
// Brokered (influxdb-api, from anywhere) and on this machine (shell); both passes.
for _, world := range []World{{}, {Unchecked: true}} {
got, err := Resolve(optionalShelf(), []string{"grafana"}, reachable(), world)
if err != nil {
t.Fatalf("an optional requirement with no provider refused the assignment: %v", err)
}
if have := strings.Join(names(got), " "); have != "grafana" {
t.Fatalf("assigning grafana put %s on the machine; nothing is installed for an optional requirement (ADR 0330 §3)", have)
}
if world.Unchecked {
continue
}
want := []Absent{{Consumer: "grafana", Requirement: "influxdb-api"}, {Consumer: "grafana", Requirement: "shell"}}
if len(got.Absent) != 2 || got.Absent[0] != want[0] || got.Absent[1] != want[1] {
t.Fatalf("absent: %+v, want %+v", got.Absent, want)
}
if len(got.Needs) != 0 {
t.Fatalf("an absent requirement was given a binding: %+v", got.Needs)
}
}
}
func TestAnOptionalRequirementWithAProviderBindsAsAHardOne(t *testing.T) {
world := World{Offered: map[string][]Provider{"influxdb-api": {
{Node: "anchor", At: "anchor.internal", Module: "influxdb", Serves: map[string]any{"port": 8086}}}}}
got, err := Resolve(optionalShelf(), []string{"grafana", "zsh"}, reachable(), world)
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, got, "grafana", "influxdb-api"); n.From != "anchor" || n.Module != "influxdb" {
t.Fatalf("bound to %s/%s", n.From, n.Module)
}
if len(got.Absent) != 0 {
t.Fatalf("a requirement with a provider was left absent: %+v", got.Absent)
}
// And the same assignment without the provider is absent: what binds it later is only the
// provider being assigned, with no act on grafana (ADR 0330 §7).
before, err := Resolve(optionalShelf(), []string{"grafana", "zsh"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
if len(before.Absent) != 1 || before.Absent[0].Requirement != "influxdb-api" {
t.Fatalf("absent before the provider was assigned: %+v", before.Absent)
}
}
func TestOneConsumersOptionalRequirementIsAnothersHardOne(t *testing.T) {
// telegraf wants influxdb-api hard: with nothing offering it, the assignment is refused as
// before, naming what to assign — grafana's optional does not soften telegraf's.
_, err := Resolve(optionalShelf(), []string{"grafana", "telegraf"}, reachable(), World{})
if err == nil || !strings.Contains(err.Error(), "assign influxdb") {
t.Fatalf("a hard requirement with no provider was not refused naming the candidate: %v", err)
}
// And the refusal names telegraf, whose remedy it is, not grafana, which met the name first.
if !strings.Contains(err.Error(), "wanted by telegraf") {
t.Errorf("the refusal does not name the module that wants it hard: %v", err)
}
// And a module pulled in later (suite → editor) that wants the shell hard installs it, even
// though grafana, met first, wanted it only optionally — and grafana is then bound to it.
got, err := Resolve(optionalShelf(), []string{"grafana", "suite"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
t.Fatalf("a hard requirement met after an optional one was not installed: %s", have)
}
for _, a := range got.Absent {
if a.Requirement == "shell" {
t.Fatalf("the shell is installed and still listed absent: %+v", got.Absent)
}
}
}
func TestAHardRequirementWithNoProviderIsStillRefused(t *testing.T) {
_, err := Resolve(optionalShelf(), []string{"telegraf"}, reachable(), World{})
if err == nil || !strings.Contains(err.Error(), "influxdb") {
t.Fatalf("got %v", err)
}
}
func optionalModule(resources string) string {
return `{"module":"grafana","version":"1",
"requires":["route",{"name":"influxdb-api","optional":true}],
"secrets":{"influxdb-api":"/var/lib/grafana/influxdb-api"},
"resources":[` + resources + `]}`
}
func TestTheModuleCheckRefusesAnOptionalPlaceholderThatCouldRenderEmpty(t *testing.T) {
refused := map[string]string{
"a placeholder without a default": `{"id":"ds","type":"file","path":"/a","mode":"0644",
"content":"url: ${bound:influxdb-api:at}"}`,
"a secret outside an only-when-bound file": `{"id":"s","type":"file","path":"/a","mode":"0400",
"content":"${secret:influxdb-api}"}`,
"only-when-bound on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644",
"content":"x","only-when-bound":"route"}`,
"only-when-bound on something not required": `{"id":"r","type":"file","path":"/a","mode":"0644",
"content":"x","only-when-bound":"mqtt-topic"}`,
"only-when-bound on a directory": `{"id":"d","type":"directory","mode":"0700",
"only-when-bound":"influxdb-api"}`,
"a default on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644",
"content":"${bound:route:name|default:x}"}`,
}
for what, resource := range refused {
if _, err := ParseManifest([]byte(optionalModule(resource))); err == nil {
t.Errorf("%s passed the module check", what)
} else if what == "a placeholder without a default" && !strings.Contains(err.Error(), "|default:") {
t.Errorf("the refusal does not say how to write it: %v", err)
}
}
accepted := map[string]string{
"a placeholder with a default": `{"id":"ds","type":"file","path":"/a","mode":"0644",
"content":"url: ${bound:influxdb-api:at|default:none}"}`,
"a placeholder and a secret inside an only-when-bound file": `{"id":"ds","type":"file","path":"/a",
"mode":"0400","content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}",
"only-when-bound":"influxdb-api"}`,
}
for what, resource := range accepted {
if _, err := ParseManifest([]byte(optionalModule(resource))); err != nil {
t.Errorf("%s was refused: %v", what, err)
}
}
}
func absentGrafana(t *testing.T, absent bool) Resolution {
t.Helper()
m := mustParse(t, optionalModule(`
{"id":"env","type":"file","path":"/var/lib/grafana/env","mode":"0644",
"content":"INFLUX=${bound:influxdb-api:at|default:none}:${bound:influxdb-api:port|default:0}\n"},
{"id":"ds","type":"file","path":"/var/lib/grafana/ds.yaml","mode":"0400",
"content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}\n","only-when-bound":"influxdb-api"}`))
m.Requires = []string{"influxdb-api"}
m.Binds = map[string]string{"influxdb-api": "/var/lib/grafana/influxdb.json"}
r := Resolution{Node: "workstation", Modules: []Manifest{m}}
if absent {
r.Absent = []Absent{{Consumer: "grafana", Requirement: "influxdb-api"}}
} else {
r.Needs = []Needed{{Name: "influxdb-api", From: "anchor", At: "anchor.internal", For: "grafana",
Module: "influxdb", Sealed: "sealed-influx", Serves: map[string]any{"port": float64(8086)}}}
}
return r
}
func TestAnAbsentRequirementIsComposedWithItsDefaultsAndWithoutItsFiles(t *testing.T) {
out, err := absentGrafana(t, true).Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
env := fileNamed(out, "grafana.env")
if env == nil || env["content"] != "INFLUX=none:0\n" {
t.Fatalf("the defaults were not rendered: %v", env)
}
for _, id := range []string{"grafana.ds", "grafana." + SecretID("influxdb-api"), "grafana." + BoundID("influxdb-api")} {
if fileNamed(out, id) != nil {
t.Errorf("%s was delivered while influxdb-api is absent (ADR 0330 §5, §6)", id)
}
}
for _, r := range out {
if _, has := r[OnlyWhenBound]; has {
t.Errorf("%v carries %s to the node-engine, which knows no such field", r["id"], OnlyWhenBound)
}
}
}
func TestABoundOptionalRequirementIsComposedAsAHardOne(t *testing.T) {
out, err := absentGrafana(t, false).Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
if env := fileNamed(out, "grafana.env"); env == nil || env["content"] != "INFLUX=anchor.internal:8086\n" {
t.Fatalf("the bound values were not rendered: %v", env)
}
ds := fileNamed(out, "grafana.ds")
if ds == nil {
t.Fatalf("the only-when-bound file was left out while bound: %v", out)
}
if _, has := ds[OnlyWhenBound]; has {
t.Errorf("the file carries %s to the node-engine", OnlyWhenBound)
}
for _, id := range []string{"grafana." + SecretID("influxdb-api"), "grafana." + BoundID("influxdb-api")} {
if fileNamed(out, id) == nil {
t.Errorf("%s was not delivered while bound", id)
}
}
}
+92 -2
View File
@@ -183,6 +183,10 @@ type Resolution struct {
// that keeps its data, and did not (novox/hq ADR 0232): it stays bound where its data is, and the
// controller raises an urgent condition naming the move until a person pins one or the other.
Kept []KeptBinding
// Absent is every optional requirement of this node's modules that nothing assigned answers
// (novox/hq ADR 0330 §3): left unbound rather than refused, with nothing installed for it, and
// listed by `plan` and `node` (§7). It binds on the first composition after a provider is assigned.
Absent []Absent
}
// Unhostable is one directly-assigned module the machine cannot run.
@@ -374,12 +378,80 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
// identical lines before realising there is none.
reported := map[string]bool{}
for len(queue) > 0 {
// **An optional requirement is settled last** (novox/hq ADR 0330). Whether a name is optional is
// a question about every module that wants it, and a module pulled in later may want it hard; so
// a name only optional modules want so far is put aside until the walk has nothing else, and only
// then, if still nobody wants it hard, settled by the optional rule: bound to a provider that is
// already assigned or reachable, exactly as a hard one, and otherwise absent — never refused,
// and never answered by installing anything.
var deferred []string
settling := map[string]bool{}
absent := map[string]bool{}
wantedHard := func(want string) bool {
for _, name := range order {
if catalogue[name].wantsHard(want) {
return true
}
}
return false
}
wantedOptionally := func(want string) bool {
for _, name := range order {
if catalogue[name].IsOptional(want) {
return true
}
}
return false
}
leaveAbsent := func(want string) {
absent[want] = true
reported[want] = true
}
for {
if len(queue) == 0 {
if len(deferred) == 0 {
break
}
// What a module chosen since wants hard goes back to the walk, which may install for
// it; only when none does is the rest settled, since settling installs nothing and so
// can change no other name's answer.
var hard, optional []string
for _, d := range deferred {
if wantedHard(d) {
hard = append(hard, d)
} else {
optional = append(optional, d)
}
}
if len(hard) > 0 {
queue, deferred = hard, optional
continue
}
for _, d := range optional {
settling[d] = true
}
queue, deferred = optional, nil
}
want := queue[0]
queue = queue[1:]
if chosen[want] || reported[want] {
continue
}
if !settling[want] && because[want] != "assigned" && !wantedHard(want) && wantedOptionally(want) {
deferred = append(deferred, want)
continue
}
// Walked as a hard requirement: a refusal names a module that wants it hard, never one that
// wanted it only optionally and met it first — whose remedy it is not.
if first, known := catalogue[because[want]]; !settling[want] && known && !first.wantsHard(want) {
for _, name := range order {
if catalogue[name].wantsHard(want) {
because[want] = name
break
}
}
}
// Already answered by something in the set. This is the case that makes assigning zsh do
// what a person meant by it.
//
@@ -483,6 +555,12 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
if brokered[want] {
reported[want] = true
where := world.Offered[want]
if settling[want] && len(where) == 0 {
// Nothing assigned anywhere answers it: absent, and nothing is put on a machine
// for it (ADR 0330 §3), however few modules in the catalogue could.
leaveAbsent(want)
continue
}
names := providerNames(where)
take := func(p Provider) {
if node.At != "" && p.At == "" || node.At == "" && p.At != "" || node.At == "" && p.At == "" {
@@ -579,6 +657,10 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
// research 026 found. Another node's provider never counts — node scope is never brokered.
if machineReach[want] && !isModule(catalogue, want) {
reported[want] = true
if settling[want] {
leaveAbsent(want)
continue
}
if world.Unchecked {
// The first pass's refusals take a machine off the network; the second says it.
continue
@@ -591,6 +673,13 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
}
candidates := offers[want]
if settling[want] && !(len(candidates) == 0 && len(world.Licences[want]) > 0) {
// Not answered by anything in this node's set, and only installing could answer it
// here — which is never done for an optional requirement (ADR 0330 §3), not even
// for the only candidate. A licence record still answers it, in the post-pass.
leaveAbsent(want)
continue
}
switch len(candidates) {
case 0:
if len(world.Licences[want]) > 0 {
@@ -724,7 +813,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
resolution := Resolution{Node: node.Name, At: node.At, PublicDomain: node.PublicDomain,
Account: node.Account, AccountHome: node.AccountHome, AgentAccount: node.AgentAccount,
AgentAccountHome: node.AgentAccountHome, Capabilities: node.Capabilities,
Because: because, Needs: needs, Unhostable: unhostable, Kept: kept}
Because: because, Needs: needs, Unhostable: unhostable, Kept: kept,
Absent: absentOf(absent, order, catalogue)}
for _, n := range providersFirst(order, catalogue) {
resolution.Modules = append(resolution.Modules, catalogue[n])
}
+3 -33
View File
@@ -70,42 +70,12 @@ func namesItsKeys(m Manifest) bool {
// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another
// that names none.
func TakenSettingsProblems(m Manifest) []string {
// Not yet here: refusing a module that declares a setting and owns a mergeable file naming no keys
// (ADR 0326 §3). betterbird is one, and the catalogue cannot name its keys before this controller
// runs, so that refusal follows once the catalogue does (novox/hq issue 574).
return takenSettingsProblems(m)
}
// WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module
// check, `module add`, a build taken in, the builder and the merge gate's registration — and never
// where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back):
// a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue
// 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts
// file named its keys, must still compose and still be put back; only its next change is held to it.
//
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
// names every file that does not, and a file is not reported twice.
func WrittenSettingsProblems(m Manifest) []string {
if len(m.Settings) == 0 {
return nil
}
var silent []string
for _, r := range m.Resources {
how, _ := r["merge"].(string)
if how == "" {
continue
}
if _, said := r[TakesSettingsField]; said {
return nil
}
silent = append(silent, fmt.Sprint(r["id"]))
}
var problems []string
for _, id := range silent {
problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+
"a module that declares a setting names the keys each mergeable file takes, an empty list "+
"for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, TakesSettingsField))
}
return problems
}
// takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and
// where a stored one is composed.
func takenSettingsProblems(m Manifest) []string {
-47
View File
@@ -194,50 +194,3 @@ func containsText(list []string, text string) bool {
}
return false
}
func TestAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) {
// ADR 0326 §3, ADR 0164 §15, novox/hq issue 574: only a module that declares no settings keeps
// today's form. One that declares a preference and owns a silent mergeable file is refused where
// a definition is written — and read and composed where a stored one is read again, so a build kept
// from before the rule is still sent and still put back.
declaring := func(app, sidecar []any) Manifest {
m := twoFiles(app, sidecar)
m.Resources[2]["content"] = "WORKERS=${setting:workers}\n"
m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: 4.0, Why: "a number"}}
return m
}
m := declaring(nil, nil)
problems := WrittenSettingsProblems(m)
if len(problems) != 2 || !containsText(problems, "mergeable app names no takes-settings") ||
!containsText(problems, "mergeable sidecar names no takes-settings") {
t.Errorf("where it is written: %v", problems)
}
if problems := TakenSettingsProblems(m); len(problems) != 0 {
t.Errorf("what a stored definition is read by refuses it: %v", problems)
}
raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources,
"settings": map[string]any{"workers": map[string]any{"kind": KindPreference, "default": 4, "why": "a number"}}})
if err != nil {
t.Fatal(err)
}
read, err := ParseManifest(raw)
if err != nil {
t.Fatalf("a stored build from before the rule is not read again: %v", err)
}
if err := JudgeSettings(read, nil, false); err != nil {
t.Errorf("a stored definition is composed as before, and was refused: %v", err)
}
if problems := WrittenSettingsProblems(declaring([]any{"server"}, []any{})); len(problems) != 0 {
t.Errorf("every mergeable file names its keys, and was refused: %v", problems)
}
if problems := WrittenSettingsProblems(twoFiles(nil, nil)); len(problems) != 0 {
t.Errorf("a module declaring no setting was refused: %v", problems)
}
// One naming file and one silent: §2 names the silent one, and §3 does not name it again.
mixed := declaring([]any{"server"}, nil)
if written, taken := WrittenSettingsProblems(mixed), TakenSettingsProblems(mixed); len(written) != 0 || len(taken) != 1 {
t.Errorf("the silent file is reported %d times by §3 and %d by §2; want once, by §2: %v %v",
len(written), len(taken), written, taken)
}
}
+10 -5
View File
@@ -29,13 +29,14 @@ func ForTest(t *testing.T) *Identity {
if len(name) > 60 {
name = name[:60]
}
// Created already migrated, a copy of a template migrated once (novox/hq issue 591); the Migrate
// below then finds nothing pending, which it does only if the copy's record matches every migration.
migrations, err := Migrations()
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatal(err)
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
store.CreateMigratedForTest(t, admin, name, migrations)
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
t.Fatalf("cannot create %s: %v", name, err)
}
conn.Close(t.Context())
cut := strings.LastIndex(admin, "/")
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
@@ -56,6 +57,10 @@ func ForTest(t *testing.T) *Identity {
if err := ident.Ready(t.Context(), 20*time.Second); err != nil {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
if _, err := ident.store.Migrate(t.Context(), migrations); err != nil {
t.Fatal(err)
}
+3 -23
View File
@@ -6,7 +6,6 @@ import (
"encoding/base64"
"errors"
"fmt"
"sync/atomic"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
@@ -24,25 +23,6 @@ import (
// access change would blank every module's password if the mesh kept nothing (design 25 §4, and the
// migration beside this).
// busPasswordCostForTest, when set, is the bcrypt cost a bus password is hashed at instead of the
// library's default. Only ForTest sets it, to bcrypt's least (novox/hq issue 591): at the default
// cost one hash takes most of a second under the race detector, and a hash at the least cost is the
// same kind of hash, checked by the same comparison and by the bus the same way.
var busPasswordCostForTest atomic.Int32
// busPasswordCost is the bcrypt cost a bus password is hashed at: the library's default, but in a test.
func busPasswordCost() int {
if c := busPasswordCostForTest.Load(); c != 0 {
return int(c)
}
return bcrypt.DefaultCost
}
// hashBusPassword is the hash a bus password is kept as, minted, recorded or seeded alike.
func hashBusPassword(password string) ([]byte, error) {
return bcrypt.GenerateFromPassword([]byte(password), busPasswordCost())
}
// BusUser is one user of the bus, as the mesh records it.
type BusUser struct {
Username string
@@ -87,7 +67,7 @@ func (i *Inventory) RecordBusPassword(ctx context.Context, u BusUser, password s
if password == "" {
return errors.New("a bus user needs a password")
}
hash, err := hashBusPassword(password)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("cannot hash a bus password: %w", err)
}
@@ -121,7 +101,7 @@ func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, err
// The cost the server will pay on every connection. Left at the library's default rather than
// raised: a node reconnecting after a network blip pays it, and the mesh's own links reconnect
// far more often than a person logs in anywhere.
hash, err := hashBusPassword(password)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("cannot hash a bus password: %w", err)
}
@@ -203,7 +183,7 @@ func (i *Inventory) SeedBusUser(ctx context.Context, u BusUser, password string)
if u.Username == "" || u.Kind == "" || password == "" {
return errors.New("a bus user needs a username, a kind and the credential it is using")
}
hash, err := hashBusPassword(password)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("cannot hash a bus password: %w", err)
}
-14
View File
@@ -158,17 +158,3 @@ func TestACredentialTheMeshDidNotMintIsRecordedOnceAndNotOverwritten(t *testing.
t.Fatal("a restart put the bootstrap credential back over a rotated one")
}
}
// Outside a test a bus password is hashed at bcrypt's default cost: only ForTest lowers it (novox/hq issue 591).
func TestABusPasswordIsHashedAtTheDefaultCostOutsideATest(t *testing.T) {
was := busPasswordCostForTest.Load()
t.Cleanup(func() { busPasswordCostForTest.Store(was) })
busPasswordCostForTest.Store(0)
hash, err := hashBusPassword("a password")
if err != nil {
t.Fatal(err)
}
if got, err := bcrypt.Cost(hash); err != nil || got != bcrypt.DefaultCost {
t.Fatalf("a bus password is hashed at cost %d (%v), not bcrypt's default %d", got, err, bcrypt.DefaultCost)
}
}
+10 -7
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"github.com/novox/mesh-controller/internal/store"
)
@@ -31,14 +30,14 @@ func ForTest(t *testing.T) *Inventory {
name = name[:60]
}
// Created already migrated, a copy of a template migrated once (novox/hq issue 591); the Migrate
// below then finds nothing pending, which it does only if the copy's record matches every migration.
migrations, err := Migrations()
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatal(err)
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
store.CreateMigratedForTest(t, admin, name, migrations)
busPasswordCostForTest.Store(int32(bcrypt.MinCost))
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
t.Fatalf("cannot create %s: %v", name, err)
}
conn.Close(t.Context())
cut := strings.LastIndex(admin, "/")
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
@@ -60,6 +59,10 @@ func ForTest(t *testing.T) *Inventory {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
if _, err := inv.store.Migrate(t.Context(), migrations); err != nil {
t.Fatal(err)
}
@@ -1,67 +0,0 @@
package inventory
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A build kept from before ADR 0326 §3 is still read and composed (novox/hq issue 574). betterbird's
// builds from before mesh-catalog 8f80cd4 declare a preference and own a mergeable file that names no
// keys: refused where a definition is written, but a machine still runs that build and a rollback may
// put it back, so reading it again — ManifestAt, which a walk, the gate and a release compose from —
// must not refuse it.
func TestAStoredBuildWithASilentMergeableFileIsStillReadAndComposed(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
manifest, err := json.Marshal(map[string]any{
"module": "mail-client", "version": "1",
"settings": map[string]any{"prefs": map[string]any{
"kind": catalogue.KindPreference, "default": "// none", "why": "its own defaults until a machine names one"}},
"resources": []any{
map[string]any{"id": "user-js", "type": "file", "path": "/var/lib/mail-client/user.js", "mode": "0644",
"content": "${setting:prefs}\n"},
map[string]any{"id": "accounts", "type": "file", "path": "/var/lib/mail-client/accounts.json",
"mode": "0600", "merge": "json", "content": "{\"accounts\": {}}\n"},
},
})
if err != nil {
t.Fatal(err)
}
b := aBuild("before-574", "mail-client", "")
b.Manifest = manifest
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
m, found, err := inv.ManifestAt(ctx, "mail-client", b.Commit)
if err != nil || !found {
t.Fatalf("the stored build is not read again: found %v, %v", found, err)
}
if written := catalogue.WrittenSettingsProblems(m); len(written) != 1 {
t.Fatalf("the rule this build predates should name its accounts file where it is written: %v", written)
}
// And composed, in today's form: the accounts file still takes its layer's keys.
with := catalogue.Rendering{Settings: catalogue.SettingsBy{"mail-client": {{From: "workstation",
Values: map[string]any{"accounts": map[string]any{"work": map[string]any{"email": "a@example.invalid"}}}}}}}
if err := catalogue.JudgeSettings(m, with.Settings["mail-client"], false); err != nil {
t.Fatalf("the stored build's settings do not compose: %v", err)
}
out, err := catalogue.Resolution{Node: "workstation", Modules: []catalogue.Manifest{m}}.Declaration(with)
if err != nil {
t.Fatalf("the stored build is not composed: %v", err)
}
for _, r := range out {
if r["id"] == "mail-client.accounts" {
if content, _ := r["content"].(string); !strings.Contains(content, "a@example.invalid") {
t.Fatalf("the accounts file did not take its layer: %v", content)
}
return
}
}
t.Fatalf("the accounts file was not composed: %v", out)
}
+1 -8
View File
@@ -75,10 +75,6 @@ type Options struct {
Holder Holder
// RenewEvery, Margin and Poll default to the package's.
RenewEvery, Margin, Poll time.Duration
// RenewWithin is how long one renewal may take before it counts as failed: RenewEvery when zero. Apart
// from it so a test that renews often still gives a slow bus a serving controller's time to answer
// (novox/hq issue 591).
RenewWithin time.Duration
// Floor is the highest epoch this mesh has issued, from the controller's own store. **An epoch is
// never issued twice**: a lease bucket raised again from nothing — a bus whose data was replaced —
// starts its revisions over, and every machine that heard epoch 57 would refuse the next controller
@@ -142,9 +138,6 @@ func Open(ctx context.Context, js jetstream.JetStream, bucket string, o Options)
if o.RenewEvery <= 0 {
o.RenewEvery = RenewEvery
}
if o.RenewWithin <= 0 {
o.RenewWithin = o.RenewEvery
}
if o.Margin <= 0 {
o.Margin = Margin
}
@@ -413,7 +406,7 @@ func (l *Lease) Renew(ctx context.Context) error {
if err != nil {
return l.lose(err)
}
renewing, cancel := context.WithTimeout(ctx, l.o.RenewWithin)
renewing, cancel := context.WithTimeout(ctx, l.o.RenewEvery)
defer cancel()
next, err := l.kv.Update(renewing, Key, value, revision)
if err != nil {
+10 -5
View File
@@ -32,13 +32,14 @@ func ForTest(t *testing.T) *Licences {
name = name[:60]
}
// Created already migrated, a copy of a template migrated once (novox/hq issue 591); the Migrate
// below then finds nothing pending, which it does only if the copy's record matches every migration.
migrations, err := Migrations()
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatal(err)
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
store.CreateMigratedForTest(t, admin, name, migrations)
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
t.Fatalf("cannot create %s: %v", name, err)
}
conn.Close(t.Context())
cut := strings.LastIndex(admin, "/")
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
@@ -60,6 +61,10 @@ func ForTest(t *testing.T) *Licences {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
if _, err := held.store.Migrate(t.Context(), migrations); err != nil {
t.Fatal(err)
}
-4
View File
@@ -257,10 +257,6 @@ type BuildResult struct {
// build; then only identical artifacts make a rebuild no move.
SourceFingerprint string `json:"source-fingerprint,omitempty"`
// Delivery is the how-manyth time the queue handed this ask out (BuildStart.Delivery): more than 1
// says the outcome is from a run started again after an earlier taker left it (novox/hq issue 594).
Delivery int `json:"delivery,omitempty"`
// Failed is why, when it did.
Failed string `json:"failed,omitempty"`
-25
View File
@@ -92,31 +92,6 @@ type BuildStart struct {
Ref string `json:"ref,omitempty"`
On string `json:"on"`
At string `json:"at"`
// Delivery is the how-manyth time the queue handed this ask out: 1 for a first start, more for one
// an earlier taker left unfinished — most often a build agent replaced mid-build — and started
// again from the beginning (novox/hq issue 594). 0 from a machine that predates it.
Delivery int `json:"delivery,omitempty"`
}
// Restarted is how a build handed out again says so (novox/hq issue 594), in its log and in its check's
// report, so a second run never reads as the first. Neutral on why: a holder replaced mid-build, a paused
// holder handing an ask back as it arrived, and a bus that held its acknowledgements past the wait all
// count as a hand-out. Empty for a first start.
func Restarted(delivery int) string {
if delivery < 2 {
return ""
}
return fmt.Sprintf("restarted: handed out %d times; an earlier hand-out did not finish here, so this run "+
"started from the beginning", delivery)
}
// RunMarker is the short form of Restarted, for a line clipped to a few words — a check's status on the
// forge (novox/hq issue 594): "(run 2)". Empty for a first start.
func RunMarker(delivery int) string {
if delivery < 2 {
return ""
}
return fmt.Sprintf("(run %d)", delivery)
}
// BuildLine is one thing a build said while building.
+2 -24
View File
@@ -310,7 +310,7 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
// built. A set that cannot be read is said and the ask built: a cancel is a person's
// exception, and a holder that refused every build while its grant was missing would
// stop the mesh building for it.
build := &natsBuild{request: request, msg: msg, on: m.on, js: m.js, seat: m.seat, delivery: deliveryOf(msg)}
build := &natsBuild{request: request, msg: msg, on: m.on, js: m.js, seat: m.seat}
if cancelled, err := IsCancelled(m.js.Conn(), m.seat, request.ID); err != nil {
fmt.Fprintf(os.Stderr, "could not read whether %s was cancelled, so it is built: %v\n", request.ID, err)
} else if cancelled {
@@ -336,18 +336,6 @@ type natsBuild struct {
// seat is the role this build was taken from; what the machine says about it is that role's.
seat string
seq int
// delivery is the how-manyth time the queue handed this ask out (novox/hq issue 594); 0 when the
// server did not say.
delivery int
}
// deliveryOf is how many times the queue has handed this message out, this time included.
func deliveryOf(msg *nats.Msg) int {
meta, err := msg.Metadata()
if err != nil {
return 0
}
return int(meta.NumDelivered)
}
func (b *natsBuild) Request() BuildRequest { return b.request }
@@ -368,11 +356,6 @@ func (b *natsBuild) Announce(ctx context.Context, result BuildResult) error {
if result.Failed == "" && result.Commit != "" {
result.Module = ModuleOf(result.Manifest)
}
// **Which run this is** (novox/hq issue 594): stamped by the transport, which alone knows, so every
// outcome carries it whatever the machine's code filled in.
if result.Delivery == 0 {
result.Delivery = b.delivery
}
body, err := json.Marshal(result)
if err != nil {
return err
@@ -392,16 +375,11 @@ func (b *natsBuild) Done() error { return b.msg.Ack() }
func (b *natsBuild) Began(ctx context.Context) error {
body, err := json.Marshal(BuildStart{
ID: b.request.ID, Repository: b.request.Repository, Path: b.request.Path,
Ref: b.request.Ref, On: b.on, At: time.Now().UTC().Format(time.RFC3339Nano), Delivery: b.delivery,
Ref: b.request.Ref, On: b.on, At: time.Now().UTC().Format(time.RFC3339Nano),
})
if err != nil {
return err
}
// **A start that is a restart says so in the build's log** (novox/hq issue 594): a reader following
// the build sees why it began again, never a silent second run.
if said := Restarted(b.delivery); said != "" {
b.Say("restarted", said)
}
publish, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if _, err := b.js.Context().Publish(BuildStartedOf(b.seat), body, nats.Context(publish)); err != nil {
-94
View File
@@ -401,97 +401,3 @@ func TestNatsAHolderSaysItTakesWorkOnceItIsBound(t *testing.T) {
t.Fatal("a machine that could not bind said it takes work")
}
}
// **An ask handed out again says so** (novox/hq issue 594). A build agent replaced mid-build leaves its
// ask unsettled, and the queue hands it out again to start over from the beginning. That second start
// must not look like a first one: the machine that takes it says it restarted — in its started event,
// in the build's log and in its outcome — so the queue and the pull request can say it too.
func TestNatsAnAskHandedOutAgainSaysItRestarted(t *testing.T) {
js := aBusWithTheBuildRole(t)
ctx, stop := context.WithCancel(context.Background())
defer stop()
started, err := js.Conn().SubscribeSync(BuildStarted())
if err != nil {
t.Fatal(err)
}
defer func() { _ = started.Unsubscribe() }()
lines, err := js.Conn().SubscribeSync(BuildLog("b-594"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = lines.Unsubscribe() }()
outcomes, err := js.Conn().SubscribeSync(BuildOutcome())
if err != nil {
t.Fatal(err)
}
defer func() { _ = outcomes.Unsubscribe() }()
_ = js.Conn().Flush()
body, _ := json.Marshal(BuildRequest{ID: "b-594", Repository: "/r"})
if _, err := js.Context().Publish(BuildWork(), body); err != nil {
t.Fatal(err)
}
machine := MachineOverNATS(js, "anchor")
defer machine.Close()
var first sync.Once
go func() {
_ = machine.Take(ctx, func(ctx context.Context, work Build) {
_ = work.Began(ctx)
handedBack := false
// The first time it is left unfinished — an agent replaced mid-build — and comes back.
first.Do(func() { _ = work.Hold(100 * time.Millisecond); handedBack = true })
if handedBack {
return
}
_ = work.Announce(ctx, BuildResult{ID: work.Request().ID, On: "anchor"})
_ = work.Done()
})
}()
var starts []BuildStart
for len(starts) < 2 {
msg, err := started.NextMsg(10 * time.Second)
if err != nil {
t.Fatalf("heard %d start(s) of an ask handed out twice: %v", len(starts), err)
}
var s BuildStart
if err := json.Unmarshal(msg.Data, &s); err != nil {
t.Fatal(err)
}
starts = append(starts, s)
}
if starts[0].Delivery != 1 || starts[1].Delivery != 2 {
t.Fatalf("the starts say deliveries %d and %d, want 1 and 2: a restart reads as a first start",
starts[0].Delivery, starts[1].Delivery)
}
msg, err := outcomes.NextMsg(10 * time.Second)
if err != nil {
t.Fatal(err)
}
var result BuildResult
if err := json.Unmarshal(msg.Data, &result); err != nil {
t.Fatal(err)
}
if result.Delivery != 2 {
t.Fatalf("the outcome says delivery %d, want 2: the pull request cannot say the check restarted",
result.Delivery)
}
sawRestarted := false
for {
msg, err := lines.NextMsg(time.Second)
if err != nil {
break
}
var line BuildLine
if json.Unmarshal(msg.Data, &line) == nil && line.Step == "restarted" {
sawRestarted = true
}
}
if !sawRestarted {
t.Fatal("the build's log never said it restarted")
}
}
+7 -19
View File
@@ -73,9 +73,6 @@ type QueuedAsk struct {
On string `json:"on,omitempty"`
Was string `json:"was-on,omitempty"`
Started time.Time `json:"started,omitempty"`
// Delivery is the how-manyth time the ask was handed out, as its latest start said: more than 1 is
// a build started again after an earlier taker left it (novox/hq issue 594).
Delivery int `json:"delivery,omitempty"`
}
// Queue is the seat's work queue as it stands.
@@ -165,7 +162,7 @@ func ClassifyQueue(asks []QueuedAsk, delivered uint64, ackPending int, heard Bui
}
a.State = AskDead
if s, ok := current[a.ID]; ok {
a.On, a.Started, a.Delivery = s.On, startedAt(s), s.Delivery
a.On, a.Started = s.On, startedAt(s)
running = append(running, i)
continue
}
@@ -186,7 +183,7 @@ func ClassifyQueue(asks []QueuedAsk, delivered uint64, ackPending int, heard Bui
for _, i := range group {
if slots == 0 {
// Past what the worker counts: handed out as often as it may be.
out[i].On, out[i].Started, out[i].Delivery = "", time.Time{}, 0
out[i].On, out[i].Started = "", time.Time{}
continue
}
out[i].State = AskInFlight
@@ -324,23 +321,11 @@ func ReadBuildEvents(ctx context.Context, js *broker.JetStream, seat string, sin
return heard, fmt.Errorf("cannot read %s from the bus: %w", subject, err)
}
defer func() { _ = sub.Unsubscribe() }()
// **Read what is there, then stop** (novox/hq issue 591): the consumer says how many events it holds
// from the moment asked, and the reading ends once they are read. A fetch for a full batch waits out
// its whole wait when fewer are there, so every reading of the queue took two seconds and more.
info, err := sub.ConsumerInfo()
if err != nil {
return heard, fmt.Errorf("cannot read %s from the bus: %w", subject, err)
}
for remaining := info.NumPending; remaining > 0; {
batch, err := sub.Fetch(int(min(remaining, 500)), nats.MaxWait(2*time.Second))
for {
batch, err := sub.Fetch(500, nats.MaxWait(2*time.Second))
if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) {
return heard, fmt.Errorf("reading %s: %w", subject, err)
}
// None in the whole wait: what was counted is gone from the stream since (its limits), not coming.
if len(batch) == 0 {
break
}
remaining -= uint64(len(batch))
for _, msg := range batch {
switch msg.Subject {
case BuildStartedOf(seat):
@@ -357,6 +342,9 @@ func ReadBuildEvents(ctx context.Context, js *broker.JetStream, seat string, sin
}
}
}
if len(batch) < 500 {
break
}
if ctx.Err() != nil {
return heard, ctx.Err()
}
-138
View File
@@ -1,138 +0,0 @@
package store
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// CreateMigratedForTest creates the database name on the test PostgreSQL that admin reaches, holding
// every one of migrations already applied and recorded, as a fresh database migrated by Migrate would.
//
// **A copy of a template, migrated once** (novox/hq issue 591): a store test used to create an empty
// database and run every migration into it, some 90 for the inventory, and with hundreds of store tests
// that alone was most of the controller's check. The template is migrated by Migrate itself, once for
// each set of migrations, and every test's database is copied from it by PostgreSQL. The caller still
// runs Migrate on the copy, which finds nothing pending only if the copy's record matches the migrations
// exactly, so a template that disagreed with them would fail the test rather than pass it unseen.
//
// The template is named after a digest of the migrations, so a changed or added migration is a new
// template, never an old one reused. Packages tested at once in separate processes share one: it is
// built under an advisory lock, and a template a crashed build left half done is not marked a template
// and is built again. It takes a *testing.T, so nothing that is not a test can call it.
func CreateMigratedForTest(t *testing.T, admin, name string, migrations []Migration) {
t.Helper()
template, err := templateFor(admin, migrations)
if err != nil {
t.Fatalf("cannot raise the migrated template: %v", err)
}
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
defer conn.Close(context.Background())
// A copy is refused while anything is connected to its template; the template allows no connection
// once built, so only the builder's last connection closing can still be in the way.
for deadline := time.Now().Add(10 * time.Second); ; {
_, err = conn.Exec(t.Context(), "create database "+name+" template "+template)
var pgErr *pgconn.PgError
if err == nil || !errors.As(err, &pgErr) || pgErr.Code != "55006" || time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
if err != nil {
t.Fatalf("cannot create %s from %s: %v", name, template, err)
}
}
// templates are the templates this process has raised or found, by name.
var templates sync.Map // name → *templateOnce
type templateOnce struct {
once sync.Once
err error
}
// templateFor is the name of the template holding migrations, raised if no process has yet.
func templateFor(admin string, migrations []Migration) (string, error) {
digest := sha256.New()
for _, m := range migrations {
fmt.Fprintf(digest, "%d %s %s\n", m.Number, m.Name, m.Checksum)
}
sum := digest.Sum(nil)
name := "tpl_" + hex.EncodeToString(sum[:12])
held, _ := templates.LoadOrStore(name, &templateOnce{})
once := held.(*templateOnce)
once.once.Do(func() {
// Not the asking test's context: the template outlives that test, and is raised once for all.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
once.err = raiseTemplate(ctx, admin, name, int64(binary.BigEndian.Uint64(sum[12:20])), migrations)
})
return name, once.err
}
// raiseTemplate builds the template name under the advisory lock key, unless it stands built.
func raiseTemplate(ctx context.Context, admin, name string, key int64, migrations []Migration) error {
conn, err := pgx.Connect(ctx, admin)
if err != nil {
return err
}
defer conn.Close(context.Background())
if _, err := conn.Exec(ctx, `select pg_advisory_lock($1)`, key); err != nil {
return err
}
defer func() { _, _ = conn.Exec(context.Background(), `select pg_advisory_unlock($1)`, key) }()
var built bool
if err := conn.QueryRow(ctx, `select exists (select 1 from pg_database where datname = $1 and datistemplate)`,
name).Scan(&built); err != nil {
return err
}
if built {
return nil
}
if _, err := conn.Exec(ctx, "drop database if exists "+name+" with (force)"); err != nil {
return err
}
if _, err := conn.Exec(ctx, "create database "+name); err != nil {
return err
}
cut := strings.LastIndex(admin, "/")
rest := ""
if q := strings.Index(admin[cut:], "?"); q >= 0 {
rest = admin[cut+q:]
}
pool, err := pgxpool.New(ctx, admin[:cut]+"/"+name+rest)
if err != nil {
return err
}
_, err = (&Store{context: "template", pool: pool}).Migrate(ctx, migrations)
pool.Close()
if err != nil {
return err
}
// Marked a template only once whole, and closed to connections, so a copy is never refused for one.
for _, sql := range []string{
"alter database " + name + " with allow_connections false",
"select pg_terminate_backend(pid) from pg_stat_activity where datname = '" + name + "'",
"alter database " + name + " with is_template true",
} {
if _, err := conn.Exec(ctx, sql); err != nil {
return err
}
}
return nil
}
+2 -2
View File
@@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev
a second place to clean besides the catalogue itself.
mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile
mesh-host 7a41e120635b3f7b6cb94cca8b3fc4093e4e6110 examples/foundation-first-node.lock
mesh-host a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
@@ -18,7 +18,7 @@ To move them, from this repository's root, with the two repositories checked out
git -C ../mesh-catalog archive <commit> modules | tar -x -C testdata/beside/mesh-catalog --wildcards \
'modules/*/module.json' 'modules/nats/Dockerfile'
find testdata/beside -name module.json -exec mv {} {}.captured \;
git -C ../mesh-host archive <commit> examples/foundation-first-node.lock | tar -x -C testdata/beside/mesh-host
git -C ../mesh-host archive <commit> examples/foundation-first-node-nats.lock | tar -x -C testdata/beside/mesh-host
and write the commits here. The SDK is captured at the commit go.mod pins for git.novox.be/novox/mesh-sdk/go
(the last part of its pseudo-version; novox/hq issue 449), which internal/link's conformance test holds it to:
@@ -1,17 +1,11 @@
// foundation-first-node.lock — what a machine must be before a mesh exists: the one genesis bundle
// (novox/hq ADR 0331 §2), on the bus the mesh runs on (ADR 0106, ADR 0131, design 25).
// foundation-first-node-nats.lock — what a machine must be before a mesh exists, on the bus being
// built (novox/hq ADR 0106, design 25).
//
// The whole bootstrap (novox/hq 03-DESIGN/01-to-be/07-the-foundation.md): a container runtime, a
// filter, a store, a database per context, those contexts' schemas, the bus with its certificate
// and its first user list, and the control plane running on top of them.
//
// **The mesh writes its own user list, and at genesis there is no mesh yet to write it.** So this
// carries the first one — the controller's own account — and the control plane's address names the
// same account. Both say `bootstrap`, and both are placeholders: the installer makes the account's
// password, puts it in the address and its hash in the list (mesh-host internal/bootstrap
// rootsecrets.go), and refuses a bundle whose bus connection it does not recognise. Until the first
// node has enrolled the installer places the list the controller composes; from the bus's adoption
// as the `nats` module on, the module receives it like any module (ADR 0331 §1, §5).
// The same twelve steps as foundation-first-node.lock, with one difference that matters: **the mesh
// writes its own user list, and at genesis there is no mesh yet to write it.** So this carries the
// first one — the controller's own account, at a well-known bootstrap password, exactly as the store
// is reached at `postgres:bootstrap` and the old bus at `guest:guest`. It is rotated with those, and
// from the controller's first composition onward the file is the controller's to write.
//
// The accounts file is its own file beside the server's configuration, because the server's own
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
@@ -21,18 +15,7 @@
//
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
// authenticates with a password (ADR 0004, design 25 §4). The control plane pins it too, through
// MESH_BROKER_CERTIFICATE, and dials the bus on the machine's own loopback.
//
// PINNED BY DIGEST, and the digest is not decoration: a tag can be made to point at a different
// image, and this file is applied on a machine with no mesh to ask about anything (ADR 0006). The
// registry prefix is the lab's; the digests are upstream's.
//
// The store's data is a NAMED VOLUME, not a directory on the machine. A directory the host
// creates is owned by root, and the database runs as somebody else inside the container — so it
// could not write, and the container crash-looped. A named volume lets the image set up its own
// ownership, and outlives the container, which is what you want for the thing holding the mesh's
// state.
// authenticates with a password (ADR 0004, design 25 §4).
{
"declaration": 1,
@@ -523,20 +523,6 @@ func refusedPlaces(resources []declaration.Resource) map[string]error {
switch res := r.(type) {
case *declaration.Directory:
err = refusePath(res.Path, placingDirectory, moduleOfID(res.ID), res.Owner)
// And outside every home, the walk's judgement before anything is applied (novox/hq issue 358): a
// link another account could make in its path refuses it, and with it what is below it, whatever
// order they are declared in. The apply walks again at the moment of use. A directory handed over
// keeps its own, stricter walk (issue 356), which refuses any link at all.
if err == nil && meshDirsByDescriptor && homeAbove(res.Path) == "" &&
!HandedOver()[handOverKey(res.ID, res.Path)] {
if judged := judgeMeshDir(res.Path); judged != nil {
err = judged
var link *LinkInPathError
if errors.As(judged, &link) {
err = &PlacementRefusedError{Path: res.Path, Why: "it " + link.why()}
}
}
}
case *declaration.Access:
err = refusePath(res.Path, placingAccess, moduleOfID(res.ID), "")
default:
+1 -1
View File
@@ -37,4 +37,4 @@
./cmd/mesh-controller TestReplay401
./cmd/mesh-controller TestReplay201ThePlansSend
./internal/inventory TestAWalksRestIsReadFromEachMachinesOwnSend
./internal/inventory TestAStoredBuildWithASilentMergeableFileIsStillReadAndComposed
./cmd/mesh-controller TestPlanAndNodeListAnOptionalRequirementLeftUnbound