Compare commits
166
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb556d6b74 | ||
|
|
11e005fe38 | ||
|
|
157d1599b4 | ||
|
|
7d949f888f | ||
|
|
9f773e0b3c | ||
|
|
b317dfef45 | ||
|
|
e4d1750015 | ||
|
|
fb01d514a9 | ||
|
|
b692a95280 | ||
|
|
0e2ab4a96a | ||
|
|
b3e5af0e0c | ||
|
|
611113fc63 | ||
|
|
344b6beb8d | ||
|
|
0fc455aa65 | ||
|
|
52d5e38729 | ||
|
|
70945de96e | ||
|
|
e98bf1efcb | ||
|
|
b72af5e918 | ||
|
|
09229d85cb | ||
|
|
bd17a041de | ||
|
|
7855ea126c | ||
|
|
f693d7e171 | ||
|
|
647d20af98 | ||
|
|
8be76b78da | ||
|
|
7515320b72 | ||
|
|
468ecd482e | ||
|
|
5eaaeb7135 | ||
|
|
b9b5a5e1dc | ||
|
|
926444373d | ||
|
|
e4364a1c86 | ||
|
|
4d74e06ed8 | ||
|
|
928942c78c | ||
|
|
af267cb557 | ||
|
|
bfe7fe1530 | ||
|
|
3208aa0461 | ||
|
|
4c84ac992b | ||
|
|
013dad4688 | ||
|
|
f64e00e83c | ||
|
|
b280b9de10 | ||
|
|
6c2a8d6210 | ||
|
|
c07f604047 | ||
|
|
b893cc829c | ||
|
|
70cb217601 | ||
|
|
1d0df24a0d | ||
|
|
202b453de1 | ||
|
|
7a0b275020 | ||
|
|
40350d82fa | ||
|
|
1f708e2d8d | ||
|
|
7f23c537cc | ||
|
|
0aac89f4e2 | ||
|
|
d8c13d97ca | ||
|
|
f1775281e2 | ||
|
|
4083755ca1 | ||
|
|
7b83aaa949 | ||
|
|
23a209f3d0 | ||
|
|
3448f98000 | ||
|
|
12db1d3748 | ||
|
|
7770d193da | ||
|
|
248fe521a3 | ||
|
|
666dcff79b | ||
|
|
caa3fc8995 | ||
|
|
a0c971cea9 | ||
|
|
8802076499 | ||
|
|
2d2e736b45 | ||
|
|
fdfda0e839 | ||
|
|
540cda11df | ||
|
|
ad753c70f6 | ||
|
|
da20fea549 | ||
|
|
2f4f50b9ca | ||
|
|
14f405ece5 | ||
|
|
0d1d780d43 | ||
|
|
5c807808e8 | ||
|
|
da5a5c9b67 | ||
|
|
07a8f04e65 | ||
|
|
dd802c81a7 | ||
|
|
a5a43c8f31 | ||
|
|
3e7f34d041 | ||
|
|
e29e855bdf | ||
|
|
da3c01089e | ||
|
|
253283d68e | ||
|
|
3f038b989e | ||
|
|
3834c72747 | ||
|
|
68088ec782 | ||
|
|
8da2619920 | ||
|
|
59c8cf2f24 | ||
|
|
3ac8657a49 | ||
|
|
230f0a1a18 | ||
|
|
6e487fa01f | ||
|
|
7a74cee83f | ||
|
|
378e65780d | ||
|
|
296e292f55 | ||
|
|
791b92c875 | ||
|
|
1490c3b075 | ||
|
|
4104d49ec8 | ||
|
|
1991fe931b | ||
|
|
d8249dad8b | ||
|
|
df4d1204c7 | ||
|
|
e8c8f0488b | ||
|
|
cfb6a8c531 | ||
|
|
9b7f9a7fd2 | ||
|
|
65632e7999 | ||
|
|
f6a48ac5ef | ||
|
|
93a82cb032 | ||
|
|
de651e0fd1 | ||
|
|
97ee33e8f4 | ||
|
|
19e3eac746 | ||
|
|
863db82737 | ||
|
|
a069898c0c | ||
|
|
ffad53aad2 | ||
|
|
89cfa9431d | ||
|
|
5939bbdefe | ||
|
|
ec85cdf70c | ||
|
|
cd303f68ed | ||
|
|
2cbebbc011 | ||
|
|
ac79e29319 | ||
|
|
ef0ca69cb0 | ||
|
|
cec26d8596 | ||
|
|
e621e15d2f | ||
|
|
a08498b240 | ||
|
|
39ae7de6d3 | ||
|
|
4e12fc19b7 | ||
|
|
19f15d8ea0 | ||
|
|
b1cfb85f86 | ||
|
|
75a27ec1a4 | ||
|
|
85af245f1a | ||
|
|
1ec27e6ba1 | ||
|
|
425134784a | ||
|
|
fb66608a13 | ||
|
|
7be2df627f | ||
|
|
cb006fc8dd | ||
|
|
0273609090 | ||
|
|
3b49d7044f | ||
|
|
ba59283b4b | ||
|
|
461fa9c7b4 | ||
|
|
06bf8cb2fb | ||
|
|
222c9c1d72 | ||
|
|
0520773412 | ||
|
|
eba864dc5f | ||
|
|
2d3d5ab616 | ||
|
|
dc65fe2319 | ||
|
|
fc43238267 | ||
|
|
8c8bc5021e | ||
|
|
557c11af1e | ||
|
|
661e8eff2f | ||
|
|
8c0fa40e65 | ||
|
|
6746ab6492 | ||
|
|
48e9656c67 | ||
|
|
a825a4c904 | ||
|
|
315c622386 | ||
|
|
d3556f452e | ||
|
|
68db18bdfc | ||
|
|
cbc6bac8c0 | ||
|
|
0b4cd46b3f | ||
|
|
7f074d9c94 | ||
|
|
e76ba966b3 | ||
|
|
da6d038e07 | ||
|
|
c795915315 | ||
|
|
aa02e7bf4e | ||
|
|
4eeba8fb20 | ||
|
|
d095a3dcef | ||
|
|
5b08eb09be | ||
|
|
472744bbaa | ||
|
|
9859923664 | ||
|
|
fdab58ae4f | ||
|
|
3b989d8bb3 | ||
|
|
88ea361bf6 |
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/micro"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
|
||||
@@ -432,6 +432,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
||||
}
|
||||
result.Against = built.Against
|
||||
result.SourceFingerprint = built.Source
|
||||
result.SourceInputs = built.SourceInputs
|
||||
// What the walk did instead of building, or why it built (novox/hq ADR 0350 §3, §4).
|
||||
result.ReuseKey = built.ReuseKey
|
||||
if built.Reused != nil {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -55,6 +55,8 @@ func offeredOutsideBy(ctx context.Context, provision string) (string, error) {
|
||||
// its next send would, and the machines that could not be resolved, with why.
|
||||
func consumersFrom(ctx context.Context, open *stores, provider, providerModule, provision string) (
|
||||
[]inventory.PairEnd, []string, error) {
|
||||
// One reading of the mesh for every machine this looks at.
|
||||
ctx = withInputCache(ctx)
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -62,7 +64,7 @@ func consumersFrom(ctx context.Context, open *stores, provider, providerModule,
|
||||
var out []inventory.PairEnd
|
||||
var unresolved []string
|
||||
for _, n := range nodes {
|
||||
plan, _, err := planFor(ctx, open, n.Name)
|
||||
plan, _, err := resolveNode(ctx, open, n.Name)
|
||||
if err != nil {
|
||||
unresolved = append(unresolved, fmt.Sprintf("%s: %s", n.Name, oneLine(err.Error())))
|
||||
continue
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -314,7 +314,7 @@ type afterAct struct {
|
||||
// after the write, which can only find more unmet than the closure would: a seat said held there is
|
||||
// held in the closure too.
|
||||
func resolvedAfter(ctx context.Context, open *stores, shelf map[string]catalogue.Manifest, node string) afterAct {
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
plan, _, err := resolveNode(ctx, open, node)
|
||||
if err == nil {
|
||||
out := afterAct{plan: plan, unheld: plan.Unheld}
|
||||
for _, m := range plan.Modules {
|
||||
@@ -355,6 +355,8 @@ func splitModules(field string) []string {
|
||||
// Nothing here can fail the act it reports on. A mesh that cannot be read is worth saying and is
|
||||
// not a reason to claim the assignment did not happen — it did.
|
||||
func blockedElsewhere(ctx context.Context, open *stores, except string) string {
|
||||
// One reading of the mesh for every machine this looks at.
|
||||
ctx = withInputCache(ctx)
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return "\n\nThe rest of the mesh could not be checked: " + err.Error()
|
||||
@@ -364,7 +366,7 @@ func blockedElsewhere(ctx context.Context, open *stores, except string) string {
|
||||
if n.Name == except {
|
||||
continue
|
||||
}
|
||||
if _, _, err := planFor(ctx, open, n.Name); err != nil {
|
||||
if _, _, err := resolveNode(ctx, open, n.Name); err != nil {
|
||||
blocked[n.Name] = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// What an assignment says about the machines it was not about.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0100: taking a module is its cutover; converging a node is one act, previewed, and
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
// A node is adopted or converged (novox/hq ADR 0100), and it is said to be adopted wherever the
|
||||
@@ -243,7 +244,7 @@ func take(ctx context.Context, open *stores, node, module string, opts takeOptio
|
||||
return "", err
|
||||
}
|
||||
if !slices.Contains(assigned, module) {
|
||||
if plan, _, err := planFor(ctx, open, node); err == nil {
|
||||
if plan, _, err := resolveNode(ctx, open, node); err == nil {
|
||||
if why, runs := plan.Because[module]; runs {
|
||||
return "", fmt.Errorf("%w: %s runs on %s because %s — assign it to %s to take it",
|
||||
inventory.ErrNotAssigned, module, node, why, node)
|
||||
@@ -649,7 +650,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plan, settings, err := planFor(ctx, open, node)
|
||||
plan, _, err := resolveNode(ctx, open, node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -706,14 +707,19 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
filter)
|
||||
}
|
||||
|
||||
gens, err := generators(ctx, open)
|
||||
// What the filter is derived from: the rendering of this machine as a question reads it, with the private network.
|
||||
input, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Reading)
|
||||
rendered, err := compose.Render(input.Input, node, compose.Reading)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
with := rendered.Rendering
|
||||
if with.Generators, err = compose.Generators(input.Input); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rules, err := plan.Rules(with)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -776,7 +782,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
if _, err := inv.Assign(ctx, node, filter); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, _, err := planFor(ctx, open, node); err != nil {
|
||||
if _, _, err := resolveNode(ctx, open, node); err != nil {
|
||||
_ = inv.Unassign(ctx, node, filter)
|
||||
return "", fmt.Errorf("%s cannot run %s, so it was not converged: %w", node, filter, err)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-host/rootsearch"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
|
||||
@@ -33,7 +33,7 @@ const packageManagerKey = "the package manager ("
|
||||
func watchApplyFailing(f *signalFacts) []conditions.Observation {
|
||||
heard := map[string]bool{}
|
||||
for _, m := range f.machines {
|
||||
if !m.asleep() && !m.lastHeard.IsZero() && f.now.Sub(m.lastHeard) <= heartbeatBound(m.every) {
|
||||
if !m.asleep() && !m.lastHeard.IsZero() && !m.silent(f.now) {
|
||||
heard[m.name] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,8 +208,11 @@ type asker struct {
|
||||
// raise keeps the asker's own condition (sourceAsker): which conditions needing the operator could not be
|
||||
// asked, and why. Nil raises nothing (a test that does not look).
|
||||
raise func(ctx context.Context, obs []conditions.Observation) error
|
||||
now func() time.Time
|
||||
logf func(string, ...any)
|
||||
// tell tells the operator one plain notice that needs no answer (operator-channel.tell, novox/hq ADR 0380):
|
||||
// the refusal of an answer to a plan's ask (ADR 0449 §3). Nil tells nothing (a test that does not look).
|
||||
tell func(ctx context.Context, headline string, lines []string) error
|
||||
now func() time.Time
|
||||
logf func(string, ...any)
|
||||
|
||||
saidNoRouter bool
|
||||
saidNoGrant bool
|
||||
@@ -406,7 +409,9 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
for _, r := range all {
|
||||
k := partKey(r.Condition, r.Part)
|
||||
if r.State == string(asks.OutcomeChosen) && now.Sub(r.Ended) < askAgainAfterAnswer {
|
||||
answered[k] = r
|
||||
if prior, has := answered[k]; !has || r.Ended.After(prior.Ended) {
|
||||
answered[k] = r
|
||||
}
|
||||
}
|
||||
if r.State == string(asks.OutcomeRefused) {
|
||||
if prior, has := refused[k]; !has || r.Opened.After(prior.Opened) {
|
||||
@@ -497,7 +502,9 @@ func (a *asker) reconcile(ctx context.Context) error {
|
||||
continue // refused lately, and nothing it was refused for has changed: asked again after the wait
|
||||
}
|
||||
}
|
||||
if r, done := answered[key]; done && sameAsked(r.Actions, p.actions) {
|
||||
// **A condition raised after the answered ask ended is a new one** (novox/hq ADR 0449 §2): a retried
|
||||
// plan that fails again is asked again, not held back for the hour its earlier answer holds.
|
||||
if r, done := answered[key]; done && sameAsked(r.Actions, p.actions) && !c.Raised.After(r.Ended) {
|
||||
if _, held := byCondition[key]; !held {
|
||||
continue
|
||||
}
|
||||
@@ -636,6 +643,8 @@ func doesWords(act conditions.Action) string {
|
||||
return "the delivery starts"
|
||||
case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["stop"] != "":
|
||||
return "the delivery is stopped"
|
||||
case act.Verb == broker.ControllerSeat+".plans" && act.Arguments["retry"] != "":
|
||||
return "the failed builds are asked again"
|
||||
case strings.HasSuffix(act.Verb, ".restart"):
|
||||
return "its service is restarted on " + act.Machine
|
||||
}
|
||||
@@ -884,6 +893,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
ended := a.now()
|
||||
if acted != nil {
|
||||
outcome = "failed: " + acted.Error()
|
||||
a.tellRefused(ctx, *r, act, acted)
|
||||
}
|
||||
r.Ended, r.Acted = ended, outcome
|
||||
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
|
||||
@@ -918,6 +928,55 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tellRefused tells the operator what became of the answer they gave to a plan's ask, when it was not done (novox/hq
|
||||
// ADR 0449 §3): the controller's `plans` verb refuses a Retry, Start or Stop when the plan moved on between the ask and
|
||||
// the answer, and the refusal was only recorded, so the operator who pressed the answer heard nothing. **A refusal is
|
||||
// told as one, and nothing else is**: a verb that said no changed nothing, but a call that got no answer (a timeout, a
|
||||
// refused grant) may have run, and is told as not known. One plain notice with the ask's headline, beside the hand act
|
||||
// the caller still records. An answer to any other ask is not told here. A notice that cannot be told is said in the
|
||||
// log and changes nothing.
|
||||
func (a *asker) tellRefused(ctx context.Context, r asked, act conditions.Action, failure error) {
|
||||
if a.tell == nil || act.Verb != broker.ControllerSeat+".plans" {
|
||||
return
|
||||
}
|
||||
var lines []string
|
||||
var refused verbRefused
|
||||
if errors.As(failure, &refused) {
|
||||
lines = []string{
|
||||
fmt.Sprintf("Your answer, %s, could not be done: %s.", act.Label, strings.TrimRight(refusalWords(refused.words, act), ".")),
|
||||
"Nothing was changed.",
|
||||
}
|
||||
} else {
|
||||
lines = []string{
|
||||
fmt.Sprintf("Your answer, %s, was given, and it is not known whether it was done.", act.Label),
|
||||
"Look at the delivery before you answer again.",
|
||||
}
|
||||
}
|
||||
if err := a.tell(ctx, r.Ask.Headline, lines); err != nil {
|
||||
a.logf("the operator could NOT be told that %s was refused (ask %s): %v", act.Label, r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// refusalWords is a verb's refusal as the operator reads it: the plans named as "the plan" (plainRefusal), and, where
|
||||
// the words still carry an identifier the operator's channel withholds, one sentence of the answer's own.
|
||||
func refusalWords(words string, act conditions.Action) string {
|
||||
return plainRefusalOr(words, refusalFallback(act))
|
||||
}
|
||||
|
||||
// refusalFallback is what an answer's refusal says when its own words cannot be said: one sentence for each answer
|
||||
// to a plan's ask, never another answer's.
|
||||
func refusalFallback(act conditions.Action) string {
|
||||
switch {
|
||||
case act.Arguments["retry"] != "":
|
||||
return "a retry would not run now; the details say why"
|
||||
case act.Arguments["go"] != "":
|
||||
return "the delivery could not be started now; the details say why"
|
||||
case act.Arguments["stop"] != "":
|
||||
return "the delivery could not be stopped now; the details say why"
|
||||
}
|
||||
return "the controller refused it; the details say why"
|
||||
}
|
||||
|
||||
// handActWarrant is the verb an act the operator chose on a warrant is recorded under: a person's decision,
|
||||
// never a repair (handActVerbs).
|
||||
const handActWarrant = "warrant"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// 1315/AC3: the operator's Retry on a failed plan, acted on through the real callAction and the controller's own
|
||||
// `plans` verb, sets the plan building again at its tier and ends its plan-failed condition (novox/hq ADR 0449 §1).
|
||||
func TestAWarrantForRetryRetriesAFailedPlanInTheStoreAndEndsItsCondition(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
asked := asksRecorded(t)
|
||||
planMovedHeard(t) // the bus the verb says its moves on, and the hand-act log
|
||||
twoTiers(t, open)
|
||||
before := time.Now().UTC().Add(-time.Hour)
|
||||
ended := before.Add(time.Minute)
|
||||
failed := inventory.Plan{ID: "plan-1315-retry", Repository: "novox/a", Branch: "main", Commit: "c0ffee",
|
||||
Created: before, State: inventory.PlanFailed, Tier: 0, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Note: "a failed to build in tier 0",
|
||||
Delivery: &inventory.PlanDelivery{},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1",
|
||||
Why: link.KilledByHand}},
|
||||
Times: &inventory.PlanTimes{Cut: &before, Started: &before, Ended: &ended, Class: inventory.ClassLeaf}}
|
||||
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
conn, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// The controller's seat answers `plans` as it does: by running the verb.
|
||||
sub, err := conn.Subscribe(link.SeatToolSubject("mesh-controller", "plans"), func(m *nats.Msg) {
|
||||
var args map[string]string
|
||||
_ = json.Unmarshal(m.Data, &args)
|
||||
answer := link.Answer{Result: json.RawMessage(`"ok"`)}
|
||||
if len(args) != 1 || args["retry"] == "" {
|
||||
answer = link.Answer{Error: "the retry was asked with more than the plan"}
|
||||
} else if err := plansCommand(context.Background(), []string{"retry", args["retry"]}); err != nil {
|
||||
answer = link.Answer{Error: err.Error()}
|
||||
}
|
||||
body, _ := json.Marshal(answer)
|
||||
_ = m.Respond(body)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
// What S23 raises for it, read from the store as the watchdog reads it.
|
||||
raised := func(context.Context) ([]conditions.Condition, error) {
|
||||
facts, err := gatherFailedPlans(ctx, open.inventory, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []conditions.Condition
|
||||
for _, o := range watchPlansFailed(&signalFacts{now: time.Now(), failed: facts}) {
|
||||
out = append(out, conditions.Condition{Key: o.Key(), Kind: o.Kind, Severity: o.Severity, Headline: o.Headline,
|
||||
Explanation: conditions.Verdict(o.Needs, o.Explanation), Needs: o.Needs, Actions: o.Actions})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
now := time.Now()
|
||||
store := memAskedStore{}
|
||||
var sent []published
|
||||
var acts []link.HandAct
|
||||
a := &asker{open: raised, store: store,
|
||||
silence: func(context.Context, string, time.Duration, string, string) error { return nil },
|
||||
publish: func(_ context.Context, subject string, body []byte, id string) error {
|
||||
sent = append(sent, published{subject, id, body})
|
||||
return nil
|
||||
},
|
||||
call: callAction(conn),
|
||||
record: func(_ context.Context, act link.HandAct) error { acts = append(acts, act); return nil },
|
||||
now: func() time.Time { return now },
|
||||
logf: t.Logf}
|
||||
cs, _ := raised(ctx)
|
||||
if len(cs) != 1 || len(cs[0].Actions) != 1 {
|
||||
t.Fatalf("the failed plan raised %+v", cs)
|
||||
}
|
||||
if err := a.reconcile(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var w asks.Warrant
|
||||
for _, r := range store {
|
||||
for _, o := range r.Ask.Options {
|
||||
if o.Label == "Retry" {
|
||||
w = asks.Warrant{Ask: r.ID, Asker: "mesh-controller", About: r.Condition, Outcome: asks.OutcomeChosen,
|
||||
Option: o.ID, Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now,
|
||||
AskDigest: r.Ask.Digest(),
|
||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.Ask == "" {
|
||||
t.Fatalf("no ask offers Retry: %+v", store)
|
||||
}
|
||||
body, _ := json.Marshal(w)
|
||||
if err := a.Decided(ctx, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Decided(ctx, body); err != nil { // heard again: nothing more
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p, err := open.inventory.PlanByID(ctx, failed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := p.Modules["a"]
|
||||
if p.State != inventory.PlanBuilding || p.Tier != 0 || m.State != "asked" || m.Build == "" || m.Build == "build-1" {
|
||||
t.Fatalf("after the warrant the plan is %s at tier %d and a is %+v", p.State, p.Tier, m)
|
||||
}
|
||||
if len(*asked) != 1 {
|
||||
t.Fatalf("the build was asked %d times: %v", len(*asked), *asked)
|
||||
}
|
||||
if got := store[w.Ask]; got.Acted != "done" {
|
||||
t.Fatalf("the ask ended %q", got.Acted)
|
||||
}
|
||||
if len(acts) != 1 || acts[0].Verb != handActWarrant || !strings.Contains(strings.Join(acts[0].Args, " "), "retry="+failed.ID) {
|
||||
t.Fatalf("the warrant was recorded as %+v", acts)
|
||||
}
|
||||
if cs, _ = raised(ctx); len(cs) != 0 {
|
||||
t.Fatalf("the plan-failed condition did not end: %+v", cs)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,15 @@ type askerRig struct {
|
||||
silenced []string
|
||||
acts []link.HandAct
|
||||
now time.Time
|
||||
// refuses, when set, is what every verb the asker calls answers; told is every notice the operator was told.
|
||||
refuses error
|
||||
told []told
|
||||
}
|
||||
|
||||
// told is one plain notice the asker told the operator.
|
||||
type told struct {
|
||||
headline string
|
||||
lines []string
|
||||
}
|
||||
|
||||
func newAskerRig(t *testing.T) *askerRig {
|
||||
@@ -102,6 +111,10 @@ func newAskerRig(t *testing.T) *askerRig {
|
||||
call: func(_ context.Context, a conditions.Action, args map[string]string) error {
|
||||
raw, _ := json.Marshal(args)
|
||||
r.called = append(r.called, a.Verb+"@"+a.Machine+" "+string(raw))
|
||||
return r.refuses
|
||||
},
|
||||
tell: func(_ context.Context, headline string, lines []string) error {
|
||||
r.told = append(r.told, told{headline, lines})
|
||||
return nil
|
||||
},
|
||||
record: func(_ context.Context, act link.HandAct) error { r.acts = append(r.acts, act); return nil },
|
||||
@@ -1013,3 +1026,215 @@ func TestTheServingAskerJudgesTheGrantForReal(t *testing.T) {
|
||||
t.Errorf("held %v (%q, %v) with nothing to read", held, why, err)
|
||||
}
|
||||
}
|
||||
|
||||
// failedPlanCondition is the condition S23 raises for a failed delivery plan, as the keeper holds it.
|
||||
func failedPlanCondition(plan string) conditions.Condition {
|
||||
p := aFailedPlan(plan, nil)
|
||||
f := calm(time.Date(2026, 10, 19, 18, 30, 0, 0, time.UTC))
|
||||
f.failed = []failedPlanFacts{failedFactsFor(p)}
|
||||
o := watchPlansFailed(f)[0]
|
||||
return conditions.Condition{Key: o.Key(), Kind: o.Kind, Severity: o.Severity, Headline: o.Headline,
|
||||
Explanation: conditions.Verdict(o.Needs, o.Explanation), Needs: o.Needs, Actions: o.Actions}
|
||||
}
|
||||
|
||||
// 1315/AC1: a failed plan's Retry is asked at the approve level, in the words "the failed builds are asked again".
|
||||
func TestAFailedPlansRetryIsAskedAtApproveInItsOwnWords(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
c := failedPlanCondition("plan-1792433168676870614")
|
||||
r.open = []conditions.Condition{c}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := r.asksSent(t)
|
||||
if len(sent) != 1 || len(sent[0].Options) != 1 {
|
||||
t.Fatalf("asked %+v", sent)
|
||||
}
|
||||
o := sent[0].Options[0]
|
||||
if o.Label != "Retry" || o.Level != asks.Approve || o.Does != "the failed builds are asked again" {
|
||||
t.Errorf("the option is %+v", o)
|
||||
}
|
||||
if err := sent[0].Check(r.now); err != nil {
|
||||
t.Errorf("the ask is refused as made: %v", err)
|
||||
}
|
||||
if strings.Contains(sent[0].Explanation, "mesh MCP server") {
|
||||
t.Errorf("the ask still says where else to answer: %q", sent[0].Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
// 1315/AC2: the warrant calls plans with retry=<plan> and nothing else, once, and is recorded as the hand act;
|
||||
// the same warrant heard again does nothing.
|
||||
func TestAWarrantForRetryCallsPlansRetryOnceAndIsRecorded(t *testing.T) {
|
||||
plan := "plan-1792433168676870614"
|
||||
r := newAskerRig(t)
|
||||
c := failedPlanCondition(plan)
|
||||
r.open = []conditions.Condition{c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, c.Key, "Retry")
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w)
|
||||
if want := `mesh-controller.plans@ {"retry":"` + plan + `"}`; len(r.called) != 1 || r.called[0] != want {
|
||||
t.Fatalf("called %v, want exactly %s", r.called, want)
|
||||
}
|
||||
if len(r.acts) != 1 {
|
||||
t.Fatalf("hand-acts %+v", r.acts)
|
||||
}
|
||||
act := r.acts[0]
|
||||
if act.Verb != handActWarrant || strings.Join(act.Args, " ") != "mesh-controller.plans retry="+plan ||
|
||||
act.Ask != w.Ask || act.Condition != c.Key || act.Outcome != "done" || act.Cause != conditions.CauseOperatorAnswer {
|
||||
t.Errorf("the hand-act is %+v", act)
|
||||
}
|
||||
if got := r.store[w.Ask]; got.Acted != "done" {
|
||||
t.Errorf("kept %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1316/AC1 and AC2: a Retry or a Stop on a plan that moved on is refused by `plans`; the operator who pressed it is
|
||||
// told once, with the ask's headline and the refusal's words, and the hand act records the failure as before.
|
||||
func TestARefusedAnswerToAPlansAskIsToldOnceAndStillRecorded(t *testing.T) {
|
||||
plan := "plan-1792433168676870614"
|
||||
waiting := conditions.Condition{Key: "plan." + plan + ".waiting", Severity: conditions.Urgent,
|
||||
Headline: "openrazer delivery waiting to start", Needs: "start it, or stop it.",
|
||||
Explanation: "Needs you: start it, or stop it.", Actions: waitingActions(plan, conditions.Urgent)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
c conditions.Condition
|
||||
label string
|
||||
}{
|
||||
{"retry", failedPlanCondition(plan), "Retry"},
|
||||
{"stop", waiting, "Stop"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
r.refuses = verbRefused{verb: "mesh-controller.plans", words: plan + " is done; there is nothing to retry"}
|
||||
r.open = []conditions.Condition{tc.c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
w := r.warrantFor(t, tc.c.Key, tc.label)
|
||||
answerWith(t, r, w)
|
||||
answerWith(t, r, w) // heard again: told once
|
||||
if len(r.told) != 1 {
|
||||
t.Fatalf("told %+v", r.told)
|
||||
}
|
||||
got := r.told[0]
|
||||
if got.headline != tc.c.Headline {
|
||||
t.Errorf("the headline is %q, not the ask's %q", got.headline, tc.c.Headline)
|
||||
}
|
||||
said := strings.Join(got.lines, " ")
|
||||
if !strings.Contains(said, "the plan is done; there is nothing to retry") || !strings.Contains(said, tc.label) {
|
||||
t.Errorf("the lines do not carry the answer and the refusal's words: %q", said)
|
||||
}
|
||||
if why, ok := conditions.Plain(said); !ok {
|
||||
t.Errorf("the notice is not plain, so the channel would withhold it: %s: %q", why, said)
|
||||
}
|
||||
if len(r.acts) != 1 || !strings.HasPrefix(r.acts[0].Outcome, "failed: ") {
|
||||
t.Errorf("the failure is not recorded as before: %+v", r.acts)
|
||||
}
|
||||
if kept := r.store[w.Ask]; !strings.HasPrefix(kept.Acted, "failed: ") {
|
||||
t.Errorf("kept %+v", kept)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1316/AC3: an answer that is done tells nothing; and a refusal of an answer that is no plan's is not told here.
|
||||
func TestADoneAnswerTellsNothingAndOnlyAPlansRefusalIsTold(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
c := failedPlanCondition("plan-1792433168676870614")
|
||||
r.open = []conditions.Condition{c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
answerWith(t, r, r.warrantFor(t, c.Key, "Retry"))
|
||||
if len(r.told) != 0 {
|
||||
t.Fatalf("a retry that was done told %+v", r.told)
|
||||
}
|
||||
|
||||
r = newAskerRig(t)
|
||||
r.refuses = verbRefused{verb: "node-service-manager.restart", words: "unit not found"}
|
||||
module := conditions.Condition{Key: "module.openrazer.g14.unhealthy", Severity: conditions.Warning,
|
||||
Headline: "openrazer not working on g14", Needs: "restart its service openrazer-daemon on g14.",
|
||||
Explanation: "Needs you: restart it.", Actions: []conditions.Action{{Label: "Restart",
|
||||
Verb: "node-service-manager.restart", Machine: "g14", Level: conditions.LevelApprove,
|
||||
Arguments: map[string]string{"unit": "openrazer-daemon.service", "scope": "user"}}}}
|
||||
r.open = []conditions.Condition{module}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
answerWith(t, r, r.warrantFor(t, module.Key, "Restart"))
|
||||
if len(r.told) != 0 || len(r.acts) != 1 {
|
||||
t.Fatalf("a refused restart told %+v, acts %+v", r.told, r.acts)
|
||||
}
|
||||
}
|
||||
|
||||
// 1316, review round 1: a call that got no answer may have run, so it is told as not known, never as "nothing was
|
||||
// changed"; and the sentence a refusal falls back to fits the answer given.
|
||||
func TestAnAnswerThatGotNoAnswerIsToldAsNotKnownNotAsRefused(t *testing.T) {
|
||||
plan := "plan-1792433168676870614"
|
||||
r := newAskerRig(t)
|
||||
r.refuses = errors.New("mesh-controller did not answer plans within 30s")
|
||||
c := failedPlanCondition(plan)
|
||||
r.open = []conditions.Condition{c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
answerWith(t, r, r.warrantFor(t, c.Key, "Retry"))
|
||||
if len(r.told) != 1 {
|
||||
t.Fatalf("told %+v", r.told)
|
||||
}
|
||||
said := strings.Join(r.told[0].lines, " ")
|
||||
if !strings.Contains(said, "not known whether") || strings.Contains(said, "Nothing was changed") {
|
||||
t.Errorf("a call that may have run is told as %q", said)
|
||||
}
|
||||
if why, ok := conditions.Plain(said); !ok {
|
||||
t.Errorf("not plain: %s: %q", why, said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachAnswersRefusalFallsBackToItsOwnSentence(t *testing.T) {
|
||||
plan := "plan-1792433168676870614"
|
||||
for _, tc := range []struct {
|
||||
act conditions.Action
|
||||
want string
|
||||
}{
|
||||
{retryAction(plan), "a retry would not run now"},
|
||||
{conditions.Action{Label: "Start", Verb: "mesh-controller.plans", Arguments: map[string]string{"go": plan}}, "could not be started now"},
|
||||
{conditions.Action{Label: "Stop", Verb: "mesh-controller.plans", Arguments: map[string]string{"stop": plan}}, "could not be stopped now"},
|
||||
} {
|
||||
// Words the operator's channel withholds: a hash and a dotted name.
|
||||
got := refusalWords("build 48bda475dunst at mesh.seat.x refused", tc.act)
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("%s: %q, want %q", tc.act.Label, got, tc.want)
|
||||
}
|
||||
if _, ok := conditions.Plain(got); !ok {
|
||||
t.Errorf("%s: the fallback is not plain: %q", tc.act.Label, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1314/AC6, ADR 0449 §2: a retried plan that fails again within the hour of the answer is asked again, because the
|
||||
// condition that comes back is a new one, raised after the answered ask ended; the same condition still open is not.
|
||||
func TestARetriedPlanThatFailsAgainWithinTheHourIsAskedAgain(t *testing.T) {
|
||||
r := newAskerRig(t)
|
||||
c := failedPlanCondition("plan-1792433168676870614")
|
||||
c.Raised = r.now.Add(-time.Minute)
|
||||
r.open = []conditions.Condition{c}
|
||||
_ = r.a.reconcile(context.Background())
|
||||
answerWith(t, r, r.warrantFor(t, c.Key, "Retry"))
|
||||
// Still open right after the answer: not asked again at once.
|
||||
r.now = r.now.Add(time.Minute)
|
||||
_ = r.a.reconcile(context.Background())
|
||||
if n := len(r.asksSent(t)); n != 1 {
|
||||
t.Fatalf("asked again while the same condition stayed open: %d", n)
|
||||
}
|
||||
// The retry ran, the plan left failed and the condition ended.
|
||||
r.now = r.now.Add(time.Minute)
|
||||
r.open = nil
|
||||
_ = r.a.reconcile(context.Background())
|
||||
// It fails again, ten minutes after the answer: a condition raised after the answered ask ended.
|
||||
r.now = r.now.Add(8 * time.Minute)
|
||||
again := failedPlanCondition("plan-1792433168676870614")
|
||||
again.Raised = r.now
|
||||
r.open = []conditions.Condition{again}
|
||||
if err := r.a.reconcile(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(r.asksSent(t)); n != 2 {
|
||||
t.Fatalf("the second failure was asked %d times in all, want 2", n)
|
||||
}
|
||||
if len(r.called) != 1 {
|
||||
t.Fatalf("something retried without an answer: %v", r.called)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
@@ -135,6 +135,12 @@ func (b busAsked) All(ctx context.Context) ([]asked, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// verbRefused is a verb's own refusal of an act: the seat answered, and said no. Any other error of callAction (a bus
|
||||
// that did not answer, a grant that is refused) is not one: the verb may have run.
|
||||
type verbRefused struct{ verb, words string }
|
||||
|
||||
func (e verbRefused) Error() string { return e.verb + " refused: " + e.words }
|
||||
|
||||
// callAction performs an action's verb as the controller, through the grant that names it.
|
||||
func callAction(conn *nats.Conn) func(ctx context.Context, a conditions.Action, args map[string]string) error {
|
||||
return func(ctx context.Context, a conditions.Action, args map[string]string) error {
|
||||
@@ -168,7 +174,23 @@ func callAction(conn *nats.Conn) func(ctx context.Context, a conditions.Action,
|
||||
return err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
return fmt.Errorf("%s refused: %s", a.Verb, answer.Error)
|
||||
return verbRefused{verb: a.Verb, words: answer.Error}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tellThrough tells the operator one plain notice through the operator channel's `tell`, over the controller's own
|
||||
// connection and grant (novox/hq ADR 0380); a holder's refusal is an error naming it.
|
||||
func tellThrough(conn *nats.Conn) func(ctx context.Context, headline string, lines []string) error {
|
||||
return func(ctx context.Context, headline string, lines []string) error {
|
||||
answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell",
|
||||
map[string]any{"headline": headline, "lines": lines}, tellWithin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
return fmt.Errorf("operator-channel.tell refused: %s", answer.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -201,7 +223,7 @@ func routerRecordOf(conn *nats.Conn, inv *inventory.Inventory) func(ctx context.
|
||||
}
|
||||
|
||||
// asksRecords is the bucket the asks seat's declarer keeps its record of asks in.
|
||||
func asksRecords(ctx context.Context, inv *inventory.Inventory) (string, error) {
|
||||
func asksRecords(ctx context.Context, inv catalogueReader) (string, error) {
|
||||
declared, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -576,6 +598,7 @@ func servingAsker(open *stores, conn *nats.Conn, js jetstream.JetStream, keeper
|
||||
_, err := link.RecordHandAct(ctx, conn, act)
|
||||
return err
|
||||
},
|
||||
tell: tellThrough(conn),
|
||||
routerRecord: routerRecordOf(conn, open.inventory),
|
||||
routerHere: routerHereIn(open.inventory),
|
||||
grantHeld: grantHeldIn(open, conn),
|
||||
|
||||
@@ -8,9 +8,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
func TestAModuleAssignedAndNotPushedIsAwaitingAPushNotUncomposable(t *testing.T) {
|
||||
@@ -146,8 +147,18 @@ func TestAMachineAwaitsAPushSinceItsOldestAssignmentSinceTheLastSend(t *testing.
|
||||
func TestALastSendIsReadFromTheSendAndTheReport(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, aKeeper())
|
||||
pushedBy(t, open, "laptop")
|
||||
// What a machine was sent is read from the commits its assignments name (issue 1109): a module is carried by a send only
|
||||
// when it was built from a commit, and a hand send on a machine with nothing assigned leaves no key to mark unknown.
|
||||
if err := open.inventory.RegisterModule(ctx, aKeeper(), inventory.Source{BuiltFrom: "c-keeper"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := open.inventory.NodeByName(ctx, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RecordSent(ctx, record.ID, "carried", map[string]string{overlay.Name: "c-overlay"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "keeper"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
@@ -46,7 +46,21 @@ func pushedBy(t *testing.T, open *stores, node string) string {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := digestOf(body)
|
||||
if err := open.inventory.RecordSent(ctx, record.ID, digest, declared.Builds); err != nil {
|
||||
// A push records the build of each module it carries (plan.go, Allocating); a module is carried by a send only when it was
|
||||
// built from a commit, for what a machine was sent is read from the commits its assignments name (issue 1109).
|
||||
builds := declared.Builds
|
||||
if builds == nil {
|
||||
current, err := open.inventory.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := make([]string, 0, len(plan.Modules))
|
||||
for _, m := range plan.Modules {
|
||||
names = append(names, m.Module)
|
||||
}
|
||||
builds = carriedBuilds(names, nil, current, nil)
|
||||
}
|
||||
if err := open.inventory.RecordSent(ctx, record.ID, digest, builds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return digest
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A machine out of touch when a tier is sent is not sent to and not waited for (novox/hq ADR 0435 §1, issue 1161): the
|
||||
// delivery goes on with the machines heard, says the others were away, and the away ones get the builds by the send
|
||||
// they are given when heard again (ADR 0353, ADR 0360 §5). Away is the silent check's one verdict, machineFacts.silent
|
||||
// over the watchman's presence (ADR 0433 §3), never a second rule; a sleeping machine is away once presence says so.
|
||||
|
||||
// awayOf is the machines out of touch at a moment: those the silent check calls silent (a sleeping machine
|
||||
// is away once presence says so). A machine
|
||||
// never heard from has not joined and is not away.
|
||||
func awayOf(machines []machineFacts, now time.Time) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range machines {
|
||||
if m.silent(now) {
|
||||
out[m.name] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// judgeAway is the walk's reading of which machines are away as a tier is sent: every node with its last-heard time and
|
||||
// the watchman's verdict on its presence key. A bus that cannot be asked leaves none away, which is what a walk did
|
||||
// before: a machine is not passed on a reading that was not made.
|
||||
var judgeAway = func(ctx context.Context, open *stores, now time.Time) (map[string]bool, error) {
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
js, err := broker.Dial(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer js.Close()
|
||||
machines := make([]machineFacts, len(nodes))
|
||||
for i, n := range nodes {
|
||||
machines[i] = machineFacts{name: n.Name, lastHeard: n.LastSeen}
|
||||
}
|
||||
if err := withPresence(ctx, js.Conn(), machines, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return awayOf(machines, now), nil
|
||||
}
|
||||
|
||||
// rememberAway keeps on the plan the machines of running that are away: sorted, each once.
|
||||
func rememberAway(p *inventory.Plan, running []string, away map[string]bool) {
|
||||
for _, n := range running {
|
||||
if away[n] && !slices.Contains(p.Away, n) {
|
||||
p.Away = append(p.Away, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(p.Away)
|
||||
}
|
||||
|
||||
// runningHeard is the machines of running that the walk waits on for a module: those a send of the walk reached, and
|
||||
// those it did not pass as away.
|
||||
func runningHeard(p inventory.Plan, running []string, s *inventory.PlanModule) []string {
|
||||
return slices.DeleteFunc(slices.Clone(running), func(n string) bool {
|
||||
if !slices.Contains(p.Away, n) {
|
||||
return false
|
||||
}
|
||||
_, sent := s.Rest[n]
|
||||
return !sent
|
||||
})
|
||||
}
|
||||
|
||||
// awaySaying is what a plan says of the machines it passed as away, empty when it passed none.
|
||||
func awaySaying(p inventory.Plan) string {
|
||||
var said []string
|
||||
for _, n := range p.Away {
|
||||
said = append(said, n+" was away: sent when it is heard again")
|
||||
}
|
||||
return strings.Join(said, "; ")
|
||||
}
|
||||
|
||||
// judgedAway reads which machines are away for a tier about to be sent, and says when it cannot.
|
||||
func judgedAway(ctx context.Context, open *stores, now time.Time) map[string]bool {
|
||||
away, err := judgeAway(ctx, open, now)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "which machines are away cannot be read, so none is passed over: %v\n", err)
|
||||
return nil // empty-on-error: none is passed over on a reading that was not made; the error is said just above
|
||||
}
|
||||
return away
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// 1161/AC5, with the store and a real bus and no ticker: a plan of two tiers whose first tier runs on a machine away when it
|
||||
// is sent finishes that tier without waiting for it and without its bound, sends the second tier to the machine heard
|
||||
// alone, and says the machine was away (issue 1161). The machine away keeps its want: it was sent nothing of the plan's
|
||||
// builds, so the send it gets when it is heard again carries them (AC3).
|
||||
func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
|
||||
// laptop is away as every tier is sent; anchor is heard.
|
||||
wasAway := judgeAway
|
||||
judgeAway = func(context.Context, *stores, time.Time) (map[string]bool, error) {
|
||||
return map[string]bool{"laptop": true}, nil
|
||||
}
|
||||
t.Cleanup(func() { judgeAway = wasAway })
|
||||
|
||||
js, err := broker.Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := js.Conn().JetStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkEvents = link.OverNATS{Conn: js.Conn(), JS: legacy}
|
||||
t.Cleanup(func() { checkEvents = nil })
|
||||
sentHeard := make(chan *nats.Msg, 32)
|
||||
sub, err := js.Conn().ChanSubscribe(link.SeatEventSubject(link.MeshControllerSeat, link.KeySent), sentHeard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
wasSaved := inventory.PlanSaved
|
||||
inventory.PlanSaved = servingPlanSaved(ctx, checkEvents, open)
|
||||
t.Cleanup(func() { inventory.PlanSaved = wasSaved })
|
||||
|
||||
// The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept.
|
||||
var mu sync.Mutex
|
||||
var asks []string
|
||||
var sends [][]string
|
||||
wasAsk := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond))
|
||||
asks = append(asks, source.Repository+"#"+id)
|
||||
return id, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = wasAsk })
|
||||
digests := 0
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
sends = append(sends, append([]string(nil), names...))
|
||||
current, err := open.inventory.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builds := map[string]string{}
|
||||
for _, m := range []string{"a", "b"} {
|
||||
builds[m] = current[m].Commit
|
||||
}
|
||||
for _, node := range names {
|
||||
digests++
|
||||
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), fmt.Sprintf("d-%s-%d", node, digests), builds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = wasSend })
|
||||
|
||||
register := func(m, commit string, asked time.Time) {
|
||||
t.Helper()
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: commit},
|
||||
inventory.Source{Repository: "novox/" + m, Seat: "git", Path: "modules/" + m, BuiltFrom: commit, Head: commit,
|
||||
Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
record := func(id, m, commit string, at time.Time) {
|
||||
t.Helper()
|
||||
b := inventory.Build{ID: id, Module: m, Commit: commit, Repository: "novox/" + m, Path: "modules/" + m, Asked: at, At: at}
|
||||
b.Manifest, _ = json.Marshal(catalogue.Manifest{Module: m, Version: commit})
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
old := time.Now().Add(-2 * time.Hour)
|
||||
for _, m := range []string{"a", "b"} {
|
||||
record("build-"+m+"-1", m, "c1", old)
|
||||
register(m, "c1", old)
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
if _, err := inv.Assign(ctx, n, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, n), "d-"+n+"-c1", map[string]string{"a": "c1", "b": "c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
record("build-a-2", "a", "c2", time.Now().Add(-time.Minute))
|
||||
register("a", "c2", time.Now().Add(-time.Minute))
|
||||
|
||||
// A machine's report on what it was sent, heard as the controller hears it.
|
||||
listener := nudgingListener{Enrolment: link.Enrolment{Inventory: inv}}
|
||||
seq := int64(0)
|
||||
reports := func(node string) {
|
||||
t.Helper()
|
||||
last, found, err := inv.SentTo(ctx, node)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("%s was sent nothing: %v", node, err)
|
||||
}
|
||||
seq++
|
||||
body, _ := json.Marshal(map[string]any{"node": node, "applied": []string{"x.server"}, "declared": last.Digest,
|
||||
"report_sequence": seq})
|
||||
var report link.Report
|
||||
if err := json.Unmarshal(body, &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := listener.Heard(ctx, report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
plan := func() inventory.Plan {
|
||||
t.Helper()
|
||||
p, err := inv.PlanByID(ctx, "plan-1160")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
built := time.Now().UTC()
|
||||
p := inventory.Plan{ID: "plan-1160", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2", Created: time.Now().UTC(),
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Delivery: &inventory.PlanDelivery{Order: []inventory.Edge{{From: "b", To: "a", Kind: edgeChangeOrder}}},
|
||||
Modules: map[string]*inventory.PlanModule{
|
||||
"a": {State: "built", BuiltAt: &built, Commit: "c2", Build: "build-a-2"},
|
||||
"b": {},
|
||||
}}
|
||||
first, stopFirst := context.WithCancel(ctx)
|
||||
defer stopFirst()
|
||||
waitFirst := startRolloutEvents(first, open, js)
|
||||
// Ends with the test before the cleanups restore what the look reads, which run after it (issue 1338).
|
||||
t.Cleanup(func() { stopFirst(); waitFirst() })
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, "tier 0 sent to anchor alone", func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(sends) == 1 && strings.Join(sends[0], ",") == "anchor"
|
||||
})
|
||||
reports("anchor")
|
||||
// The note of the move to tier 1 is read in the step that sees it: later steps of the tier say their own.
|
||||
var moved string
|
||||
eventually(t, "tier 1 reached on anchor's report alone", func() bool {
|
||||
if q := plan(); q.Tier == 1 {
|
||||
moved = q.Note
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if !strings.Contains(moved, "laptop was away: sent when it is heard again") || strings.Contains(moved, "anchor") {
|
||||
t.Errorf("1161/AC2: the plan says %q; want laptop named away and not anchor", moved)
|
||||
}
|
||||
// The ask for tier 1's build is the next step, and records its build id on the module.
|
||||
eventually(t, "tier 1 asked", func() bool { m := plan().Modules["b"]; return m != nil && m.Build != "" })
|
||||
b := plan().Modules["b"]
|
||||
askedAt, _ := link.BuildAskedAt(b.Build)
|
||||
record(b.Build, "b", "c2", askedAt)
|
||||
register("b", "c2", askedAt)
|
||||
planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build)
|
||||
eventually(t, "tier 1 sent to anchor alone", func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(sends) == 2 && strings.Join(sends[1], ",") == "anchor"
|
||||
})
|
||||
// 1161/AC3: laptop was sent nothing of the plan's builds, so it still wants them for the send it gets when heard again.
|
||||
was, known, err := inv.SentBuilds(ctx, "laptop")
|
||||
if err != nil || !known {
|
||||
t.Fatalf("1161/AC3: laptop's last send: %v, known %v", err, known)
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if was["a"] == current["a"].Commit || was["a"] != "c1" {
|
||||
t.Errorf("1161/AC3: laptop was last sent a at %q and a is now %q; want it still at c1, owed c2", was["a"], current["a"].Commit)
|
||||
}
|
||||
if got := plan().Away; strings.Join(got, ",") != "laptop" {
|
||||
t.Errorf("1161/AC2: the plan keeps %v away; want laptop", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A machine out of touch when a tier is sent is not sent to and not waited for (novox/hq ADR 0435 §1, issue 1161): the
|
||||
// walk judges it with the silent check's one verdict, machineFacts.silent, and a sleeping machine is away once presence says so.
|
||||
|
||||
// 1161/AC1: away is the silent check's verdict; AC4: a machine heard is not away.
|
||||
func TestAMachineIsAwayAsTheSilentCheckJudgesIt(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
machines := []machineFacts{
|
||||
{name: "heard", lastHeard: now.Add(-time.Minute)},
|
||||
{name: "quiet", lastHeard: now.Add(-silentBound - time.Minute)},
|
||||
{name: "stuck", lastHeard: now, presence: presenceSilent},
|
||||
{name: "back", lastHeard: now.Add(-time.Hour), presence: presenceHeard},
|
||||
{name: "new"},
|
||||
}
|
||||
got := awayOf(machines, now)
|
||||
for node, want := range map[string]bool{"heard": false, "quiet": true, "stuck": true, "back": false,
|
||||
"new": false} {
|
||||
if got[node] != want {
|
||||
t.Errorf("1161/AC1: %s away is %v; want %v (away: %v)", node, got[node], want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1161/AC1, AC2: the plan keeps the machines of the tier it passed as away, sorted and once.
|
||||
func TestAPlanKeepsTheMachinesItPassedAsAway(t *testing.T) {
|
||||
p := &inventory.Plan{}
|
||||
away := map[string]bool{"g14": true, "ace": true}
|
||||
rememberAway(p, []string{"anchor", "g14"}, away)
|
||||
rememberAway(p, []string{"g14", "ace"}, away)
|
||||
if !slices.Equal(p.Away, []string{"ace", "g14"}) {
|
||||
t.Fatalf("1161/AC2: the plan keeps %v away; want ace, g14", p.Away)
|
||||
}
|
||||
}
|
||||
|
||||
// 1161/AC2: the plan says it, where its other passed machines are said.
|
||||
func TestAPlanSaysTheMachinesItPassedAsAway(t *testing.T) {
|
||||
say := awaySaying(inventory.Plan{Away: []string{"g14"}})
|
||||
if !strings.Contains(say, "g14 was away: sent when it is heard again") {
|
||||
t.Fatalf("1161/AC2: the plan says %q", say)
|
||||
}
|
||||
if got := awaySaying(inventory.Plan{}); got != "" {
|
||||
t.Fatalf("1161/AC2: a plan that passed no machine says %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1161/AC1: the gate does not wait for a machine passed as away, unless a send reached it; AC4: a machine heard is waited for.
|
||||
func TestAGateDoesNotWaitForAMachinePassedAsAway(t *testing.T) {
|
||||
p := inventory.Plan{Away: []string{"g14"}}
|
||||
none := &inventory.PlanModule{}
|
||||
if got := runningHeard(p, []string{"anchor", "g14"}, none); !slices.Equal(got, []string{"anchor"}) {
|
||||
t.Fatalf("1161/AC1: the gate waits on %v; want anchor alone", got)
|
||||
}
|
||||
sent := &inventory.PlanModule{Rest: map[string]inventory.SentDeclaration{"g14": {Digest: "d"}}}
|
||||
if got := runningHeard(p, []string{"anchor", "g14"}, sent); !slices.Equal(got, []string{"anchor", "g14"}) {
|
||||
t.Fatalf("1161/AC4: a machine a send reached is waited for: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
|
||||
@@ -176,6 +176,11 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
|
||||
repository, m.Base, m.Commit, already)
|
||||
return nil
|
||||
}
|
||||
if already == 0 {
|
||||
if done, err := f.firstBuilds(ctx, m, entries, read, now); err != nil || done {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Print(nothingReadsIt(repository, m))
|
||||
return nil
|
||||
}
|
||||
@@ -362,7 +367,7 @@ func buildsEvery(p inventory.Plan, modules []inventory.Entry) bool {
|
||||
// ownPath says a merge touches a module on the controller's own path, whose walk waits for nobody's word.
|
||||
func ownPath(touches []inventory.Entry) bool {
|
||||
for _, e := range touches {
|
||||
if _, own := onTheControllersPath[e.Manifest.Module]; own {
|
||||
if onTheOwnPath(e.Manifest.Module, e.Manifest) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -742,6 +747,9 @@ func carriedOf(merges []inventory.BatchedMerge) ([]inventory.PlanCommit, []inven
|
||||
type keptMerge struct {
|
||||
link.SourceMoved
|
||||
Moves []string `json:"moves,omitempty"`
|
||||
// FirstBuild says the merge is a first merge into a repository the mesh builds nothing from, kept to ask the first
|
||||
// build of its modules once mesh-delivery's word on it is kept (novox/hq ADR 0432, issue 1146).
|
||||
FirstBuild bool `json:"first_build,omitempty"`
|
||||
}
|
||||
|
||||
// announcedOf is a kept merge as the forge announced it, and what it moved; empty where the record says none.
|
||||
@@ -937,7 +945,11 @@ func cutUnrelated(ctx context.Context, open *stores, batches, started, waiting [
|
||||
var read map[string][]inventory.ReadRepository
|
||||
var edges []inventory.Edge
|
||||
judged := false
|
||||
openWalks, err := openWalksOf(ctx, inv, started)
|
||||
onPath, err := ownPathNow(ctx, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
openWalks, err := openWalksOf(ctx, inv, started, onPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -970,7 +982,7 @@ func cutUnrelated(ctx context.Context, open *stores, batches, started, waiting [
|
||||
var fold, keep []inventory.Plan
|
||||
if !b.OwnPath() {
|
||||
for _, w := range waiting {
|
||||
ww, err := openWalkOf(ctx, inv, w)
|
||||
ww, err := openWalkOf(ctx, inv, w, onPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1005,7 +1017,7 @@ func cutUnrelated(ctx context.Context, open *stores, batches, started, waiting [
|
||||
if !cut.Open() {
|
||||
continue
|
||||
}
|
||||
w, err := openWalkOf(ctx, inv, cut)
|
||||
w, err := openWalkOf(ctx, inv, cut, onPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1383,8 +1395,9 @@ func classOfModules(modules []string, entries []inventory.Entry) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
onPath := ownPathOf(entries)
|
||||
for _, m := range modules {
|
||||
if _, own := onTheControllersPath[m]; own || resolvers[m] {
|
||||
if _, own := onPath[m]; own || resolvers[m] {
|
||||
return inventory.ClassCore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
@@ -73,6 +73,8 @@ func reportKept(ctx context.Context, plan catalogue.Resolution) {
|
||||
// sent, on every machine — the resolver kept it there (said, urgent, until a person pins), or a pin
|
||||
// moves it (said, so the data goes first), and never anything else.
|
||||
func probeBindings(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
// One reading of the mesh for every machine this looks at.
|
||||
ctx = withInputCache(ctx)
|
||||
inv := d.open.inventory
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
@@ -80,7 +82,7 @@ func probeBindings(ctx context.Context, d *doctor) ([]conditions.Observation, er
|
||||
}
|
||||
var out []conditions.Observation
|
||||
for _, n := range nodes {
|
||||
plan, _, err := planFor(ctx, d.open, n.Name)
|
||||
plan, _, err := resolveNode(ctx, d.open, n.Name)
|
||||
if err != nil {
|
||||
if unresolvable(err) {
|
||||
// D1 says it, with the binding that refused it when that is why.
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -14,9 +15,9 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -152,6 +153,7 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
||||
Path: result.Path,
|
||||
// What it was made from (novox/hq issue 280): two builds with one are one build.
|
||||
SourceFingerprint: result.SourceFingerprint,
|
||||
SourceInputs: result.SourceInputs,
|
||||
// And its reuse key (novox/hq ADR 0350 §1), which the chain names (ADR 0408 §1).
|
||||
ReuseKey: result.ReuseKey,
|
||||
}
|
||||
@@ -236,47 +238,56 @@ func buildsCommand(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
for _, b := range builds {
|
||||
what := b.Module
|
||||
if what == "" {
|
||||
// It failed before knowing what it was building, which is most of the interesting
|
||||
// failures. The repository is what a person has to go and look at.
|
||||
what = "?"
|
||||
}
|
||||
outcome := "built " + short(b.Commit)
|
||||
if !b.Worked() {
|
||||
outcome = "failed"
|
||||
}
|
||||
fmt.Printf("%-18s %-14s %-10s %s %s\n",
|
||||
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"), b.ID)
|
||||
fmt.Printf(" %s", b.Repository)
|
||||
if b.Ref != "" {
|
||||
fmt.Printf(" at %s", b.Ref)
|
||||
}
|
||||
fmt.Println()
|
||||
for _, made := range b.Made {
|
||||
fmt.Printf(" %-10s %s\n", made.Kind, made.Reference)
|
||||
}
|
||||
// What it published and under what reuse key (novox/hq ADR 0408 §1).
|
||||
if p := b.Published; p != nil {
|
||||
if p.Artifact != "" {
|
||||
fmt.Printf(" published %s\n", p.Artifact)
|
||||
}
|
||||
if p.Package != nil {
|
||||
fmt.Printf(" published %s@%s\n", p.Package.Name, p.Package.Version)
|
||||
}
|
||||
}
|
||||
if b.ReuseKey != "" {
|
||||
fmt.Printf(" reuse key %s\n", b.ReuseKey)
|
||||
}
|
||||
if !b.Worked() {
|
||||
// The builder's own first line. The whole failure is often a build log, and printing
|
||||
// it here would bury every other row.
|
||||
fmt.Printf(" %s\n", firstLine(b.Failed))
|
||||
}
|
||||
printBuild(os.Stdout, b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printBuild says one build: what, its outcome, where and when, what it made and published, its reuse key, and its
|
||||
// source fingerprint (novox/hq issue 1204: the one tool that reads what the builder recorded it was made from).
|
||||
func printBuild(w io.Writer, b inventory.Build) {
|
||||
what := b.Module
|
||||
if what == "" {
|
||||
// It failed before knowing what it was building, which is most of the interesting
|
||||
// failures. The repository is what a person has to go and look at.
|
||||
what = "?"
|
||||
}
|
||||
outcome := "built " + short(b.Commit)
|
||||
if !b.Worked() {
|
||||
outcome = "failed"
|
||||
}
|
||||
fmt.Fprintf(w, "%-18s %-14s %-10s %s %s\n",
|
||||
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"), b.ID)
|
||||
fmt.Fprintf(w, " %s", b.Repository)
|
||||
if b.Ref != "" {
|
||||
fmt.Fprintf(w, " at %s", b.Ref)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
for _, made := range b.Made {
|
||||
fmt.Fprintf(w, " %-10s %s\n", made.Kind, made.Reference)
|
||||
}
|
||||
// What it published and under what reuse key (novox/hq ADR 0408 §1).
|
||||
if p := b.Published; p != nil {
|
||||
if p.Artifact != "" {
|
||||
fmt.Fprintf(w, " published %s\n", p.Artifact)
|
||||
}
|
||||
if p.Package != nil {
|
||||
fmt.Fprintf(w, " published %s@%s\n", p.Package.Name, p.Package.Version)
|
||||
}
|
||||
}
|
||||
if b.ReuseKey != "" {
|
||||
fmt.Fprintf(w, " reuse key %s\n", b.ReuseKey)
|
||||
}
|
||||
if b.SourceFingerprint != "" {
|
||||
fmt.Fprintf(w, " source %s\n", b.SourceFingerprint)
|
||||
}
|
||||
if !b.Worked() {
|
||||
// The builder's own first line. The whole failure is often a build log, and printing
|
||||
// it here would bury every other row.
|
||||
fmt.Fprintf(w, " %s\n", firstLine(b.Failed))
|
||||
}
|
||||
}
|
||||
|
||||
// builderCommand issues a build machine its own broker credential.
|
||||
//
|
||||
// **A build machine is not a node**, and giving it a node's account would let it read another
|
||||
@@ -473,7 +484,7 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
|
||||
return "", err
|
||||
}
|
||||
// Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266).
|
||||
if err := verbMayAsk(ctx, source, repository); err != nil {
|
||||
if err := verbMayAsk(ctx, source, repository, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -812,7 +823,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := verbMayAsk(ctx, source, repository); err != nil {
|
||||
if err := verbMayAsk(ctx, source, repository, path); err != nil {
|
||||
return err
|
||||
}
|
||||
ident, err := openIdentity(ctx)
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -88,7 +88,7 @@ func newSourceForms(ctx context.Context, inv *inventory.Inventory) *sourceForms
|
||||
if world == nil {
|
||||
w := catalogue.World{}
|
||||
if shelf, err := inv.Catalogue(ctx); err == nil {
|
||||
if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil {
|
||||
if read, err := restOfTheMeshOf(ctx, inv, shelf, ""); err == nil {
|
||||
w = read
|
||||
}
|
||||
}
|
||||
@@ -389,10 +389,31 @@ func (f *sourceForms) onTheForge(repository, seat string) (owner, name string, o
|
||||
// least one required status, no administrator merging past one.
|
||||
//
|
||||
// Anything else only when the build request was kept as asked at the controller's terminal, for this very
|
||||
// repository and path. path is the module's directory as built; branch the trunk it is registered from.
|
||||
// repository and path; or, for the build of a merge, when mesh-delivery announced it on the operator's Approve of
|
||||
// that head (novox/hq ADR 0420 §7 for a moved module, ADR 0432 for a new one), the forge's checks still holding. path is the module's directory as built; branch the trunk it is registered from.
|
||||
func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module string, built inventory.Source,
|
||||
buildID, path, branch string) (int64, error) {
|
||||
forms := newSourceForms(ctx, inv)
|
||||
return mayRegisterIn(ctx, inv, newSourceForms(ctx, inv), module, built, buildID, path, branch)
|
||||
}
|
||||
|
||||
// catalogueReader is the catalogue's manifests, as the router's records are found in them.
|
||||
type catalogueReader interface {
|
||||
Catalogue(ctx context.Context) (map[string]catalogue.Manifest, error)
|
||||
}
|
||||
|
||||
// registrations is what a registration's decision reads of the mesh: *inventory.Inventory, or a test's stand-in.
|
||||
type registrations interface {
|
||||
catalogueReader
|
||||
SourceOf(ctx context.Context, module string) (inventory.Source, error)
|
||||
SourceIdentity(ctx context.Context, module string) (int64, error)
|
||||
Catalogued(ctx context.Context) ([]inventory.Entry, error)
|
||||
BuildRequestByID(ctx context.Context, id string) (inventory.BuildRequest, bool, error)
|
||||
MergeOf(ctx context.Context, repository, commit string) (inventory.BatchedMerge, bool, error)
|
||||
}
|
||||
|
||||
// mayRegisterIn is mayRegisterFrom on what it reads of the mesh handed in.
|
||||
func mayRegisterIn(ctx context.Context, inv registrations, forms *sourceForms, module string, built inventory.Source,
|
||||
buildID, path, branch string) (int64, error) {
|
||||
was, err := inv.SourceOf(ctx, module)
|
||||
isNew := errors.Is(err, inventory.ErrNoSuchModule)
|
||||
if err != nil && !isNew {
|
||||
@@ -404,7 +425,8 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
|
||||
}
|
||||
refuse := func(why string) (int64, error) {
|
||||
return 0, fmt.Errorf("%w: %s. A module is registered from such a source only by a build asked at the "+
|
||||
"controller's terminal, never through a verb: a registered module is what the next push sends, and whoever "+
|
||||
"controller's terminal, or by the build of a merge mesh-delivery announced on the operator's Approve of that "+
|
||||
"head, never through a verb: a registered module is what the next push sends, and whoever "+
|
||||
"may call a verb includes agents (novox/hq ADR 0266)", errNotItsSource, why)
|
||||
}
|
||||
|
||||
@@ -438,8 +460,11 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
|
||||
// **Or the move the operator approved** (novox/hq ADR 0420 §7, issue 1025): a known module built from another
|
||||
// repository by a merge, whose pull request mesh-delivery merged on the operator's Approve of that head.
|
||||
// A verb alone, or a merge with no such warrant, is refused as before.
|
||||
// **Or a new module from a repository nothing registered is built from, on the same warrant** (novox/hq ADR 0432,
|
||||
// issue 1146): the build of a merge mesh-delivery announced on the operator's Approve of that head registers it.
|
||||
// The forge's protection and the repository's identity are checked below as for every registration.
|
||||
movedWhy := ""
|
||||
if why != "" && !terminal && !isNew {
|
||||
if why != "" && !terminal {
|
||||
accepted, err := approvedMove(ctx, inv, forms, buildID, built)
|
||||
switch {
|
||||
case err == nil:
|
||||
@@ -507,7 +532,10 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
|
||||
owner, name, m, facts.ID, m, id))
|
||||
}
|
||||
}
|
||||
if movedWhy != "" {
|
||||
if movedWhy != "" && isNew {
|
||||
fmt.Printf("%s: %s is new, from %s/%s — registered at the operator's Approve: %s\n", buildID, module, owner, name,
|
||||
movedWhy)
|
||||
} else if movedWhy != "" {
|
||||
fmt.Printf("%s: %s moved from %s to %s/%s — registered at the operator's Approve: %s\n", buildID, module,
|
||||
sourceWords(was.Repository, was.Seat), owner, name, movedWhy)
|
||||
}
|
||||
@@ -516,7 +544,7 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
|
||||
|
||||
// askedHereFor says the build was asked at the controller's terminal, for this repository and this path: a kept
|
||||
// request marked so, whose source is the outcome's (novox/hq ADR 0266).
|
||||
func askedHereFor(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string,
|
||||
func askedHereFor(ctx context.Context, inv registrations, forms *sourceForms, buildID string,
|
||||
built inventory.Source, path string) (bool, error) {
|
||||
r, found, err := inv.BuildRequestByID(ctx, buildID)
|
||||
if err != nil || !found || !r.AtTerminal {
|
||||
@@ -539,13 +567,49 @@ func sourceWords(repository, seat string) string {
|
||||
return buildSource{Repository: repository, Seat: seat}.String()
|
||||
}
|
||||
|
||||
// firstBuildWarrant is what the controller checked before it asks the first build of the modules a merge adds to a
|
||||
// repository nothing is built from: the merge was made on the operator's Approve of its head (approvedAnnounced). It
|
||||
// travels in the context of that ask alone, in this process, so no verb carries one.
|
||||
type firstBuildWarrant struct {
|
||||
repository string // owner/name on the git seat
|
||||
paths map[string]bool
|
||||
why string
|
||||
}
|
||||
|
||||
type firstBuildWarrantKey struct{}
|
||||
|
||||
// withFirstBuildWarrant is ctx carrying the warrant for asking the first builds of paths in repository.
|
||||
func withFirstBuildWarrant(ctx context.Context, repository string, paths []string, why string) context.Context {
|
||||
w := &firstBuildWarrant{repository: repository, paths: map[string]bool{}, why: why}
|
||||
for _, p := range paths {
|
||||
w.paths[strings.Trim(p, "/")] = true
|
||||
}
|
||||
return context.WithValue(ctx, firstBuildWarrantKey{}, w)
|
||||
}
|
||||
|
||||
func firstBuildWarrantIn(ctx context.Context) *firstBuildWarrant {
|
||||
w, _ := ctx.Value(firstBuildWarrantKey{}).(*firstBuildWarrant)
|
||||
return w
|
||||
}
|
||||
|
||||
// covers says the warrant is for exactly this ask: the repository on the git seat, and a module path the merge holds.
|
||||
func (w *firstBuildWarrant) covers(source buildSource, path string) bool {
|
||||
return w != nil && source.Seat == gitSeat && strings.EqualFold(strings.Trim(source.Repository, "/"), w.repository) &&
|
||||
w.paths[strings.Trim(path, "/")]
|
||||
}
|
||||
|
||||
// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from
|
||||
// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be
|
||||
// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked.
|
||||
func verbMayAsk(ctx context.Context, source buildSource, url string) error {
|
||||
func verbMayAsk(ctx context.Context, source buildSource, url, path string) error {
|
||||
if startedAtTheTerminal() {
|
||||
return nil
|
||||
}
|
||||
// **Or the first build of a module a merge adds on the operator's Approve** (novox/hq ADR 0432, issue 1146): the
|
||||
// controller's own ask, carrying the warrant it checked, for that repository and the module paths the merge holds.
|
||||
if firstBuildWarrantIn(ctx).covers(source, path) {
|
||||
return nil
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -134,18 +134,18 @@ func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T)
|
||||
ctx := t.Context()
|
||||
t.Setenv(verbVar, "build")
|
||||
t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat")
|
||||
err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git")
|
||||
err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git", "")
|
||||
var policy *heldAtTheTerminal
|
||||
if !errors.As(err, &policy) {
|
||||
t.Fatalf("a verb's build of an agent's repository was asked: %v", err)
|
||||
}
|
||||
if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"},
|
||||
"http://forge.internal:20000/novox/mesh-catalog.git"); err != nil {
|
||||
"http://forge.internal:20000/novox/mesh-catalog.git", ""); err != nil {
|
||||
t.Fatalf("a verb's build of the catalogue repository was refused: %v", err)
|
||||
}
|
||||
t.Setenv(verbVar, "")
|
||||
t.Setenv(link.CallerVar, "")
|
||||
if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, ""); err != nil {
|
||||
if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "", ""); err != nil {
|
||||
t.Fatalf("the terminal was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,7 @@ func TestAModuleMovesOnTheOperatorsApproveOfTheMergeThatMovedIt(t *testing.T) {
|
||||
keptDeliveryMerge = func(_ context.Context, owner, repo string, number int, head string) (deliveryMerge, bool, error) {
|
||||
return a.merge, !c.unkept && owner == "novox" && repo == "mesh-host" && number == 7 && head == a.merge.HeadSHA, nil
|
||||
}
|
||||
routerAskOf = func(_ context.Context, _ *inventory.Inventory, id string) (*asks.Ask, *asks.Warrant, error) {
|
||||
routerAskOf = func(_ context.Context, _ catalogueReader, _, id string) (*asks.Ask, *asks.Warrant, error) {
|
||||
if id != a.merge.AskID {
|
||||
t.Errorf("%s: the router was asked for %q", c.what, id)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -32,3 +37,43 @@ func TestABuildIDSaysWhenItWasAsked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 1204: builds says the source fingerprint the builder recorded, the one tool that reads it.
|
||||
func TestBuildsSaysTheSourceFingerprint(t *testing.T) {
|
||||
at := time.Date(2026, 10, 19, 10, 40, 0, 0, time.UTC)
|
||||
built := inventory.Build{ID: "build-1", Module: "mailu", Commit: "b4527d9091e972f28cb365623d9280045e7741a3", On: "ace",
|
||||
Repository: "novox/mesh-catalog", At: at, ReuseKey: "r1:abc",
|
||||
SourceFingerprint: "src1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}
|
||||
|
||||
// 1204/AC1
|
||||
var out bytes.Buffer
|
||||
printBuild(&out, built)
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
if last := lines[len(lines)-1]; last != " source "+built.SourceFingerprint {
|
||||
t.Errorf("last line %q; want the source line after the reuse key:\n%s", last, out.String())
|
||||
}
|
||||
if !strings.Contains(lines[len(lines)-2], "reuse key r1:abc") {
|
||||
t.Errorf("the source line does not follow the reuse key:\n%s", out.String())
|
||||
}
|
||||
|
||||
// 1204/AC2
|
||||
without := built
|
||||
without.SourceFingerprint = ""
|
||||
var plain bytes.Buffer
|
||||
printBuild(&plain, without)
|
||||
if strings.Contains(plain.String(), "source") {
|
||||
t.Errorf("a build with no fingerprint says a source line:\n%s", plain.String())
|
||||
}
|
||||
if want := strings.Join(lines[:len(lines)-1], "\n") + "\n"; plain.String() != want {
|
||||
t.Errorf("the other lines changed:\n%s\nwant\n%s", plain.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1215/AC4: what the builder said of the inputs is what the controller keeps of the build.
|
||||
func TestTheControllerKeepsTheInputsABuilderSaid(t *testing.T) {
|
||||
in := &sdkdelivery.SourceInputs{Module: "mailu", Tree: "t", Toolchains: map[string]string{"code": "ts sha256:aa 01"}}
|
||||
kept := buildFrom(link.BuildResult{ID: "build-1", Repository: "r", Commit: "c", SourceFingerprint: "src1:x", SourceInputs: in})
|
||||
if !reflect.DeepEqual(kept.SourceInputs, in) {
|
||||
t.Errorf("the controller keeps %+v, the builder said %+v", kept.SourceInputs, in)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// The chain on the bus (novox/hq ADR 0408, design 47 milestone 5): builds, rollouts and assignments are keys of the
|
||||
// bus's key-value store, written by the controller that holds the lease. The tables of the controller's store are
|
||||
// copied into them once, by the serving controller at its start, and a kind is read from its bucket only once the move
|
||||
// has recorded it (inventory.ChainReads).
|
||||
// bus's key-value store, written by the controller that holds the lease, and the controller's store keeps none of them.
|
||||
|
||||
// chainHolds is whether this process may write the chain now: the lease's gate, the one every act passes.
|
||||
func chainHolds(ctx context.Context) error {
|
||||
@@ -33,97 +31,7 @@ func openTheChain(ctx context.Context) (*chain.Chain, error) {
|
||||
return chain.OnTheBus(ctx, js.Conn(), chainHolds)
|
||||
}
|
||||
|
||||
// kindChainMoveRefused is the condition of a move that stopped without switching a kind.
|
||||
const kindChainMoveRefused = "chain-move-refused"
|
||||
|
||||
// chainMoveRetry is how long a move that failed for want of the bus waits before it tries again.
|
||||
const chainMoveRetry = 30 * time.Second
|
||||
|
||||
// movingTheChain copies the tables into the buckets and records it, at the serving controller's start: after the buckets
|
||||
// exist and the lease is held, before anything reads a kind that has not moved. **The first try is made here, before the
|
||||
// controller serves**, so what it serves is read from one place. A move that fails for want of the bus is tried again
|
||||
// every half minute while the controller serves from the tables; one that is refused — a count that differs, a value
|
||||
// over the bus's largest — is raised as an urgent condition, and the tables stay what is read: nothing is half-switched.
|
||||
func movingTheChain(ctx context.Context, inv *inventory.Inventory) {
|
||||
say := func(format string, args ...any) { fmt.Printf("chain: "+format+"\n", args...) }
|
||||
// The first try is made here, before the controller serves; the tries after it, while it serves.
|
||||
if movedOrStopped(ctx, inv, say) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(chainMoveRetry):
|
||||
}
|
||||
if movedOrStopped(ctx, inv, say) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// movedOrStopped makes one try at the move and says whether there is nothing more to try: moved, refused, or the controller
|
||||
// ending. False for a failure of the kind that is tried again (the bus away, a write not granted yet, the lease not held).
|
||||
func movedOrStopped(ctx context.Context, inv *inventory.Inventory, say func(string, ...any)) bool {
|
||||
_, err := inv.MoveChain(ctx, say)
|
||||
var refusal *inventory.Refusal
|
||||
switch {
|
||||
case err == nil:
|
||||
go clearChainMove(ctx)
|
||||
go askAgainSoon(ctx, inv)
|
||||
return true
|
||||
case errors.As(err, &refusal):
|
||||
say("%v", err)
|
||||
go raiseChainMove(ctx, refusal)
|
||||
return true
|
||||
case ctx.Err() != nil:
|
||||
return true
|
||||
}
|
||||
say("the move to the bus did not finish, and the tables are read until it does: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// chainMoveObservation is the urgent condition for a refused move.
|
||||
func chainMoveObservation(r *inventory.Refusal) conditions.Observation {
|
||||
return conditions.Observation{Scope: conditions.ScopeBus, ID: "chain." + r.Kind, Token: kindChainMoveRefused,
|
||||
Kind: kindChainMoveRefused, Severity: conditions.Urgent, Resolver: conditions.ResolverOperator,
|
||||
Summary: "the controller's " + r.Kind + " were not moved to the bus: " + r.Why, Source: "chain move"}
|
||||
}
|
||||
|
||||
// whenKept runs f once the serving controller keeps conditions: the move runs before they are kept.
|
||||
func whenKept(ctx context.Context, f func(k *conditions.Keeper)) {
|
||||
for conditionsFrom == nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
f(conditionsFrom)
|
||||
}
|
||||
|
||||
func raiseChainMove(ctx context.Context, r *inventory.Refusal) {
|
||||
whenKept(ctx, func(k *conditions.Keeper) {
|
||||
if _, err := k.Observe(ctx, chainMoveObservation(r)); err != nil {
|
||||
fmt.Printf("chain: the condition for the refused move could not be raised: %v\n", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func clearChainMove(ctx context.Context) {
|
||||
whenKept(ctx, func(k *conditions.Keeper) {
|
||||
for _, kind := range []string{inventory.KindBuilds, inventory.KindRollouts, inventory.KindAssignments} {
|
||||
key := chainMoveObservation(&inventory.Refusal{Kind: kind}).Key()
|
||||
if _, err := k.Clear(ctx, key, "the "+kind+" are on the bus"); err != nil {
|
||||
fmt.Printf("chain: the condition for the move of the %s could not be cleared: %v\n", kind, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// attachTheChain gives the inventory of the serving controller the chain on its connection, then moves what has not moved.
|
||||
// attachTheChain gives the inventory of the serving controller the chain on its connection.
|
||||
func attachTheChain(ctx context.Context, inv *inventory.Inventory, server *link.Server) error {
|
||||
bus, ok := server.Bus().(link.OverNATS)
|
||||
if !ok {
|
||||
@@ -134,7 +42,19 @@ func attachTheChain(ctx context.Context, inv *inventory.Inventory, server *link.
|
||||
return err
|
||||
}
|
||||
inv.UseChain(c)
|
||||
movingTheChain(ctx, inv)
|
||||
inv.RequireLayout()
|
||||
// And the builds laid out under their modules, once, before they are read (issue 1121) — **before this returns**, so
|
||||
// nothing the start goes on to read meets a chain not laid out yet: on 2026-10-18 the layout ran beside the start, a
|
||||
// read of the start was refused, and the controller exited and was started again every five seconds. The layout of
|
||||
// keys laid out before skips them, so this takes seconds; one that fails is tried again beside the serving, loudly.
|
||||
if err := inv.LayBuildsOut(ctx, func(format string, args ...any) { fmt.Printf("chain: "+format+"\n", args...) }); err != nil {
|
||||
fmt.Printf("chain: the builds are not laid out under their modules yet, and none is read until they are: %v\n", err)
|
||||
go layBuildsOutSoon(ctx, inv)
|
||||
}
|
||||
// And the requests the move of delivery 1 found never handed over, asked again once (askAgain), once it is serving.
|
||||
go askAgainSoon(ctx, inv)
|
||||
// And what delivery 1 recorded of what each machine was sent, copied onto the keys of the assignments (CopySentBuilds).
|
||||
go copySentBuildsSoon(ctx, inv)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -179,3 +99,79 @@ func askAgainSoon(ctx context.Context, inv *inventory.Inventory) {
|
||||
askAgain(ctx, inv)
|
||||
}
|
||||
}
|
||||
|
||||
// kindSentBuildsNotCopied is the condition of the one-time copy of node.sent_builds onto the assignments' keys that has not
|
||||
// succeeded: until it has, a module an assignment pulled in may be missing from what a machine is read to run.
|
||||
const kindSentBuildsNotCopied = "sent-builds-not-copied"
|
||||
|
||||
// copySentBuildsSoon copies what the store recorded of what each machine was sent onto the keys of the assignments, as soon
|
||||
// as this controller holds the lease (the chain refuses a write before), and tries again every half minute while it fails,
|
||||
// each time it fails saying so as an urgent condition until it has worked. It is idempotent: CopySentBuilds overwrites no key
|
||||
// that names a commit and empties each column it has copied.
|
||||
func copySentBuildsSoon(ctx context.Context, inv *inventory.Inventory) {
|
||||
failed := false
|
||||
for {
|
||||
_, err := inv.CopySentBuilds(ctx)
|
||||
o := conditions.Observation{Scope: conditions.ScopeBuild, ID: kindSentBuildsNotCopied, Token: kindSentBuildsNotCopied,
|
||||
Kind: kindSentBuildsNotCopied, Severity: conditions.Urgent, Resolver: conditions.ResolverOperator,
|
||||
Source: "chain", Summary: "what each machine was sent is not copied onto its assignments: "}
|
||||
if err != nil {
|
||||
fmt.Printf("chain: what the machines were sent is not copied onto the assignments: %v\n", err)
|
||||
if keeper := conditionsFrom; keeper != nil && !failed {
|
||||
o.Summary += err.Error()
|
||||
if _, oerr := keeper.Observe(ctx, o); oerr == nil {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if keeper := conditionsFrom; keeper != nil && failed {
|
||||
_, _ = keeper.Clear(ctx, o.Key(), "copied")
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(chainMoveRetry):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chainMoveRetry is how long the copy waits before it tries again.
|
||||
const chainMoveRetry = 30 * time.Second
|
||||
|
||||
// kindBuildsNotLaidOut is the condition of the layout of the builds under their modules that has not succeeded: until it
|
||||
// has, no build is read, and a send that needs one is refused with the reason.
|
||||
const kindBuildsNotLaidOut = "builds-not-laid-out"
|
||||
|
||||
// layBuildsOutSoon lays the builds out under their modules as soon as this controller holds the lease (the chain refuses a
|
||||
// write before), and tries again every half minute while it fails, loudly: each failure is printed, and the first raises an
|
||||
// urgent condition until it has worked. It is idempotent (LayBuildsOut).
|
||||
func layBuildsOutSoon(ctx context.Context, inv *inventory.Inventory) {
|
||||
failed := false
|
||||
for {
|
||||
err := inv.LayBuildsOut(ctx, func(format string, args ...any) { fmt.Printf("chain: "+format+"\n", args...) })
|
||||
o := conditions.Observation{Scope: conditions.ScopeBuild, ID: kindBuildsNotLaidOut, Token: kindBuildsNotLaidOut,
|
||||
Kind: kindBuildsNotLaidOut, Severity: conditions.Urgent, Resolver: conditions.ResolverOperator,
|
||||
Source: "chain", Summary: "the builds are not laid out under their modules, so none is read: "}
|
||||
if err != nil {
|
||||
fmt.Printf("chain: the builds are not laid out under their modules, and none is read until they are: %v\n", err)
|
||||
if keeper := conditionsFrom; keeper != nil && !failed {
|
||||
o.Summary += err.Error()
|
||||
if _, oerr := keeper.Observe(ctx, o); oerr == nil {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if keeper := conditionsFrom; keeper != nil && failed {
|
||||
_, _ = keeper.Clear(ctx, o.Key(), "laid out")
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(chainMoveRetry):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/chain"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
@@ -176,12 +176,12 @@ func TestASendOfWhatAMachineRunsAlreadyChangesNoLinkOfItsAssignment(t *testing.T
|
||||
if a, _ := assignmentOf(on, "notes"); a.Rollout != "plan-1" || a.Build != "build-1" || a.Commit != "c1" {
|
||||
t.Fatalf("the assignment names %+v", on)
|
||||
}
|
||||
// A declaration sent by hand with no builds known says nothing of them.
|
||||
// A declaration sent by hand with no builds known makes what the machine runs unknown: a time on the key, no commit.
|
||||
if err := inv.RecordSent(ctx, node, "d3", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if on, _ = inv.AssignmentsOn(ctx, "anchor"); runOf(on, "notes").Commit != "c1" {
|
||||
t.Fatalf("a send of unknown builds changed %+v", on)
|
||||
if on, _ = inv.AssignmentsOn(ctx, "anchor"); runOf(on, "notes").Commit != "" || runOf(on, "notes").SentUnknown.IsZero() {
|
||||
t.Fatalf("a send of unknown builds left %+v", on)
|
||||
}
|
||||
// A module sent a commit no build is kept of is named by its commit alone.
|
||||
if err := inv.RecordSent(ctx, node, "d4", map[string]string{"notes": "c2"}); err != nil {
|
||||
@@ -207,3 +207,39 @@ func runOf(on []chain.Assignment, module string) chain.Assignment {
|
||||
a, _ := assignmentOf(on, module)
|
||||
return a
|
||||
}
|
||||
|
||||
// **An assignment waiting for its build is one key of the assignments bucket** (ADR 0261, ADR 0408, issue 1094): entered
|
||||
// through the `assign` verb for a module whose build is in flight, one read of `<node>.<module>` says state pending with the
|
||||
// build it waits for, the build's outcome through the build seat's path makes the key stand with the wait carried as applied,
|
||||
// and the table of pending assignments is read by nobody. No key is written by hand.
|
||||
func TestAnAssignmentWaitingForItsBuildIsOneKeyOfTheAssignmentsBucket(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
asksWithPaths(t)
|
||||
mergeAdding(t, open, "modules/sensors", "3da80a4b00aa")
|
||||
if _, err := assign(ctx, open, "laptop", "sensors"); err != nil {
|
||||
t.Fatalf("an assignment while its build runs was refused: %v", err)
|
||||
}
|
||||
waiting, found, err := inv.AssignmentValue(ctx, "laptop", "sensors")
|
||||
if err != nil || !found || waiting.State != chain.StatePending || waiting.Stands() || !waiting.Assigned.IsZero() ||
|
||||
waiting.Pending == nil || waiting.Pending.State != chain.PendingWaiting || waiting.Pending.Build != "b-modules/sensors" ||
|
||||
!strings.HasPrefix(waiting.Pending.ID, "p-") {
|
||||
t.Fatalf("one read of the key says %+v (found %v, %v)", waiting, found, err)
|
||||
}
|
||||
if got := assignedTo(t, open, "laptop"); slices.Contains(got, "sensors") {
|
||||
t.Fatalf("a module only waiting is assigned: %v", got)
|
||||
}
|
||||
|
||||
if err := (builds{inv: inv, open: open}).Built(ctx, outcome("b-modules/sensors", "modules/sensors", "3da80a4b00aa", "sensors", "")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
made, found, err := inv.AssignmentValue(ctx, "laptop", "sensors")
|
||||
if err != nil || !found || !made.Stands() || made.State != "" || made.Assigned.IsZero() || made.Pending == nil ||
|
||||
made.Pending.State != chain.PendingApplied || made.Pending.ID != waiting.Pending.ID {
|
||||
t.Fatalf("one read of the key after the build says %+v (found %v, %v)", made, found, err)
|
||||
}
|
||||
if !slices.Contains(assignedTo(t, open, "laptop"), "sensors") {
|
||||
t.Fatal("the build registered sensors and it was not assigned")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// **Acceptance 12 of design 47: no table in the controller's store holds builds, rollouts or assignments** (novox/hq ADR
|
||||
// 0408, issue 1111). A delivery is driven through the live trigger — a merge heard, its batch cut into a plan, the plan's
|
||||
// build asked and answered, the assignment made — until a build, a rollout and an assignment exist; they are read from the
|
||||
// chain, and then the store's own catalog is read: none of the tables of the chain is in it, nor the function that raised the generation from the assignment table; the table that stays, stays.
|
||||
func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asked := asksRecorded(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
if _, err := assign(ctx, open, "anchor", "notes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hear(t, open, catalogueMerge("c-notes", "notes", t0), t0.Add(time.Second))
|
||||
cutAt(t, open, t0.Add(91*time.Second))
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) != 1 {
|
||||
t.Fatalf("%d walk(s) after the cut", len(ws))
|
||||
}
|
||||
rollout := ws[0].ID
|
||||
asking, err := inv.PlanByID(ctx, rollout)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ {
|
||||
advancePlans(ctx, open)
|
||||
if asking, err = inv.PlanByID(ctx, rollout); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
buildID := asking.Modules["notes"].Build
|
||||
if buildID == "" || len(*asked) != 1 {
|
||||
t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID)
|
||||
}
|
||||
digest := "sha256:" + strings.Repeat("b", 64)
|
||||
result := outcome(buildID, "modules/notes", "c-notes", "notes", "")
|
||||
result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}}
|
||||
if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// What the live path made is in the chain.
|
||||
if built, found, err := inv.BuildByID(ctx, buildID); err != nil || !found || built.Commit != "c-notes" {
|
||||
t.Fatalf("the build is not read from the chain: %+v %v %v", built, found, err)
|
||||
}
|
||||
if p, err := inv.PlanByID(ctx, rollout); err != nil || p.ID != rollout {
|
||||
t.Fatalf("the rollout is not read from the chain: %+v %v", p, err)
|
||||
}
|
||||
if on, err := inv.AssignmentsOn(ctx, "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if _, ok := assignmentOf(on, "notes"); !ok {
|
||||
t.Fatalf("the assignment is not read from the chain: %+v", on)
|
||||
}
|
||||
|
||||
// And the catalog of the store holds none of them.
|
||||
have := map[string]bool{}
|
||||
for _, row := range inv.QueryForTest(t, `select table_name::text from information_schema.tables where table_schema = 'public'`) {
|
||||
have[strings.Trim(row[0], `"`)] = true
|
||||
}
|
||||
for _, table := range []string{"build", "build_request", "release_plan", "batched_merge", "assignment", "pending_assignment", "chain_moved"} {
|
||||
if have[table] {
|
||||
t.Errorf("the table %s is in the controller's store", table)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"build_gate", "assignment_generation"} {
|
||||
if !have[table] {
|
||||
t.Errorf("the table %s, which stays, is gone", table)
|
||||
}
|
||||
}
|
||||
if rows := inv.QueryForTest(t, `select count(*) from pg_proc where proname = 'raise_assignment_generation'`); rows[0][0] != "0" {
|
||||
t.Errorf("the function raise_assignment_generation is in the store: %v", rows)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// 1212/AC1 and AC2: the check request names the pulls bucket of each module claiming the delivery seat, in the order
|
||||
// of the claimants' names.
|
||||
func TestACheckNamesThePullsBucketsOfTheSeatsClaimants(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
entries []inventory.Entry
|
||||
want []string
|
||||
}{
|
||||
"only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), namedOnly("mesh-delivery")}, []string{"mesh-delivery-gate_pulls"}},
|
||||
"only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery"), namedOnly("gitea")}, []string{"mesh-delivery_pulls"}},
|
||||
"both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}},
|
||||
"none": {[]inventory.Entry{namedOnly("gitea")}, nil},
|
||||
} {
|
||||
if got := pullsBucketsOf(c.entries); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: %v, want %v", name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1212/AC1 and AC2: the check a request carries names those buckets (the wiring, not only the helper).
|
||||
func TestTheCheckRequestCarriesThePullsBuckets(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
entries []inventory.Entry
|
||||
want []string
|
||||
}{
|
||||
"only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate")}, []string{"mesh-delivery-gate_pulls"}},
|
||||
"only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls"}},
|
||||
"both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}},
|
||||
} {
|
||||
got := checkOf(link.PullUpdated{Owner: "novox", Repo: "a", Number: 7}, checkScope{}, c.entries, nil, nil, nil, nil)
|
||||
if !reflect.DeepEqual(got.Pulls, c.want) || got.Owner != "novox" || got.Number != 7 {
|
||||
t.Errorf("%s: %+v, want pulls %v", name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The check anybody can run is the check registration runs (novox/hq issue 148, ADR 0037): a manifest
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
@@ -12,7 +14,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -72,7 +75,7 @@ type checkScope struct {
|
||||
// Mesh says the repository is the mesh's: modules are built from it on some branch, its owner is the
|
||||
// core's, or the change adds a module to it.
|
||||
Mesh bool
|
||||
// Judge is who judges the gate (link.JudgeSelf, link.JudgeValidator, or the running controller).
|
||||
// Judge is who judges the gate (link.JudgeSelf, or the running controller).
|
||||
Judge string
|
||||
// From is a module built from the repository, for how the mesh clones it; nil when none is.
|
||||
From *inventory.Entry
|
||||
@@ -98,10 +101,6 @@ func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][]
|
||||
switch e.Manifest.Module {
|
||||
case "mesh-controller":
|
||||
s.Judge = link.JudgeSelf
|
||||
case "mesh-host":
|
||||
if s.Judge == "" {
|
||||
s.Judge = link.JudgeValidator
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range r.Added {
|
||||
@@ -140,18 +139,26 @@ func sourceOwner(repository string) string {
|
||||
|
||||
// PullUpdated decides a pull request's merge check, and asks for it when there is something to run.
|
||||
func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
_, err := f.pullUpdated(ctx, p, os.Stdout)
|
||||
return err
|
||||
}
|
||||
|
||||
// pullUpdated is PullUpdated saying what it did to w. It returns the id of the check it asked of the build seat, or
|
||||
// "" when it said a verdict itself and asked nothing. A command that answers JSON on standard output (`delivery
|
||||
// check`) gives it standard error, so its output stays the document alone (novox/hq issue 1311).
|
||||
func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) (string, error) {
|
||||
inv := f.open.inventory
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
edges, err := inv.Dependencies(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
scope := pullScope(p, entries, read, edges)
|
||||
// **A cycle of edges fails the check, naming every module and edge** (novox/hq ADR 0419 clause 6), a lone pull
|
||||
@@ -160,12 +167,12 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
Head: p.Commit, Number: p.Number, Paths: p.Paths, PathsTruncated: p.PathsTruncated, Removed: p.Removed, BaseSHA: p.BaseSHA}
|
||||
if failing, cyclic, err := cycleVerdict(ctx, "", []orderMember{alone},
|
||||
map[string]orderReach{alone.ID: {Moved: scope.Modules}}, edges, entries); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
} else if cyclic {
|
||||
failing.Commit, failing.Trunk = p.Commit, twelve(p.BaseSHA)
|
||||
fmt.Printf("%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, failing.Summary)
|
||||
sayChecked(ctx, failing)
|
||||
return nil
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, failing.Summary)
|
||||
sayChecked(ctx, w, failing)
|
||||
return "", nil
|
||||
}
|
||||
// **A member of a group whose gate is the composed check's runs its repository's check alone** (novox/hq ADR 0403
|
||||
// clause 1): mesh-delivery is asked once, and says so on the group's line. Told nothing, the pull request is
|
||||
@@ -178,7 +185,7 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
u, err := inv.UpgradeOf(ctx, module)
|
||||
return u, err == nil
|
||||
})
|
||||
fmt.Print(planText(plan))
|
||||
fmt.Fprint(w, planText(plan))
|
||||
direct := directVerdict(p, plan)
|
||||
if composed {
|
||||
scope.Modules, scope.Dependents, scope.New, scope.Manifests = nil, nil, nil, nil
|
||||
@@ -186,19 +193,19 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
}
|
||||
switch {
|
||||
case !scope.gated() && !scope.Mesh:
|
||||
fmt.Printf("%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s, and the repository is not the mesh's: said, nothing run\n",
|
||||
p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched)
|
||||
sayChecked(ctx, direct)
|
||||
return nil
|
||||
sayChecked(ctx, w, direct)
|
||||
return "", nil
|
||||
case !scope.gated() && p.MergeCheckSaid && !p.MergeCheck:
|
||||
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck}
|
||||
fmt.Printf("%s/%s#%d (%.8s): %s; %s\n", p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched, noMergeCheck)
|
||||
sayChecked(ctx, direct)
|
||||
return nil
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s; %s\n", p.Owner, p.Repo, p.Number, p.Commit, noModuleTouched, noMergeCheck)
|
||||
sayChecked(ctx, w, direct)
|
||||
return "", nil
|
||||
}
|
||||
request, err := checkRequestFor(ctx, f.open, p, scope, entries)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
request.Check.Plan = &plan
|
||||
request.Check.Composed = composed
|
||||
@@ -207,11 +214,11 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
seat := buildSeatHeld(ctx)
|
||||
ask, err := askOverOn(seat)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
defer ask.Close()
|
||||
if err := ask.Ask(ctx, request); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
// **A newer request replaces the one still waiting, and a newer key stops the one running** (novox/hq ADR 0406 §3, §4,
|
||||
// issue 974): the check queue keeps one request a pull request, and a running check watches its key. Nothing is asked
|
||||
@@ -225,9 +232,9 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
strings.Join(append(append([]string{}, scope.Modules...), prefixedAll("new:", scope.New)...), ", "),
|
||||
scope.Width, scope.Tiers)
|
||||
}
|
||||
fmt.Printf("%s/%s#%d (%.8s): asked %s to check it before it merges — %s — as %s\n", p.Owner, p.Repo, p.Number,
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): asked %s to check it before it merges — %s — as %s\n", p.Owner, p.Repo, p.Number,
|
||||
p.Commit, seat, what, request.ID)
|
||||
return nil
|
||||
return request.ID, nil
|
||||
}
|
||||
|
||||
// composedWords is what a group member's own verdict says of its gate layer (novox/hq ADR 0403 clause 1).
|
||||
@@ -270,7 +277,7 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
|
||||
for _, e := range entries {
|
||||
shelf[e.Manifest.Module] = e.Manifest
|
||||
}
|
||||
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
|
||||
world, err := restOfTheMesh(ctx, open, shelf, "")
|
||||
if err != nil {
|
||||
return link.BuildRequest{}, err
|
||||
}
|
||||
@@ -305,13 +312,19 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
|
||||
Held: heldBy(ctx),
|
||||
Seats: seatBases(ctx),
|
||||
Source: sourceOnSeat(source),
|
||||
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base, Trunk: p.BaseSHA, Ready: p.Ready,
|
||||
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
|
||||
Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: namedForTheCheck(ctx, p, builds),
|
||||
Running: runningBy(ctx)},
|
||||
Check: checkOf(p, scope, entries, beside, builds, namedForTheCheck(ctx, p, builds), runningBy(ctx)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkOf is the check a request carries: the pull request, what the change touches, and the buckets of the pull
|
||||
// requests' keys of the modules claiming the delivery seat (novox/hq ADR 0437 §3, issue 1212).
|
||||
func checkOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry, beside map[string]link.CheckedOut,
|
||||
builds []link.CheckBuild, named []link.CheckedBuild, running map[string]string) *link.CheckRequest {
|
||||
return &link.CheckRequest{Pulls: pullsBucketsOf(entries), Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
||||
Trunk: p.BaseSHA, Ready: p.Ready, Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents,
|
||||
New: scope.New, Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: named, Running: running}
|
||||
}
|
||||
|
||||
// besideOf is what a check reads beside its own repository, by the directory it is found under: each core repository
|
||||
// by the module the mesh builds from it, at besideRefs. running is the commit the mesh runs of a module.
|
||||
func besideOf(entries []inventory.Entry, running func(module string) string,
|
||||
@@ -521,7 +534,7 @@ func checked(ctx context.Context, result link.BuildResult) {
|
||||
fmt.Printf("%s: stopped, its pull request's key moved on; the newer check will judge\n", result.ID)
|
||||
return
|
||||
}
|
||||
sayChecked(ctx, checkedOf(result))
|
||||
sayChecked(ctx, os.Stdout, checkedOf(result))
|
||||
}
|
||||
|
||||
// checkedOf is what a check's outcome says: each layer, and an error — never a pass — for a check that
|
||||
@@ -637,7 +650,7 @@ func prefixedAll(prefix string, items []string) []string {
|
||||
}
|
||||
|
||||
// sayChecked says a merge check's verdict on the bus, where the forge's holder hears it.
|
||||
func sayChecked(ctx context.Context, c link.Checked) {
|
||||
func sayChecked(ctx context.Context, w io.Writer, c link.Checked) {
|
||||
repo := "none"
|
||||
if c.RepoCheck != nil {
|
||||
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
|
||||
@@ -646,7 +659,7 @@ func sayChecked(ctx context.Context, c link.Checked) {
|
||||
if c.Trunk != "" {
|
||||
judged = " merged into trunk " + c.Trunk
|
||||
}
|
||||
fmt.Printf("%s: %s/%s#%d at %.8s%s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
||||
fmt.Fprintf(w, "%s: %s/%s#%d at %.8s%s checked on %s: gate %s — %s; repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
||||
c.Commit, judged, orSomewhere(c.On), strings.ToUpper(c.Verdict), c.Summary, repo)
|
||||
if checkEvents == nil {
|
||||
return
|
||||
@@ -658,6 +671,16 @@ func sayChecked(ctx context.Context, c link.Checked) {
|
||||
stating, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer stop()
|
||||
if err := checkEvents.PublishSeatEvent(stating, link.MeshControllerSeat, link.KeyChecked, body); err != nil {
|
||||
fmt.Printf("%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err)
|
||||
fmt.Fprintf(w, "%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pullsBucketsOf are the buckets of the pull requests' keys a check names for the build agent: one per module
|
||||
// claiming the delivery seat, in the order of their names (novox/hq ADR 0437 §3, issue 1212).
|
||||
func pullsBucketsOf(entries []inventory.Entry) []string {
|
||||
var out []string
|
||||
for _, m := range claimantsOf(entries, catalogue.DeliverySeat) {
|
||||
out = append(out, broker.BucketName(m, "pulls"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestThePullRequestIsMappedOntoTheModuleGraph(t *testing.T) {
|
||||
"", "modules/newmod", "modules/newmod/module.json", true, ""},
|
||||
{"the controller judges itself", pull("novox", "mesh-controller", "main", []string{"cmd/x.go"}),
|
||||
"mesh-controller", "", "module.json", true, "self"},
|
||||
{"the node-engine is judged with its validator", pull("novox", "mesh-host", "main", []string{"validate/v.go"}),
|
||||
"mesh-host", "", "module.json", true, "validator"},
|
||||
{"the node-engine is judged by the controller the mesh runs, as any module is (issue 1391)",
|
||||
pull("novox", "mesh-host", "main", []string{"validate/v.go"}), "mesh-host", "", "module.json", true, ""},
|
||||
{"the core's owner, no module: the mesh's, its own check alone", pull("novox", "hq", "main", []string{"README.md"}),
|
||||
"", "", "", true, ""},
|
||||
{"a branch nothing is built from: the mesh's repository, no module", pull("novox", "photos", "master",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1388: no caller composes through the controller's own composition any more. The only functions left that call it are the
|
||||
// composition's own, which issue 1389 deletes with it; this fails the moment any other function reaches for it again.
|
||||
func TestNoCallerComposesThroughTheControllersOwnComposition(t *testing.T) {
|
||||
legacy := map[string]bool{"planFor": true, "declarationWith": true, "composedAndValidated": true, "generators": true,
|
||||
"renderingFor": true, "theRestOfTheMesh": true, "whoResolves": true}
|
||||
// The composition's own pairs, until 1389: "file: caller calls callee".
|
||||
allowed := map[string]bool{
|
||||
"facts.go: composedAndValidated calls declarationWith": true,
|
||||
"facts.go: composedAndValidated calls planFor": true,
|
||||
"network.go: generators calls whoResolves": true,
|
||||
"network.go: whoResolves calls planFor": true,
|
||||
"plan.go: declarationWith calls renderingFor": true,
|
||||
"plan.go: grantsFor calls planFor": true,
|
||||
"plan.go: planFor calls theRestOfTheMesh": true,
|
||||
}
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var found []string
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fset, f, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range file.Decls {
|
||||
fn, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fn.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||
if c, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := c.Fun.(*ast.Ident); ok && legacy[id.Name] {
|
||||
found = append(found, f+": "+fn.Name.Name+" calls "+id.Name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Strings(found)
|
||||
for _, pair := range found {
|
||||
if !allowed[pair] {
|
||||
t.Errorf("%s: it composes through the controller's own composition, not mesh-sdk's", pair)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
)
|
||||
|
||||
// 1384: nothing shows the controller's composition and mesh-sdk's compose every node alike on the live facts. This test is
|
||||
// that proof (novox/hq ADR 0453 clause 3): it reads the facts snapshot MESH_FACTS names (the live one in every check and
|
||||
// every try, the build seat gives it), else one captured in testdata/facts-equal.json (taken with `facts show` and
|
||||
// scrubbed as the snapshot is), else the snapshot of a small mesh of its own; raises it into throwaway stores as the merge
|
||||
// gate does; and composes every machine both ways.
|
||||
//
|
||||
// It runs only when named (a try with this test's name as the tests pattern); a whole-suite run skips it.
|
||||
//
|
||||
// (a) Foreseeing on the fresh store: the same error or none, the same validator problems, LeftOut and its reasons, Withheld,
|
||||
// Unbound, Unplaced, HandedOnly and the own secrets named foreseen; and the ports mesh-sdk names foreseen are the ones the
|
||||
// controller allocates when it sends.
|
||||
// (b) the send: on two stores raised from the same snapshot, the controller's Allocating composition of every machine, and the
|
||||
// adapter's compose, perform and compose again; the same ports, pair credentials and own secrets were made, by key.
|
||||
// (c) after (b), Reading on one store, both ways: the declaration byte for byte.
|
||||
//
|
||||
// It sends nothing and opens no store but the throwaway ones. A difference names the machine, the comparison and the first
|
||||
// field that differs; a machine both paths refuse alike is said, not failed. The test is deleted with the controller's
|
||||
// composition (issue 1389).
|
||||
func TestEveryMachineComposesAlikeBothWays(t *testing.T) {
|
||||
// It is slow (it composes every machine of the live mesh several times each way) and is deleted with the controller's
|
||||
// composition (issue 1389), so it runs only when asked for by name: a try or a `go test -run` that names it. A merge check
|
||||
// runs every test and does not.
|
||||
if run := flag.Lookup("test.run"); run == nil || !strings.Contains(run.Value.String(), "TestEveryMachineComposesAlikeBothWays") {
|
||||
t.Skip("runs only when named: a try with the tests pattern TestEveryMachineComposesAlikeBothWays")
|
||||
}
|
||||
ctx := t.Context()
|
||||
admin := os.Getenv("MESH_TEST_POSTGRES")
|
||||
if admin == "" {
|
||||
t.Skip("no throwaway PostgreSQL (MESH_TEST_POSTGRES) to raise the mesh in")
|
||||
}
|
||||
started := time.Now()
|
||||
phase := func(what string) { t.Logf("[%s] %s", time.Since(started).Round(time.Second), what) }
|
||||
f, source := equalityFacts(t)
|
||||
if len(f.Machines) < 1 {
|
||||
t.Fatalf("the facts (%s) hold no machine, so nothing was composed and nothing is proved", source)
|
||||
}
|
||||
t.Logf("the facts of %s (%s): %d machine(s)", f.Taken.Format("2006-01-02 15:04:05"), source, len(f.Machines))
|
||||
shelf, err := shelfOfFacts(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources := sourcesOfFacts(f)
|
||||
|
||||
// Three stores, raised from the same snapshot, one after the other: (a) both ways on a fresh store, (b) and (c) the
|
||||
// controller's sends and readings, (b) mesh-sdk's sends. They cannot run together: throwawayStores finds its databases by
|
||||
// the process environment (and a store opened later finds them there too), so a second one raised at the same time takes
|
||||
// the first one's connection from under it (the live try: database gate_inventory_... does not exist).
|
||||
var (
|
||||
oldForeseen, sdkForeseen = map[string]composedBoth{}, map[string]composedBoth{}
|
||||
foreseenPorts = map[string][]string{}
|
||||
readOld, readSDK = map[string]composedBoth{}, map[string]composedBoth{}
|
||||
oldMade, sdkMade made
|
||||
oldRefused = map[string]string{}
|
||||
sendRefused = map[string]string{}
|
||||
errA, errOld, errSDK error
|
||||
)
|
||||
func() { // (a) Foreseeing on the fresh store.
|
||||
phase("(a) starts: raising a fresh store")
|
||||
fresh, drop, err := throwawayStores(ctx, admin)
|
||||
if err != nil {
|
||||
errA = err
|
||||
return
|
||||
}
|
||||
defer drop()
|
||||
if _, err := raiseFromFacts(ctx, fresh, f, shelf, sources); err != nil {
|
||||
errA = err
|
||||
return
|
||||
}
|
||||
// mesh-sdk's composition first: it writes nothing, where the controller's makes every pair credential on the way. The
|
||||
// Input is read once for all of its machines.
|
||||
phase("(a) the store is raised; mesh-sdk's Foreseeing starts")
|
||||
cached := withInputCache(ctx)
|
||||
for _, m := range f.Machines {
|
||||
in, err := inputFor(cached, fresh)
|
||||
if err != nil {
|
||||
errA = fmt.Errorf("%s: the adapter cannot read the mesh: %w", m.Name, err)
|
||||
return
|
||||
}
|
||||
d, problems, cerr := compose.ComposeIn(in.Input, m.Name, compose.Foreseeing)
|
||||
if cerr == nil {
|
||||
foreseenPorts[m.Name] = append([]string(nil), d.ForeseenPorts...)
|
||||
sort.Strings(foreseenPorts[m.Name])
|
||||
}
|
||||
sdkForeseen[m.Name] = capturedFrom(sendableOf(d), problems, cerr)
|
||||
}
|
||||
phase("(a) mesh-sdk's Foreseeing of every machine")
|
||||
gens, gensErr := generators(ctx, fresh)
|
||||
for _, m := range f.Machines {
|
||||
if gensErr != nil {
|
||||
oldForeseen[m.Name] = composedBoth{Err: gensErr.Error()}
|
||||
continue
|
||||
}
|
||||
d, problems, err := composedAndValidated(ctx, fresh, m.Name, gens, Foreseeing)
|
||||
oldForeseen[m.Name] = capturedFrom(d, problems, err)
|
||||
}
|
||||
phase("(a) the controller's Foreseeing of every machine")
|
||||
}()
|
||||
if errA != nil {
|
||||
t.Fatal(errA)
|
||||
}
|
||||
func() { // (b) and (c): the controller's send of every machine, then both ways of reading.
|
||||
phase("(b) starts: the controller's send, raising a store")
|
||||
oldStore, drop, err := throwawayStores(ctx, admin)
|
||||
if err != nil {
|
||||
errOld = err
|
||||
return
|
||||
}
|
||||
defer drop()
|
||||
if _, err := raiseFromFacts(ctx, oldStore, f, shelf, sources); err != nil {
|
||||
errOld = err
|
||||
return
|
||||
}
|
||||
oldGens, oldGensErr := generators(ctx, oldStore)
|
||||
for _, m := range f.Machines {
|
||||
if oldGensErr != nil {
|
||||
oldRefused[m.Name] = oldGensErr.Error()
|
||||
continue
|
||||
}
|
||||
if _, _, err := composedAndValidated(ctx, oldStore, m.Name, oldGens, Allocating); err != nil {
|
||||
oldRefused[m.Name] = err.Error()
|
||||
}
|
||||
}
|
||||
phase("(b) the controller's send of every machine")
|
||||
if oldMade, err = madeIn(ctx, oldStore); err != nil {
|
||||
errOld = err
|
||||
return
|
||||
}
|
||||
cached := withInputCache(ctx)
|
||||
for _, m := range f.Machines {
|
||||
if oldGensErr != nil {
|
||||
readOld[m.Name] = composedBoth{Err: oldGensErr.Error()}
|
||||
} else {
|
||||
d, problems, err := composedAndValidated(ctx, oldStore, m.Name, oldGens, Reading)
|
||||
readOld[m.Name] = capturedFrom(d, problems, err)
|
||||
}
|
||||
d, problems, err := composeQuestion(cached, oldStore, m.Name, compose.Reading, true)
|
||||
readSDK[m.Name] = capturedFrom(d, problems, err)
|
||||
}
|
||||
phase("(c) both ways of reading every machine after the controller's send")
|
||||
}()
|
||||
if errOld != nil {
|
||||
t.Fatal(errOld)
|
||||
}
|
||||
func() { // (b) mesh-sdk's send of every machine.
|
||||
phase("(b) starts: mesh-sdk's send, raising a store")
|
||||
sdkStore, drop, err := throwawayStores(ctx, admin)
|
||||
if err != nil {
|
||||
errSDK = err
|
||||
return
|
||||
}
|
||||
defer drop()
|
||||
if _, err := raiseFromFacts(ctx, sdkStore, f, shelf, sources); err != nil {
|
||||
errSDK = err
|
||||
return
|
||||
}
|
||||
for _, m := range f.Machines {
|
||||
if _, err := composeSend(ctx, sdkStore, m.Name); err != nil {
|
||||
sendRefused[m.Name] = err.Error()
|
||||
}
|
||||
}
|
||||
phase("(b) mesh-sdk's send of every machine")
|
||||
sdkMade, errSDK = madeIn(ctx, sdkStore)
|
||||
}()
|
||||
if errSDK != nil {
|
||||
t.Fatal(errSDK)
|
||||
}
|
||||
|
||||
var failures []string
|
||||
for _, m := range f.Machines {
|
||||
failures = append(failures, bothWaysDiffer(m.Name, "(a) Foreseeing", oldForeseen[m.Name], sdkForeseen[m.Name], false)...)
|
||||
failures = append(failures, bothWaysDiffer(m.Name, "(c) Reading after the send", readOld[m.Name], readSDK[m.Name], true)...)
|
||||
}
|
||||
// **The send must compose every machine both ways.** A machine either send refuses is no proof of anything: what the
|
||||
// controller made on the way to refusing it is no send's, and a refusal alike says nothing about what a send of it makes.
|
||||
// So it fails here, naming the machine and both refusals (a refusal alike in (a), the Foreseeing on a store that holds no
|
||||
// secret a send made, is said and stays). A pass proves that every machine of the facts was sent and compared.
|
||||
refused := map[string]bool{}
|
||||
for _, m := range f.Machines {
|
||||
why, oldNo := oldRefused[m.Name]
|
||||
sdkWhy, sdkNo := sendRefused[m.Name]
|
||||
if !oldNo && !sdkNo {
|
||||
continue
|
||||
}
|
||||
refused[m.Name] = true
|
||||
failures = append(failures, fmt.Sprintf("%s, (b) the send: it is not composed both ways, so nothing of it is compared: "+
|
||||
"the controller's refusal %s, mesh-sdk's %s", m.Name, quoted(oneLine(why)), quoted(oneLine(sdkWhy))))
|
||||
}
|
||||
failures = append(failures, madeDiffer("(b) the send", oldMade, sdkMade, refused)...)
|
||||
|
||||
// (a)'s ports: those mesh-sdk names foreseen, for each machine, are the ones the controller's send allocated to it.
|
||||
for _, m := range f.Machines {
|
||||
if refused[m.Name] {
|
||||
continue
|
||||
}
|
||||
var allocated []string
|
||||
for _, p := range oldMade.Ports {
|
||||
if node, rest, ok := strings.Cut(p, " "); ok && node == m.Name {
|
||||
module, port, _ := strings.Cut(strings.SplitN(rest, "->", 2)[0], ":")
|
||||
allocated = append(allocated, module+":"+port)
|
||||
}
|
||||
}
|
||||
sort.Strings(allocated)
|
||||
// A port the snapshot already holds is not allocated by a send, and one a module is given is not allocated either: the
|
||||
// ones mesh-sdk names must all be among those the send allocated.
|
||||
for _, want := range foreseenPorts[m.Name] {
|
||||
found := false
|
||||
for _, got := range allocated {
|
||||
found = found || got == want
|
||||
}
|
||||
if !found {
|
||||
failures = append(failures, fmt.Sprintf("%s, (a) Foreseeing: mesh-sdk names the port %s foreseen and the "+
|
||||
"controller's send allocates none for it", m.Name, want))
|
||||
}
|
||||
}
|
||||
}
|
||||
for machine, why := range sendRefused {
|
||||
t.Logf("%s: mesh-sdk's send of it is refused: %s", machine, why)
|
||||
}
|
||||
for machine, c := range oldForeseen {
|
||||
if c.Err != "" {
|
||||
t.Logf("%s: the controller refuses it, and mesh-sdk alike: %s", machine, oneLine(c.Err))
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
t.Fatalf("the two compositions differ on %d point(s):\n %s", len(failures), strings.Join(failures, "\n "))
|
||||
}
|
||||
t.Logf("%d machine(s) were sent and compose alike both ways", len(f.Machines))
|
||||
}
|
||||
|
||||
// equalityFacts is the snapshot the comparison composes: MESH_FACTS, else the one captured in testdata, else the facts of a
|
||||
// mesh of its own (the same shape, small). Source says which.
|
||||
func equalityFacts(t *testing.T) (snapshot.Facts, string) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
if path := os.Getenv("MESH_FACTS"); path != "" {
|
||||
f, err := readFacts(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatalf("MESH_FACTS names %s and it is not a snapshot: %v", path, err)
|
||||
}
|
||||
return f, "MESH_FACTS " + path
|
||||
}
|
||||
if _, err := os.Stat("testdata/facts-equal.json"); err == nil {
|
||||
f, err := readFacts(ctx, "testdata/facts-equal.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f, "captured in testdata/facts-equal.json"
|
||||
}
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f, "gathered from a mesh of the test's own"
|
||||
}
|
||||
|
||||
// madeIn is what a store holds that a send makes, by key and never by value.
|
||||
func madeIn(ctx context.Context, open *stores) (made, error) {
|
||||
var out made
|
||||
inv := open.inventory
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
ports, err := inv.PortsFor(ctx, n.Name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, p := range ports {
|
||||
out.Ports = append(out.Ports, fmt.Sprintf("%s %s:%d->%d", n.Name, p.Module, p.Wanted, p.Machine))
|
||||
}
|
||||
rows, err := inv.SecretsFrom(ctx, n.Name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, s := range rows {
|
||||
out.Pairs = append(out.Pairs, fmt.Sprintf("%s %s on %s from %s local=%s", s.Name, s.ConsumerModule, s.Consumer, n.Name, s.Local))
|
||||
}
|
||||
}
|
||||
own, err := inv.OwnSecretRows(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, r := range own {
|
||||
out.OwnSecrets = append(out.OwnSecrets, fmt.Sprintf("%s %s/%s (%s)", r.Node, r.Module, r.Name, r.Origin))
|
||||
}
|
||||
sort.Strings(out.Ports)
|
||||
sort.Strings(out.Pairs)
|
||||
sort.Strings(out.OwnSecrets)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The comparison of the controller's composition and mesh-sdk's (novox/hq ADR 0453 clause 3, design 47 R4b, issue 1384),
|
||||
// the part of it that needs no store. The test that raises a mesh and composes every machine both ways is
|
||||
// compose_equal_integration_test.go; both are deleted with the controller's own composition (issue 1389).
|
||||
|
||||
// composedBoth is what one composition of a machine said, in the terms the two paths are compared in.
|
||||
type composedBoth struct {
|
||||
Err string
|
||||
Problems []string
|
||||
LeftOut map[string]string
|
||||
Withheld []string
|
||||
Unbound []string
|
||||
Unplaced []string
|
||||
Handed []string
|
||||
// Foreseen are the own secrets the composition names foreseen, as `module/name`.
|
||||
Foreseen []string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// capturedFrom is a sendable, its validator problems and the error that came with it, as compared.
|
||||
func capturedFrom(d sendable, problems []string, err error) composedBoth {
|
||||
if err != nil {
|
||||
return composedBoth{Err: err.Error()}
|
||||
}
|
||||
c := composedBoth{Problems: append([]string(nil), problems...), LeftOut: d.leftOutWhy,
|
||||
Unplaced: append([]string(nil), d.unplaced...), Handed: append([]string(nil), d.handedOnly...),
|
||||
Foreseen: append([]string(nil), d.foreseen...)}
|
||||
for _, o := range d.withheld {
|
||||
c.Withheld = append(c.Withheld, o.String())
|
||||
}
|
||||
for _, u := range d.unbound {
|
||||
c.Unbound = append(c.Unbound, u.String())
|
||||
}
|
||||
sort.Strings(c.Problems)
|
||||
sort.Strings(c.Withheld)
|
||||
sort.Strings(c.Unbound)
|
||||
sort.Strings(c.Unplaced)
|
||||
sort.Strings(c.Handed)
|
||||
sort.Strings(c.Foreseen)
|
||||
c.Body, _ = d.Body()
|
||||
return c
|
||||
}
|
||||
|
||||
// bothWaysDiffer says how two compositions of one machine differ, one line for each field that does, naming the machine and
|
||||
// the comparison. A machine both refuse alike is no difference. withBody compares the declaration byte for byte and names
|
||||
// the first resource that differs.
|
||||
func bothWaysDiffer(machine, comparison string, old, sdk composedBoth, withBody bool) []string {
|
||||
var out []string
|
||||
say := func(field string, a, b any) {
|
||||
out = append(out, fmt.Sprintf("%s, %s: %s differs: the controller's %v, mesh-sdk's %v", machine, comparison, field, a, b))
|
||||
}
|
||||
if old.Err != sdk.Err {
|
||||
say("the refusal", quoted(old.Err), quoted(sdk.Err))
|
||||
return out
|
||||
}
|
||||
if old.Err != "" {
|
||||
return nil
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
old, sdk []string
|
||||
}{
|
||||
{"the validator's problems", old.Problems, sdk.Problems},
|
||||
{"Withheld", old.Withheld, sdk.Withheld},
|
||||
{"Unbound", old.Unbound, sdk.Unbound},
|
||||
{"Unplaced", old.Unplaced, sdk.Unplaced},
|
||||
{"HandedOnly", old.Handed, sdk.Handed},
|
||||
{"the own secrets named foreseen", old.Foreseen, sdk.Foreseen},
|
||||
} {
|
||||
if !sameStrings(f.old, f.sdk) {
|
||||
say(f.name, f.old, f.sdk)
|
||||
}
|
||||
}
|
||||
if !sameLeftOut(old.LeftOut, sdk.LeftOut) {
|
||||
say("LeftOut and its reasons", old.LeftOut, sdk.LeftOut)
|
||||
}
|
||||
if withBody && !bytes.Equal(old.Body, sdk.Body) {
|
||||
out = append(out, fmt.Sprintf("%s, %s: the declaration differs byte for byte; %s", machine, comparison,
|
||||
firstDifferingResource(old.Body, sdk.Body)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func quoted(s string) string {
|
||||
if s == "" {
|
||||
return "none"
|
||||
}
|
||||
return fmt.Sprintf("%q", s)
|
||||
}
|
||||
|
||||
func sameStrings(a, b []string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
func sameLeftOut(a, b map[string]string) bool {
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
// firstDifferingResource names the first resource of two declaration bodies that is not the same, and the first field of it.
|
||||
func firstDifferingResource(a, b []byte) string {
|
||||
var x, y struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
|
||||
return "a body is not a declaration"
|
||||
}
|
||||
byID := func(rs []map[string]any) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range rs {
|
||||
out[fmt.Sprint(r["id"])] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
left, right := byID(x.Resources), byID(y.Resources)
|
||||
for id := range left {
|
||||
ids[id] = true
|
||||
}
|
||||
for id := range right {
|
||||
ids[id] = true
|
||||
}
|
||||
var order []string
|
||||
for id := range ids {
|
||||
order = append(order, id)
|
||||
}
|
||||
sort.Strings(order)
|
||||
for _, id := range order {
|
||||
l, inLeft := left[id]
|
||||
r, inRight := right[id]
|
||||
switch {
|
||||
case !inRight:
|
||||
return fmt.Sprintf("resource %s is only in the controller's", id)
|
||||
case !inLeft:
|
||||
return fmt.Sprintf("resource %s is only in mesh-sdk's", id)
|
||||
}
|
||||
var fields []string
|
||||
for k := range l {
|
||||
fields = append(fields, k)
|
||||
}
|
||||
for k := range r {
|
||||
if _, have := l[k]; !have {
|
||||
fields = append(fields, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(fields)
|
||||
for _, k := range fields {
|
||||
if !reflect.DeepEqual(l[k], r[k]) {
|
||||
return fmt.Sprintf("resource %s, field %s: the controller's %v, mesh-sdk's %v", id, k, l[k], r[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(left, right) {
|
||||
return "the envelopes differ"
|
||||
}
|
||||
return "the resources are the same; the envelope differs"
|
||||
}
|
||||
|
||||
// made is what a send made in a store, by key and never by value: the machine ports given, the pair credentials and the
|
||||
// own secrets.
|
||||
type made struct {
|
||||
Ports []string
|
||||
Pairs []string
|
||||
OwnSecrets []string
|
||||
}
|
||||
|
||||
// madeDiffer says how what two sends made differs, by entry: for each kind, the entries only the controller made, the ones only
|
||||
// mesh-sdk made, and the ones both made with a different value. Never a whole list. The entries of a machine in refused (a
|
||||
// machine whose send is refused on either side, which sends nothing) are left out of both.
|
||||
func madeDiffer(comparison string, old, sdk made, refused map[string]bool) []string {
|
||||
var out []string
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
old, sdk []string
|
||||
split func(string) (key, value string)
|
||||
nodes func(string) []string
|
||||
}{
|
||||
{"the ports allocated", old.Ports, sdk.Ports, splitAt("->"), firstWord},
|
||||
{"the pair credentials made", old.Pairs, sdk.Pairs, func(l string) (string, string) { return l, "" }, pairNodes},
|
||||
{"the own secrets made", old.OwnSecrets, sdk.OwnSecrets, splitAtLast(" ("), firstWord},
|
||||
} {
|
||||
left, right := entriesOf(f.old, f.split, f.nodes, refused), entriesOf(f.sdk, f.split, f.nodes, refused)
|
||||
var onlyOld, onlySDK, different []string
|
||||
for key, v := range left {
|
||||
if w, both := right[key]; !both {
|
||||
onlyOld = append(onlyOld, key)
|
||||
} else if w != v {
|
||||
different = append(different, fmt.Sprintf("%s (the controller's %s, mesh-sdk's %s)", key, v, w))
|
||||
}
|
||||
}
|
||||
for key := range right {
|
||||
if _, both := left[key]; !both {
|
||||
onlySDK = append(onlySDK, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(onlyOld)
|
||||
sort.Strings(onlySDK)
|
||||
sort.Strings(different)
|
||||
var said []string
|
||||
if len(onlyOld) > 0 {
|
||||
said = append(said, "made only by the controller: "+strings.Join(onlyOld, ", "))
|
||||
}
|
||||
if len(onlySDK) > 0 {
|
||||
said = append(said, "made only by mesh-sdk: "+strings.Join(onlySDK, ", "))
|
||||
}
|
||||
if len(different) > 0 {
|
||||
said = append(said, "made by both with a different value: "+strings.Join(different, ", "))
|
||||
}
|
||||
if len(said) > 0 {
|
||||
out = append(out, fmt.Sprintf("%s: %s differ: %s", comparison, f.name, strings.Join(said, "; ")))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// entriesOf is the entries of a list by key, those of a machine in skip left out.
|
||||
func entriesOf(lines []string, split func(string) (string, string), nodes func(string) []string, skip map[string]bool) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, l := range lines {
|
||||
skipped := false
|
||||
for _, n := range nodes(l) {
|
||||
skipped = skipped || skip[n]
|
||||
}
|
||||
if skipped {
|
||||
continue
|
||||
}
|
||||
k, v := split(l)
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitAt(sep string) func(string) (string, string) {
|
||||
return func(l string) (string, string) {
|
||||
k, v, _ := strings.Cut(l, sep)
|
||||
return k, v
|
||||
}
|
||||
}
|
||||
|
||||
func splitAtLast(sep string) func(string) (string, string) {
|
||||
return func(l string) (string, string) {
|
||||
if i := strings.LastIndex(l, sep); i >= 0 {
|
||||
return l[:i], l[i+1:]
|
||||
}
|
||||
return l, ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstWord(l string) []string {
|
||||
f := strings.Fields(l)
|
||||
if len(f) == 0 {
|
||||
return nil
|
||||
}
|
||||
return f[:1]
|
||||
}
|
||||
|
||||
// pairNodes are the consumer and the provider of a pair credential line ("name module on consumer from provider local=...").
|
||||
func pairNodes(l string) []string {
|
||||
f := strings.Fields(l)
|
||||
var out []string
|
||||
for i := 0; i+1 < len(f); i++ {
|
||||
if f[i] == "on" || f[i] == "from" {
|
||||
out = append(out, f[i+1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1384/AC3: the comparison itself. A composition that differs by one setting's worth is named, with its machine and the
|
||||
// resource and field that first differ; two that agree, and two that refuse alike, are not differences. It failed first,
|
||||
// before bothWaysDiffer read the body: a changed setting went unseen.
|
||||
func TestTheComparisonNamesTheMachineWhoseCompositionDiffers(t *testing.T) {
|
||||
body := func(port string) []byte {
|
||||
raw, err := json.Marshal(map[string]any{"declaration": 1, "resources": []map[string]any{
|
||||
{"id": "files.config", "type": "file", "content": "port=" + port},
|
||||
{"id": "files.dir", "type": "directory"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
same := composedBoth{Body: body("8080"), Foreseen: []string{"files/admin"}}
|
||||
if got := bothWaysDiffer("laptop", "Reading", same, same, true); len(got) != 0 {
|
||||
t.Fatalf("a composition differs from itself: %v", got)
|
||||
}
|
||||
changed := composedBoth{Body: body("8081"), Foreseen: []string{"files/admin"}}
|
||||
got := bothWaysDiffer("laptop", "Reading", same, changed, true)
|
||||
if len(got) != 1 || !strings.Contains(got[0], "laptop, Reading") ||
|
||||
!strings.Contains(got[0], "resource files.config, field content") ||
|
||||
!strings.Contains(got[0], "port=8080") || !strings.Contains(got[0], "port=8081") {
|
||||
t.Fatalf("the changed setting is not named with its machine, resource and field: %v", got)
|
||||
}
|
||||
// A field of the lists.
|
||||
other := composedBoth{Body: body("8080"), Foreseen: []string{"files/admin", "files/token"}}
|
||||
if got := bothWaysDiffer("anchor", "Foreseeing", same, other, false); len(got) != 1 ||
|
||||
!strings.Contains(got[0], "anchor, Foreseeing: the own secrets named foreseen differs") {
|
||||
t.Fatalf("a secret named foreseen on one path alone is not named: %v", got)
|
||||
}
|
||||
// Refused alike is said, not failed; refused differently is a difference.
|
||||
a, b := composedBoth{Err: "files on laptop needs a credential"}, composedBoth{Err: "files on laptop needs a credential"}
|
||||
if got := bothWaysDiffer("laptop", "Foreseeing", a, b, true); len(got) != 0 {
|
||||
t.Fatalf("two compositions that refuse a machine alike differ: %v", got)
|
||||
}
|
||||
if got := bothWaysDiffer("laptop", "Foreseeing", a, composedBoth{Body: body("1")}, true); len(got) != 1 ||
|
||||
!strings.Contains(got[0], "the refusal differs") {
|
||||
t.Fatalf("a machine one path refuses and the other composes is not named: %v", got)
|
||||
}
|
||||
// And what a send made: only the entries that differ, per machine, never the whole lists.
|
||||
long := made{Ports: []string{"anchor files:80->8080", "anchor web:81->8081", "laptop db:5432->5432"},
|
||||
OwnSecrets: []string{"anchor files/admin (made)", "laptop db/root (made)"}}
|
||||
other2 := made{Ports: []string{"anchor files:80->8080", "anchor web:81->9091", "laptop db:5432->5432", "laptop cache:6379->6379"},
|
||||
OwnSecrets: []string{"anchor files/admin (made)", "laptop db/root (accepted)"}}
|
||||
got = madeDiffer("the send", long, other2, nil)
|
||||
if len(got) != 2 || !strings.Contains(got[0], "made only by mesh-sdk: laptop cache:6379") ||
|
||||
!strings.Contains(got[0], "anchor web:81 (the controller's 8081, mesh-sdk's 9091)") ||
|
||||
strings.Contains(got[0], "anchor files:80") || strings.Contains(got[0], "laptop db:5432") ||
|
||||
!strings.Contains(got[1], "laptop db/root (the controller's (made), mesh-sdk's (accepted))") ||
|
||||
strings.Contains(got[1], "anchor files/admin") {
|
||||
t.Fatalf("the send's differences are not only the differing entries: %v", got)
|
||||
}
|
||||
// A machine refused on either side is left out of both.
|
||||
if got := madeDiffer("the send", long, other2, map[string]bool{"anchor": true, "laptop": true}); len(got) != 0 {
|
||||
t.Fatalf("a refused machine is compared: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// aCoreInput is an Input of one machine that takes a core module whose credential the vault has not boxed: the composition
|
||||
// stands in a vault box for it, and the machine holds the controller's own seal of it.
|
||||
func aCoreInput(t *testing.T) (*meshInput, string) {
|
||||
t.Helper()
|
||||
raw := make([]byte, 32)
|
||||
raw[0] = 9
|
||||
key := base64.StdEncoding.EncodeToString(raw)
|
||||
client := catalogue.Manifest{Module: "mesh-provisioner-client", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode}},
|
||||
OwnSecrets: catalogue.OwnSecrets{broker.ProvisionerSecret: {Path: "/var/lib/mesh-provisioner-client/broker"}}}
|
||||
return &meshInput{Input: compose.Input{
|
||||
Catalogue: map[string]catalogue.Manifest{client.Module: client},
|
||||
Nodes: []compose.Node{{Name: "laptop", Assigned: []string{client.Module}, SealingKey: key, Stated: true,
|
||||
Contract: compose.VaultBoxContract}},
|
||||
OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: client.Module, Name: broker.ProvisionerSecret,
|
||||
Origin: "made", SealedTo: key, Sealed: "SEALED-BY-THE-CONTROLLER"}},
|
||||
}}, client.Module
|
||||
}
|
||||
|
||||
// 1385 (the lead's answer 1): where the vault is silent and no box is kept, the declaration carries the controller's own seal
|
||||
// for the core credential, as ADR 0396 has it, and stands in for nothing. It failed first: the composition's declaration
|
||||
// carried the stand-in box, and a send was refused.
|
||||
func TestACoreCredentialTheVaultDidNotBoxIsSealedByTheController(t *testing.T) {
|
||||
in, module := aCoreInput(t)
|
||||
d, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.CoreBoxesWanted) != 1 || len(stillForeseen(d)) == 0 {
|
||||
t.Fatalf("the composition does not stand in for the vault's box: %v", d.CoreBoxesWanted)
|
||||
}
|
||||
sealed, _, err := sealedByTheController(in, "laptop", d, []string{module})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := stillForeseen(sealed); len(left) != 0 {
|
||||
t.Fatalf("the declaration still stands in for %v", left)
|
||||
}
|
||||
var file map[string]any
|
||||
for _, r := range sealed.Resources {
|
||||
if r["id"] == module+"."+catalogue.NeedID(broker.ProvisionerSecret) {
|
||||
file = r
|
||||
}
|
||||
}
|
||||
if file == nil || file["sealed"] != "SEALED-BY-THE-CONTROLLER" || file[catalogue.VaultSealedField] != nil {
|
||||
t.Fatalf("the core credential is carried as %v: the controller's own seal, not the vault's box", file)
|
||||
}
|
||||
// And a module the vault did answer for is left as it is.
|
||||
other, _, err := sealedByTheController(in, "laptop", d, []string{"another-module"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stillForeseen(other)) == 0 {
|
||||
t.Fatal("a module the vault answered for was sealed by the controller")
|
||||
}
|
||||
}
|
||||
|
||||
// 1383 (the lead's answer 4): the machine that runs the bus is refused where no user has a credential, with busUserList's
|
||||
// words, and a machine that does not run the bus is not. It failed first: the Input stood a list in and the bus node composed.
|
||||
func TestTheMachineThatRunsTheBusIsRefusedWhereNoUserHasACredential(t *testing.T) {
|
||||
bus := catalogue.Manifest{Module: "nats", Version: "1", BusUsers: "/etc/nats/users.conf",
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
other := catalogue.Manifest{Module: "files", Version: "1"}
|
||||
why := errors.New("this machine runs the bus and not one user has a credential, so the composed list would refuse every connection in the mesh")
|
||||
in := &meshInput{busRefused: why, Input: compose.Input{
|
||||
Catalogue: map[string]catalogue.Manifest{"nats": bus, "files": other},
|
||||
Nodes: []compose.Node{{Name: "anchor", Assigned: []string{"nats"}}, {Name: "laptop", Assigned: []string{"files"}}},
|
||||
}}
|
||||
if err := refusedBus(in, "anchor"); err == nil || !strings.Contains(err.Error(), "not one user has a credential") {
|
||||
t.Fatalf("the machine that runs the bus is not refused: %v", err)
|
||||
}
|
||||
if err := refusedBus(in, "laptop"); err != nil {
|
||||
t.Fatalf("a machine that does not run the bus is refused: %v", err)
|
||||
}
|
||||
if err := refusedBus(&meshInput{Input: in.Input}, "anchor"); err != nil {
|
||||
t.Fatalf("a bus with users is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's seal replaces the stand-in box whatever path the resource carries: the path is the manifest's with the
|
||||
// machine's words filled in, not the manifest's own. It failed first, with the path compared (the live try: "composed twice and
|
||||
// still names ... asked of the vault by the next send").
|
||||
func TestTheControllersSealReplacesTheBoxWhateverPathTheFileCarries(t *testing.T) {
|
||||
in, module := aCoreInput(t)
|
||||
d, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
if _, box := r[catalogue.VaultSealedField]; box {
|
||||
r["path"] = "/home/someone/.var/lib/mesh-provisioner-client/broker"
|
||||
}
|
||||
}
|
||||
sealed, _, err := sealedByTheController(in, "laptop", d, []string{module})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left := stillForeseen(sealed); len(left) != 0 {
|
||||
t.Fatalf("the declaration still stands in for %v", left)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// The controller's adapter from its stores to mesh-sdk's composition input (novox/hq ADR 0453 clause 3, design 47 R4b,
|
||||
// issues 1382 and 1383).
|
||||
//
|
||||
// **The adapter reads, and perform writes.** readComposeInput asks the stores once for everything the composition reads
|
||||
// and returns compose.Input, which the composition never changes; it makes, seals, allocates and records nothing. A
|
||||
// pair credential, a port, an own secret or a core box that the next send would make is not made here: the composition
|
||||
// names it foreseen, and compose_perform.go makes it for a send, then reads the stores again and composes again. A
|
||||
// question (status, facts, probes, the merge gate's reading) never performs.
|
||||
//
|
||||
// **The Input is built once per operation.** A verb that composes every node reads the stores once and hands the Input to
|
||||
// each node's composition of it.
|
||||
|
||||
// composeInputBuilt is called once for each Input the adapter builds: the seam a test counts the reads of an operation by
|
||||
// (issue 1382, criterion 5).
|
||||
var composeInputBuilt = func() {}
|
||||
|
||||
// readComposeInput reads the stores once and returns what the composition needs. ctx carries what a send keeps (the
|
||||
// recorded modules kept at the build their machine runs, recorded_kept.go): the same contexts every send composes under.
|
||||
func readComposeInput(ctx context.Context, open *stores) (*meshInput, error) {
|
||||
in, shape, err := readMeshAndNodes(ctx, open)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// What each node resolves to, with no credential given (the settings are read before it): the modules whose settings are read, and the
|
||||
// needs whose pair credentials are asked of the stores. Nothing is written by it.
|
||||
unread, err := readSettings(ctx, open, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plans := map[string]compose.Resolved{}
|
||||
for _, n := range in.Nodes {
|
||||
if shape.refused[n.Name] != nil {
|
||||
continue // refused for what it keeps: nothing of it is read further
|
||||
}
|
||||
resolved, err := compose.Resolve(in, n.Name)
|
||||
switch {
|
||||
case compose.IsUnresolvable(err):
|
||||
// Their set does not compose: nothing of theirs is read further, as the controller never read a thing for a
|
||||
// node that did not resolve.
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
plans[n.Name] = resolved
|
||||
}
|
||||
if err := unreadSettings(unread, plans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var busRefused error
|
||||
wouldMake, err := readCredentialsAndBuilds(ctx, open, &in, plans, shape, &busRefused)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
composeInputBuilt()
|
||||
return &meshInput{Input: in, wouldMake: wouldMake, busRefused: busRefused, refused: shape.refused}, nil
|
||||
}
|
||||
|
||||
// meshInput is the Input an operation composes over, and the pair credentials its reading found none held for: the ones the
|
||||
// next send makes, which perform makes.
|
||||
type meshInput struct {
|
||||
compose.Input
|
||||
wouldMake []inventory.PairCredential
|
||||
// busRefused is why the bus's user list cannot be composed (no user has a credential): the machine that runs the bus is
|
||||
// refused with it, as the controller's own composition refused it, and no other machine is.
|
||||
busRefused error
|
||||
// refused is, by node, why that machine alone cannot be composed (nodeRefusal).
|
||||
refused map[string]error
|
||||
}
|
||||
|
||||
// nodeOf is the node of that name in an Input.
|
||||
func nodeOf(in compose.Input, name string) (compose.Node, bool) {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == name {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return compose.Node{}, false
|
||||
}
|
||||
|
||||
// inputShape is what the first half of the reading found that the second half needs.
|
||||
type inputShape struct {
|
||||
sent map[string]map[string]string // by node: module to the commit last sent
|
||||
kept map[string]map[string]string // by node: module to the commit kept
|
||||
current map[string]inventory.CurrentBuild
|
||||
// refused is, by node, why a recorded module cannot be kept at the build the machine runs (keepRecorded's refusal, in
|
||||
// its words): that machine alone is refused with it, as planFor refused it, and nothing else of the mesh is.
|
||||
refused map[string]error
|
||||
}
|
||||
|
||||
// readMeshAndNodes is the mesh-wide and per-node half of the Input (issue 1382).
|
||||
func readMeshAndNodes(ctx context.Context, open *stores) (compose.Input, inputShape, error) {
|
||||
inv := open.inventory
|
||||
var in compose.Input
|
||||
shape := inputShape{sent: map[string]map[string]string{}, kept: map[string]map[string]string{}, refused: map[string]error{}}
|
||||
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
in.Catalogue = shelf
|
||||
if in.Holdings, err = inv.Holdings(ctx); err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
if in.Licences, err = readLicences(ctx, open); err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
places, err := inv.Overlays(ctx)
|
||||
if err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
placed := map[string]inventory.Overlay{}
|
||||
for _, p := range places {
|
||||
placed[p.Name] = p
|
||||
}
|
||||
if shape.current, err = inv.CurrentBuilds(ctx); err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
|
||||
// The range the mesh allocates from and what its internal names end in, read from the environment here and nowhere else.
|
||||
in.OverlayCIDR = strings.TrimSpace(os.Getenv(OverlayCIDRVar))
|
||||
in.InternalSuffix = overlay.Suffix()
|
||||
if in.Tunnels, err = readTunnels(ctx, inv); err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
if in.Adopted, err = readAdopted(ctx, inv); err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
carried, err := inv.CarriedPeers(ctx)
|
||||
if err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
for _, c := range carried {
|
||||
in.CarriedPeers = append(in.CarriedPeers, compose.CarriedPeer{
|
||||
PublicKey: c.PublicKey, Address: c.Address, EnrolledAs: c.EnrolledAs, Named: c.Named})
|
||||
}
|
||||
if in.Joining, err = inv.NodesWithALiveToken(ctx); err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
|
||||
keepsSecrets := false
|
||||
for _, m := range shelf {
|
||||
keepsSecrets = keepsSecrets || m.Keeps != ""
|
||||
}
|
||||
|
||||
for _, n := range nodes {
|
||||
var refusal error
|
||||
node, err := readNode(ctx, open, n, placed[n.Name], &refusal)
|
||||
if err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
if refusal != nil {
|
||||
shape.refused[n.Name] = refusal
|
||||
}
|
||||
shape.sent[n.Name], shape.kept[n.Name] = node.SentBuilds, node.KeptBuilds
|
||||
in.Nodes = append(in.Nodes, node)
|
||||
}
|
||||
if keepsSecrets {
|
||||
if in.OperatorExport, err = inv.OperatorExport(ctx); err != nil {
|
||||
return in, shape, err
|
||||
}
|
||||
}
|
||||
return in, shape, nil
|
||||
}
|
||||
|
||||
// readNode is one node of the Input: everything the composition reads about it, in the order the controller read it.
|
||||
func readNode(ctx context.Context, open *stores, n inventory.Node, place inventory.Overlay,
|
||||
refused *error) (compose.Node, error) {
|
||||
inv := open.inventory
|
||||
out := compose.Node{
|
||||
Name: n.Name,
|
||||
Place: compose.Place{Key: place.Key, Endpoint: place.Endpoint, Site: place.Site, Hub: place.Hub,
|
||||
Address: place.Address},
|
||||
Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, AgentAccountHome: n.AgentAccountHome,
|
||||
Adopted: n.Adopted,
|
||||
}
|
||||
var err error
|
||||
if out.Assigned, err = inv.Assigned(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Capabilities, err = inv.ProfileOf(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.PublicDomain, err = inv.PublicDomainOf(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Pins, err = inv.PinsFor(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Bound, err = inv.BindingsFor(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
given, err := inv.PortsFor(ctx, n.Name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, a := range given {
|
||||
if out.Ports == nil {
|
||||
out.Ports = map[string]map[int]int{}
|
||||
}
|
||||
if out.Ports[a.Module] == nil {
|
||||
out.Ports[a.Module] = map[int]int{}
|
||||
}
|
||||
out.Ports[a.Module][a.Wanted] = a.Machine
|
||||
}
|
||||
if out.SealingKey, err = inv.SealingKeyOf(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if n.Adopted {
|
||||
if out.Taken, err = inv.Taken(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if out.OutwardLinks, err = inv.OutwardLinksOf(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
account, had, err := inv.MachineOf(ctx, n.Name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if had && account.Machine != nil {
|
||||
out.NetworksSaid = true
|
||||
for _, a := range account.Machine.Networks {
|
||||
out.Networks = append(out.Networks, catalogue.AttachedNetwork{Link: a.Link, Network: a.Network})
|
||||
}
|
||||
}
|
||||
if out.Sequence, err = inv.Sequence(ctx, n.ID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Epoch, err = inv.SentEpoch(ctx, n.ID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.Generation, err = inv.SentGeneration(ctx, n.ID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.ReadsModules, err = inv.ReadsModules(ctx, n.ID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.SentBuilds, out.SentKnown, err = inv.SentBuilds(ctx, n.Name); err != nil {
|
||||
return out, err
|
||||
}
|
||||
runs, err := inv.AssignmentRuns(ctx, n.Name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
out.Runs = make(map[string]compose.Run, len(runs))
|
||||
for module, ref := range runs {
|
||||
out.Runs[module] = compose.Run{Commit: ref.Commit, Build: ref.Build}
|
||||
}
|
||||
}
|
||||
// A recorded module kept at the build this machine runs, in the context a send composes under (recorded_kept.go): its
|
||||
// manifest and the commit, applied to this node's own resolution only.
|
||||
// What cannot be kept refuses this machine alone (keepRecorded's refusal, as planFor returned it).
|
||||
kept, err := recordedKept(ctx, open, n.Name)
|
||||
if err != nil {
|
||||
*refused = err
|
||||
} else if len(kept) > 0 {
|
||||
out.KeptBuilds = kept
|
||||
if out.Kept, err = keptManifests(ctx, open, n.Name, kept); err != nil {
|
||||
*refused = err
|
||||
out.KeptBuilds, out.Kept = nil, nil
|
||||
}
|
||||
}
|
||||
stated, has, err := inv.HealthOf(ctx, n.Name)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Stated = has
|
||||
if has {
|
||||
out.Contract = stated.Contract
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// keptManifests is the manifest of each module kept at the build a machine runs: keepRecorded's reads, and its refusals in its
|
||||
// words, without putting any of it into a shelf (that is the node's own Kept, applied by the composition).
|
||||
func keptManifests(ctx context.Context, open *stores, node string, kept map[string]string) (map[string]catalogue.Manifest, error) {
|
||||
// applyKept puts each manifest into the shelf it is given and refuses a build that cannot be read; the shelf here is a
|
||||
// scratch one, whose entries are the kept manifests.
|
||||
scratch := map[string]catalogue.Manifest{}
|
||||
if err := applyKept(ctx, open, node, kept, scratch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scratch, nil
|
||||
}
|
||||
|
||||
func readTunnels(ctx context.Context, inv *inventory.Inventory) (map[string]compose.FoundTunnel, error) {
|
||||
found, err := inv.Tunnels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]compose.FoundTunnel, len(found))
|
||||
for name, t := range found {
|
||||
out[name] = compose.FoundTunnel{Tunnel: tunnelOf(t.Tunnel), NodeAdopted: t.NodeAdopted}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readAdopted(ctx context.Context, inv *inventory.Inventory) (*compose.AdoptedTunnel, error) {
|
||||
tunnel, hub, adopted, err := inv.AdoptedTunnel(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hub == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &compose.AdoptedTunnel{Tunnel: tunnelOf(tunnel), Hub: hub, Adopted: adopted}, nil
|
||||
}
|
||||
|
||||
func tunnelOf(t inventory.Tunnel) compose.Tunnel {
|
||||
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, p := range t.Peers {
|
||||
out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readLicences is every licence the mesh holds, with who uses it and the key sealed to each holder: given even where empty,
|
||||
// so a holder the mesh holds no key for composes as that, and not as a key that was not asked for.
|
||||
func readLicences(ctx context.Context, open *stores) ([]compose.Licence, error) {
|
||||
held, err := open.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := held.All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []compose.Licence
|
||||
for _, one := range all {
|
||||
l := compose.Licence{Name: one.Name, Serves: one.Serves, Keys: map[compose.Holder]string{}}
|
||||
holders, err := held.HoldersOf(ctx, one.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, h := range holders {
|
||||
holder := compose.Holder{Node: h.Node, Module: h.Module}
|
||||
l.Holders = append(l.Holders, holder)
|
||||
key, err := held.KeyFor(ctx, one.Name, h.Node, h.Module)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Keys[holder] = key
|
||||
}
|
||||
node, module, err := held.ManagerOf(ctx, one.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.Manager = compose.Holder{Node: node, Module: module}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readSettings is each node's settings layers for every module that has any, read BEFORE any node is resolved: the first pass
|
||||
// resolves a node whose set takes a provider's served facts from the node that runs it, with that node's settings on them
|
||||
// (servedOnNode), so a mesh whose provider says ${setting:domain} resolves only with the settings in the Input. A module's
|
||||
// settings that fail to read are returned in unread, by node and module, and are an error only for a module a node resolves to
|
||||
// (unreadSettings): the controller read none for a module nothing runs.
|
||||
func readSettings(ctx context.Context, open *stores, in *compose.Input) (unread map[string]map[string]error, err error) {
|
||||
have, err := open.inventory.ModulesWithSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(have))
|
||||
for module := range have {
|
||||
if _, onShelf := in.Catalogue[module]; onShelf {
|
||||
names = append(names, module)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
for i := range in.Nodes {
|
||||
n := &in.Nodes[i]
|
||||
for _, module := range names {
|
||||
layers, err := open.inventory.SettingsFor(ctx, n.Name, module)
|
||||
if err != nil {
|
||||
if unread == nil {
|
||||
unread = map[string]map[string]error{}
|
||||
}
|
||||
if unread[n.Name] == nil {
|
||||
unread[n.Name] = map[string]error{}
|
||||
}
|
||||
unread[n.Name][module] = err
|
||||
continue
|
||||
}
|
||||
if len(layers) == 0 {
|
||||
continue
|
||||
}
|
||||
if n.Settings == nil {
|
||||
n.Settings = map[string][]catalogue.Layer{}
|
||||
}
|
||||
n.Settings[module] = layers
|
||||
}
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// unreadSettings is the error of a module's settings that did not read, for a module the node resolves to.
|
||||
func unreadSettings(unread map[string]map[string]error, plans map[string]compose.Resolved) error {
|
||||
names := make([]string, 0, len(plans))
|
||||
for node := range plans {
|
||||
names = append(names, node)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, node := range names {
|
||||
for _, m := range plans[node].Plan.Modules {
|
||||
if err := unread[node][m.Module]; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// The second half of the adapter (issue 1383): the credential and build fields of the Input, read and never made.
|
||||
|
||||
// readCredentialsAndBuilds fills what the first half left: the credentials, the own secrets, the vault's boxes, the bus,
|
||||
// each node's serving key and certificate, and the builds.
|
||||
func readCredentialsAndBuilds(ctx context.Context, open *stores, in *compose.Input, plans map[string]compose.Resolved,
|
||||
shape inputShape, busRefused *error) ([]inventory.PairCredential, error) {
|
||||
if err := readIdentity(ctx, open, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wouldMake, err := readCredentials(ctx, open, in, plans, busRefused)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wouldMake, readBuilds(ctx, open, in, plans, shape)
|
||||
}
|
||||
|
||||
// readIdentity is the mesh's authority and each node's serving key and the certificate kept for it, for a mesh in which a
|
||||
// module asks for a certificate. It establishes and issues nothing: a certificate not kept yet is composed with a stand-in, and
|
||||
// issued by the send (certificateFor).
|
||||
func readIdentity(ctx context.Context, open *stores, in *compose.Input) error {
|
||||
needs := false
|
||||
for _, m := range in.Catalogue {
|
||||
needs = needs || m.Certificate != nil
|
||||
}
|
||||
if !needs {
|
||||
return nil
|
||||
}
|
||||
ident, err := open.Identity(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if certificate, known, err := ident.KnownAuthority(ctx); err != nil {
|
||||
return err
|
||||
} else if known {
|
||||
in.Authority = certificate
|
||||
}
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, n := range nodes {
|
||||
ids[n.Name] = n.ID
|
||||
}
|
||||
for i := range in.Nodes {
|
||||
n := &in.Nodes[i]
|
||||
if n.ServingKey, err = ident.ServingKeyOf(ctx, ids[n.Name]); err != nil {
|
||||
return err
|
||||
}
|
||||
if n.ServingKey == "" {
|
||||
continue
|
||||
}
|
||||
if certificate, known, err := ident.KeptCertificate(ctx, overlay.InternalName(n.Name), n.ServingKey); err != nil {
|
||||
return err
|
||||
} else if known {
|
||||
n.Certificate = certificate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readCredentials is how each pair credential a node takes from elsewhere stands, the credentials made for each provider's
|
||||
// consumers, the own secrets, the vault's boxes and the bus. It reads and never makes: a credential a send would make is
|
||||
// left out (the composition's would-make), a credential only a person gives and nobody gave is not-given, and one the send
|
||||
// would be refused is refused with the controller's words.
|
||||
func readCredentials(ctx context.Context, open *stores, in *compose.Input, plans map[string]compose.Resolved,
|
||||
busRefused *error) ([]inventory.PairCredential, error) {
|
||||
var wouldMake []inventory.PairCredential
|
||||
inv := open.inventory
|
||||
shelf := in.Catalogue
|
||||
nodeOf := map[string]compose.Node{}
|
||||
for _, n := range in.Nodes {
|
||||
nodeOf[n.Name] = n
|
||||
}
|
||||
names := make([]string, 0, len(plans))
|
||||
for name := range plans {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
resolved := plans[name]
|
||||
nodeShelf := shelfWith(shelf, nodeOf[name].Kept)
|
||||
for _, need := range resolved.Plan.Needs {
|
||||
if need.ByRecord {
|
||||
continue
|
||||
}
|
||||
p := inventory.PairCredential{Name: need.Name, Consumer: name, ConsumerModule: need.For,
|
||||
Provider: need.From, Local: need.Local}
|
||||
if module, outside := issuedOutsideBy(nodeShelf, need); outside {
|
||||
p.Kind, p.ProviderModule = inventory.PairOutside, module
|
||||
} else if need.SharedOwn != "" {
|
||||
p.Kind, p.Own = inventory.PairShared, need.SharedOwn
|
||||
p.ProviderModule = sharedProviderModule(resolved.Plan, shelf, need)
|
||||
}
|
||||
state, err := inv.PairCredentialState(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := compose.Credential{Provision: need.Name, Consumer: name, ConsumerModule: need.For,
|
||||
Provider: need.From, Local: need.Local}
|
||||
switch state.State {
|
||||
case inventory.PairHeld:
|
||||
c.State, c.Sealed = compose.CredentialHeld, state.Sealed
|
||||
case inventory.PairNotGiven:
|
||||
c.State, c.Why = compose.CredentialNotGiven, state.Why
|
||||
case inventory.PairRefused:
|
||||
c.State, c.Why = compose.CredentialRefused, state.Why
|
||||
default:
|
||||
// Left out: a need with no entry is the credential the next send makes, and perform makes it.
|
||||
wouldMake = append(wouldMake, p)
|
||||
continue
|
||||
}
|
||||
in.Credentials = append(in.Credentials, c)
|
||||
}
|
||||
}
|
||||
|
||||
// The credentials made for each provider's consumers, so the grants it must create are read from them.
|
||||
for _, n := range in.Nodes {
|
||||
rows, err := inv.SecretsFrom(ctx, n.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range rows {
|
||||
in.Issued = append(in.Issued, compose.Issued{Provision: s.Name, Consumer: s.Consumer,
|
||||
ConsumerModule: s.ConsumerModule, Local: s.Local, Provider: n.Name, ForProvider: s.ForProvider})
|
||||
}
|
||||
}
|
||||
own, err := inv.OwnSecretRows(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range own {
|
||||
in.OwnSecrets = append(in.OwnSecrets, compose.OwnSecretRow{Node: r.Node, Module: r.Module, Name: r.Name,
|
||||
Origin: r.Origin, SealedTo: r.SealedTo, Sealed: r.Sealed})
|
||||
}
|
||||
for _, n := range in.Nodes {
|
||||
boxes, err := inv.KeptCoreBoxes(ctx, n.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for module, byName := range boxes {
|
||||
for name, b := range byName {
|
||||
in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: n.Name, Module: module, Name: name,
|
||||
Generation: b.Generation, SealedTo: b.SealedTo, Box: b.Box})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(in.KeptCoreBoxes, func(i, j int) bool {
|
||||
a, b := in.KeptCoreBoxes[i], in.KeptCoreBoxes[j]
|
||||
if a.Node != b.Node {
|
||||
return a.Node < b.Node
|
||||
}
|
||||
if a.Module != b.Module {
|
||||
return a.Module < b.Module
|
||||
}
|
||||
return a.Name < b.Name
|
||||
})
|
||||
|
||||
// The bus: its user list as the controller composes it, for the machine that runs it, and who the mesh has minted a
|
||||
// credential for.
|
||||
var bus []catalogue.Manifest
|
||||
for _, m := range shelf {
|
||||
if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
|
||||
bus = append(bus, m)
|
||||
}
|
||||
}
|
||||
if len(bus) > 0 {
|
||||
list, missing, err := busUserList(ctx, inv, bus)
|
||||
if err != nil && !strings.Contains(err.Error(), "not one user has a credential") {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
// The machine that runs the bus is refused, as the controller's own composition refused it: a list that
|
||||
// refuses every connection in the mesh is never composed (refusedBus); the other machines compose.
|
||||
*busRefused = err
|
||||
}
|
||||
in.BusUsers, in.BusMissing = list, missing
|
||||
}
|
||||
if in.BusMinted, err = inv.BusMinted(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.BusMemberships, err = inv.BusMemberships(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wouldMake, nil
|
||||
}
|
||||
|
||||
// readBuilds is the build half of the Input: every repository the mesh built, each module's current build, and the build made
|
||||
// from a commit of a module for the machines whose node-engine reads them.
|
||||
func readBuilds(ctx context.Context, open *stores, in *compose.Input, plans map[string]compose.Resolved, shape inputShape) error {
|
||||
inv := open.inventory
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for repository := range held {
|
||||
in.Built = append(in.Built, repository)
|
||||
}
|
||||
sort.Strings(in.Built)
|
||||
in.CurrentBuilds = make(map[string]string, len(shape.current))
|
||||
for module, b := range shape.current {
|
||||
in.CurrentBuilds[module] = b.Commit
|
||||
}
|
||||
// The build made from each commit a declaration may carry, for the machines that read a build per module.
|
||||
for _, n := range in.Nodes {
|
||||
if !n.ReadsModules {
|
||||
continue
|
||||
}
|
||||
resolved, ok := plans[n.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, m := range resolved.Plan.Modules {
|
||||
for _, commit := range []string{shape.current[m.Module].Commit, shape.kept[n.Name][m.Module], shape.sent[n.Name][m.Module]} {
|
||||
if commit == "" {
|
||||
continue
|
||||
}
|
||||
if _, done := in.BuildIDs[m.Module][commit]; done {
|
||||
continue
|
||||
}
|
||||
build, err := inv.BuildOf(ctx, m.Module, commit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.BuildIDs == nil {
|
||||
in.BuildIDs = map[string]map[string]string{}
|
||||
}
|
||||
if in.BuildIDs[m.Module] == nil {
|
||||
in.BuildIDs[m.Module] = map[string]string{}
|
||||
}
|
||||
in.BuildIDs[m.Module][commit] = build
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shelfWith is the shelf with each module a node keeps at the build it runs put in place of the current one: a copy.
|
||||
func shelfWith(shelf map[string]catalogue.Manifest, kept map[string]catalogue.Manifest) map[string]catalogue.Manifest {
|
||||
if len(kept) == 0 {
|
||||
return shelf
|
||||
}
|
||||
out := make(map[string]catalogue.Manifest, len(shelf)+len(kept))
|
||||
for name, m := range shelf {
|
||||
out[name] = m
|
||||
}
|
||||
for name, m := range kept {
|
||||
out[name] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sharedProviderModule is which module answers a shared credential on the providing node: the one in the plan, else the first
|
||||
// the catalogue names (providerModuleOf, in an order that does not move between calls).
|
||||
func sharedProviderModule(plan catalogue.Resolution, shelf map[string]catalogue.Manifest, n catalogue.Needed) string {
|
||||
for _, m := range plan.Modules {
|
||||
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
||||
return m.Module
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(shelf))
|
||||
for name := range shelf {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if _, shared := shelf[name].SharedCredentialOf(n.Name); shared {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// tableState is every table's rows and every sequence's last value, as the adapter's reads must leave them.
|
||||
func tableState(t *testing.T, inv *inventory.Inventory) map[string]string {
|
||||
t.Helper()
|
||||
out := map[string]string{}
|
||||
for _, row := range inv.QueryForTest(t, `select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_type = 'BASE TABLE' order by table_name`) {
|
||||
name := strings.Trim(row[0], `"`)
|
||||
count := inv.QueryForTest(t, fmt.Sprintf(`select count(*)::text from %q`, name))
|
||||
out["rows:"+name] = fmt.Sprint(count)
|
||||
digest := inv.QueryForTest(t, fmt.Sprintf(`select coalesce(md5(string_agg(t::text, '|' order by t::text)), '') from %q t`, name))
|
||||
out["content:"+name] = fmt.Sprint(digest)
|
||||
}
|
||||
for _, row := range inv.QueryForTest(t, `select sequencename, coalesce(last_value::text, '') from pg_sequences
|
||||
where schemaname = 'public' order by sequencename`) {
|
||||
out["sequence:"+row[0]] = row[1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1382/AC4 and 1383/AC6: building the Input writes nothing. The store's rows, their contents and its sequences are the same
|
||||
// before and after, on a mesh whose credentials are not made, whose ports are not given and which keeps a recorded build.
|
||||
func TestBuildingTheInputWritesNothing(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
// A consumer whose credential nothing has made yet, on a mesh that already composed.
|
||||
register(t, open, catalogue.Manifest{Module: "reports", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/reports/config.json", "mode": "0600",
|
||||
"content": "{}", "merge": "json"}}})
|
||||
if _, err := inv.Assign(ctx, "laptop", "reports"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := tableState(t, inv)
|
||||
if _, err := readComposeInput(ctx, open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := tableState(t, inv)
|
||||
var moved []string
|
||||
for k, v := range after {
|
||||
if before[k] != v {
|
||||
moved = append(moved, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(moved)
|
||||
if len(moved) > 0 {
|
||||
t.Fatalf("building the Input wrote: %v", moved)
|
||||
}
|
||||
}
|
||||
|
||||
// a consumer of the s3-bucket aMeshWithSecrets' provider offers, with a file the credential goes into.
|
||||
func aConsumerOfTheBucket(name string) catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: name, Version: "1", Requires: []string{"s3-bucket"},
|
||||
Secrets: map[string]string{"s3-bucket": "/etc/" + name + "/s3"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/" + name + "/config.json", "mode": "0600",
|
||||
"content": "{}", "merge": "json"}}}
|
||||
}
|
||||
|
||||
// 1383/AC1: of a provider's three consumers, two have their credentials made and one has not: Issued holds the two rows, with
|
||||
// the copy sealed to the provider, the third is a credential the next send makes, and no row was inserted for it (planFor
|
||||
// inserted it, plan.go: SecretFor in every mode).
|
||||
func TestAPairCredentialNoRowHoldsIsAWouldMakeAndNoRowIsInserted(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, aConsumerOfTheBucket("reports"))
|
||||
if _, err := inv.Assign(ctx, "laptop", "reports"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The controller's own planning makes the credentials of files and reports.
|
||||
if _, _, err := planFor(ctx, open, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
register(t, open, aConsumerOfTheBucket("metrics"))
|
||||
if _, err := inv.Assign(ctx, "laptop", "metrics"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := tableState(t, inv)
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []compose.Issued
|
||||
for _, r := range in.Issued {
|
||||
if r.Provider == "anchor" && r.Provision == "s3-bucket" {
|
||||
rows = append(rows, r)
|
||||
if r.ForProvider == "" {
|
||||
t.Errorf("the row of %s carries no copy sealed to the provider", r.ConsumerModule)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("Issued holds %d row(s) for anchor's s3-bucket, want files and reports: %+v", len(rows), rows)
|
||||
}
|
||||
var would []string
|
||||
for _, p := range in.wouldMake {
|
||||
would = append(would, p.ConsumerModule)
|
||||
}
|
||||
if len(would) != 1 || would[0] != "metrics" {
|
||||
t.Fatalf("the credentials the next send makes are %v, want metrics", would)
|
||||
}
|
||||
for _, c := range in.Credentials {
|
||||
if c.ConsumerModule == "metrics" {
|
||||
t.Errorf("a credential nobody holds has an entry: %+v", c)
|
||||
}
|
||||
}
|
||||
after := tableState(t, inv)
|
||||
for k, v := range after {
|
||||
if before[k] != v {
|
||||
t.Errorf("reading the Input moved %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1383/AC2: a credential only a person gives that nobody gave is not given, and so is one given under a key the machine no
|
||||
// longer has; a credential given by a person for the pair and sealed to a key one end no longer has is refused: in the words
|
||||
// plan.go and secrets.go use.
|
||||
func TestACredentialOnlyAPersonGivesIsNotGivenAndAGivenOneUnderAnOldKeyIsSaidSo(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, catalogue.Manifest{Module: "indexers", Version: "1", Provides: []catalogue.Offer{{
|
||||
Name: "indexer-api", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}})
|
||||
register(t, open, catalogue.Manifest{Module: "grabber", Version: "1", Requires: []string{"indexer-api"},
|
||||
Secrets: map[string]string{"indexer-api": "/etc/grabber/key"}})
|
||||
register(t, open, aConsumerOfTheBucket("ledger"))
|
||||
register(t, open, catalogue.Manifest{Module: "objects", Version: "1", Provides: catalogue.FromAnywhere("s3-bucket")})
|
||||
for _, a := range [][2]string{{"anchor", "indexers"}, {"laptop", "grabber"}, {"anchor", "objects"}, {"laptop", "ledger"}} {
|
||||
if _, err := inv.Assign(ctx, a[0], a[1]); err != nil {
|
||||
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
||||
}
|
||||
}
|
||||
stateOf := func(provision, consumerModule string) (compose.Credential, bool) {
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range in.Credentials {
|
||||
if c.Provision == provision && c.ConsumerModule == consumerModule {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return compose.Credential{}, false
|
||||
}
|
||||
|
||||
c, found := stateOf("indexer-api", "grabber")
|
||||
if !found || c.State != compose.CredentialNotGiven || !strings.Contains(c.Why, "nobody gave it") {
|
||||
t.Fatalf("a credential nobody gave reads as %+v", c)
|
||||
}
|
||||
if err := inv.AcceptSecretForPair(ctx, "indexer-api", "laptop", "grabber", "anchor", "", "the-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, found := stateOf("indexer-api", "grabber"); !found || c.State != compose.CredentialHeld || c.Sealed == "" {
|
||||
t.Fatalf("a credential a person gave reads as %+v", c)
|
||||
}
|
||||
// The consumer's machine generated a key of its own since.
|
||||
if err := inv.RecordSealingKey(ctx, nodeID(t, open, "laptop"), aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, found := stateOf("indexer-api", "grabber"); !found || c.State != compose.CredentialNotGiven ||
|
||||
!strings.Contains(c.Why, "a sealing key one end no longer has") {
|
||||
t.Fatalf("a credential given under a key the machine no longer has reads as %+v", c)
|
||||
}
|
||||
|
||||
// A credential for a pair, given by a person, under a key one end has since replaced: the mesh cannot make another.
|
||||
if err := inv.RecordSealingKey(ctx, nodeID(t, open, "laptop"), aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.AcceptSecretForPair(ctx, "s3-bucket", "laptop", "ledger", "anchor", "", "the-bucket-key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSealingKey(ctx, nodeID(t, open, "laptop"), aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, found := stateOf("s3-bucket", "ledger"); !found || c.State != compose.CredentialRefused ||
|
||||
!strings.Contains(c.Why, "was accepted from a person, and a sealing key at one end has changed since") {
|
||||
t.Fatalf("a credential given for the pair under an old key reads as %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// 1383/AC3: the own secrets carry their origin, the key they are sealed to and the sealed value; the vault's kept boxes, the
|
||||
// licences (with their keys given, so an empty key means the mesh holds none), the users the bus has minted and the
|
||||
// memberships equal what the inventory answers.
|
||||
func TestTheAdaptersInputCarriesTheOwnSecretsTheBoxesTheLicencesAndTheBus(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
key, err := inv.SealingKeyOf(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, err := inv.SecretForModule(ctx, "anchor", "objects", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
box := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`)
|
||||
if err := inv.KeepCoreBox(ctx, "anchor", inventory.CoreBox{Module: "objects", Name: "admin", Generation: 4,
|
||||
SealedTo: key, Box: box}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SeedBusUser(ctx, inventory.BusUser{Username: "anchor.objects", Kind: "module", Node: "anchor",
|
||||
Module: "objects"}, "a-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.PutBusMembership(ctx, "laptop", "sealed-membership"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := open.Licences(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := held.Add(ctx, "claude", "static", map[string]any{"model": "x"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var own *compose.OwnSecretRow
|
||||
for i := range in.OwnSecrets {
|
||||
if r := &in.OwnSecrets[i]; r.Node == "anchor" && r.Module == "objects" && r.Name == "admin" {
|
||||
own = r
|
||||
}
|
||||
}
|
||||
if own == nil || own.Origin != "made" || own.SealedTo != key || own.Sealed != sealed {
|
||||
t.Errorf("the own secret reads as %+v; the inventory holds %q sealed to %q", own, sealed, key)
|
||||
}
|
||||
var kept *compose.CoreBox
|
||||
for i := range in.KeptCoreBoxes {
|
||||
if b := &in.KeptCoreBoxes[i]; b.Node == "anchor" && b.Module == "objects" && b.Name == "admin" {
|
||||
kept = b
|
||||
}
|
||||
}
|
||||
if kept == nil || kept.Generation != 4 || kept.SealedTo != key || string(kept.Box) != string(box) {
|
||||
t.Errorf("the kept box reads as %+v", kept)
|
||||
}
|
||||
if got := strings.Join(in.BusMinted, ","); !strings.Contains(got, "anchor.objects") {
|
||||
t.Errorf("BusMinted is %v", in.BusMinted)
|
||||
}
|
||||
if in.BusMemberships["laptop"] != "sealed-membership" {
|
||||
t.Errorf("BusMemberships is %v", in.BusMemberships)
|
||||
}
|
||||
if len(in.Licences) != 1 || in.Licences[0].Name != "claude" || in.Licences[0].Keys == nil {
|
||||
t.Errorf("Licences read as %+v", in.Licences)
|
||||
}
|
||||
}
|
||||
|
||||
// 1383/AC4: the bus's user list is what busUserList gives for the machine that runs the bus, and the Input carries none when
|
||||
// no module of the shelf runs the bus.
|
||||
func TestTheBusUsersAreWhatTheControllerComposesForTheMachineThatRunsTheBus(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.BusUsers != "" || len(in.BusMissing) != 0 {
|
||||
t.Fatalf("a mesh whose shelf runs no bus carries a bus user list: %q %v", in.BusUsers, in.BusMissing)
|
||||
}
|
||||
bus := catalogue.Manifest{Module: "nats", Version: "1", BusUsers: "/etc/nats/users.conf",
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
register(t, open, bus)
|
||||
if err := open.inventory.SeedBusUser(ctx, inventory.BusUser{Username: "controller", Kind: "controller"}, "pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err = readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, missing, _ := busUserList(ctx, open.inventory, []catalogue.Manifest{bus})
|
||||
if in.BusUsers != list || fmt.Sprint(in.BusMissing) != fmt.Sprint(missing) {
|
||||
t.Errorf("the Input carries %q (%v); busUserList gives %q (%v)", in.BusUsers, in.BusMissing, list, missing)
|
||||
}
|
||||
}
|
||||
|
||||
// 1383/AC5: Built, CurrentBuilds and BuildIDs equal the inventory's Held, CurrentBuilds and BuildOf, and the serving key and
|
||||
// the certificate are the machine's.
|
||||
func TestTheAdaptersInputCarriesTheBuildsAndTheMachinesKey(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantBuilt []string
|
||||
for repository := range held {
|
||||
wantBuilt = append(wantBuilt, repository)
|
||||
}
|
||||
sort.Strings(wantBuilt)
|
||||
if fmt.Sprint(in.Built) != fmt.Sprint(wantBuilt) && !(len(in.Built) == 0 && len(wantBuilt) == 0) {
|
||||
t.Errorf("Built is %v, the inventory's %v", in.Built, wantBuilt)
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for module, b := range current {
|
||||
if in.CurrentBuilds[module] != b.Commit {
|
||||
t.Errorf("CurrentBuilds[%s] is %q, the inventory's %q", module, in.CurrentBuilds[module], b.Commit)
|
||||
}
|
||||
}
|
||||
for module, byCommit := range in.BuildIDs {
|
||||
for commit, build := range byCommit {
|
||||
if want, _ := inv.BuildOf(ctx, module, commit); want != build {
|
||||
t.Errorf("BuildIDs[%s][%s] is %q, the inventory's %q", module, commit, build, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The lead's answer 4: where the mesh holds no credential for any bus user, the machine that runs the bus is refused with
|
||||
// busUserList's words (the controller's composition refused it), the Input still builds, and another machine composes.
|
||||
func TestTheBusMachineIsRefusedWhereNoUserHasACredentialAndTheOthersCompose(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
bus := catalogue.Manifest{Module: "nats", Version: "1", BusUsers: "/etc/nats/users.conf",
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
register(t, open, bus)
|
||||
register(t, open, aWebModule())
|
||||
if _, err := assign(ctx, open, "anchor", "nats"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := composeQuestion(ctx, open, "anchor", compose.Foreseeing, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "not one user has a credential") {
|
||||
t.Fatalf("the bus machine is composed: %v", err)
|
||||
}
|
||||
if _, _, err := composeQuestion(ctx, open, "laptop", compose.Foreseeing, false); err != nil {
|
||||
t.Fatalf("a machine that does not run the bus is refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// 1382: the adapter's Input names, for every node, what the inventory answers for it, each compared with the read named
|
||||
// beside the field in go/compose/input.go (novox/hq ADR 0453 clause 3).
|
||||
func TestTheAdaptersInputNamesWhatTheInventoryAnswersForEachNode(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
if err := inv.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.PortFor(ctx, "laptop", "files", 8080, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(in.Catalogue, shelf) {
|
||||
t.Error("the Input's Catalogue is not the shelf the inventory holds")
|
||||
}
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(in.Nodes) != len(nodes) {
|
||||
t.Fatalf("the Input names %d node(s), the inventory has %d", len(in.Nodes), len(nodes))
|
||||
}
|
||||
places, err := inv.Overlays(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range nodes {
|
||||
n, ok := nodeOf(in.Input, record.Name)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the Input", record.Name)
|
||||
}
|
||||
for _, p := range places {
|
||||
if p.Name == record.Name {
|
||||
if want := (compose.Place{Key: p.Key, Endpoint: p.Endpoint, Site: p.Site, Hub: p.Hub, Address: p.Address}); n.Place != want {
|
||||
t.Errorf("%s: Place is %+v, the inventory's %+v", record.Name, n.Place, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
assigned, _ := inv.Assigned(ctx, record.Name)
|
||||
if !reflect.DeepEqual(n.Assigned, assigned) {
|
||||
t.Errorf("%s: Assigned is %v, the inventory's %v", record.Name, n.Assigned, assigned)
|
||||
}
|
||||
caps, _ := inv.ProfileOf(ctx, record.Name)
|
||||
if !reflect.DeepEqual(n.Capabilities, caps) {
|
||||
t.Errorf("%s: Capabilities are %v, the inventory's %v", record.Name, n.Capabilities, caps)
|
||||
}
|
||||
domain, _ := inv.PublicDomainOf(ctx, record.Name)
|
||||
if n.PublicDomain != domain {
|
||||
t.Errorf("%s: PublicDomain is %q, the inventory's %q", record.Name, n.PublicDomain, domain)
|
||||
}
|
||||
if n.Account != record.Account || n.AccountHome != record.AccountHome || n.AgentAccount != record.AgentAccount ||
|
||||
n.AgentAccountHome != record.AgentAccountHome || n.Adopted != record.Adopted {
|
||||
t.Errorf("%s: the accounts or the mode differ from the inventory's: %+v", record.Name, n)
|
||||
}
|
||||
pins, _ := inv.PinsFor(ctx, record.Name)
|
||||
if len(pins) != len(n.Pins) || (len(pins) > 0 && !reflect.DeepEqual(n.Pins, pins)) {
|
||||
t.Errorf("%s: Pins are %v, the inventory's %v", record.Name, n.Pins, pins)
|
||||
}
|
||||
bound, _ := inv.BindingsFor(ctx, record.Name)
|
||||
if len(bound) != len(n.Bound) || (len(bound) > 0 && !reflect.DeepEqual(n.Bound, bound)) {
|
||||
t.Errorf("%s: Bound is %v, the inventory's %v", record.Name, n.Bound, bound)
|
||||
}
|
||||
key, _ := inv.SealingKeyOf(ctx, record.Name)
|
||||
if n.SealingKey != key {
|
||||
t.Errorf("%s: SealingKey is %q, the inventory's %q", record.Name, n.SealingKey, key)
|
||||
}
|
||||
ports, _ := inv.PortsFor(ctx, record.Name)
|
||||
want := map[string]map[int]int{}
|
||||
for _, a := range ports {
|
||||
if want[a.Module] == nil {
|
||||
want[a.Module] = map[int]int{}
|
||||
}
|
||||
want[a.Module][a.Wanted] = a.Machine
|
||||
}
|
||||
if len(want) != len(n.Ports) || (len(want) > 0 && !reflect.DeepEqual(n.Ports, want)) {
|
||||
t.Errorf("%s: Ports are %v, the inventory's %v", record.Name, n.Ports, want)
|
||||
}
|
||||
// The settings of every module it resolves to, layer for layer.
|
||||
resolved, err := compose.Resolve(in.Input, record.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range resolved.Plan.Modules {
|
||||
layers, err := inv.SettingsFor(ctx, record.Name, m.Module)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(layers) != len(n.Settings[m.Module]) || (len(layers) > 0 && !reflect.DeepEqual(n.Settings[m.Module], layers)) {
|
||||
t.Errorf("%s: the settings of %s are %v, the inventory's %v", record.Name, m.Module, n.Settings[m.Module], layers)
|
||||
}
|
||||
}
|
||||
}
|
||||
if domain := nodeDomain(in.Input, "anchor"); domain != "example.test" {
|
||||
t.Errorf("anchor's PublicDomain is %q", domain)
|
||||
}
|
||||
if got := nodePorts(in.Input, "laptop"); got["files"][8080] == 0 {
|
||||
t.Errorf("laptop's port for files:8080 is not in the Input: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func nodeDomain(in compose.Input, name string) string {
|
||||
n, _ := nodeOf(in, name)
|
||||
return n.PublicDomain
|
||||
}
|
||||
|
||||
func nodePorts(in compose.Input, name string) map[string]map[int]int {
|
||||
n, _ := nodeOf(in, name)
|
||||
return n.Ports
|
||||
}
|
||||
|
||||
// 1382/AC2: a node keeping a recorded module at the build it runs has that module in Kept and KeptBuilds, and the Input's
|
||||
// Catalogue is the current shelf, not the kept manifest (R4a review round 1, finding 1).
|
||||
func TestANodeKeepingARecordedBuildHasItInKeptAndTheCatalogueStaysCurrent(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
start := time.Now().Add(-time.Hour)
|
||||
image := "registry.invalid:5000/postgres/server@sha256:" + strings.Repeat("a", 64)
|
||||
for _, b := range []link.BuildResult{
|
||||
aContainerBuild(t, "postgres", "c1111111", catalogue.PolicyRecord, start, map[string][2]string{"server": {image, ""}}),
|
||||
} {
|
||||
if _, _, err := takeIn(ctx, inv, asTheOperator(t, inv, b)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "postgres"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-anchor", map[string]string{"postgres": "c1111111"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := takeIn(ctx, inv, aContainerBuild(t, "postgres", "c2222222", catalogue.PolicyRecord, start.Add(time.Minute),
|
||||
map[string][2]string{"server": {image, "runtime"}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A send that is not a person's push keeps the build the machine runs.
|
||||
in, err := readComposeInput(keepingRecorded(ctx), open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
anchor, _ := nodeOf(in.Input, "anchor")
|
||||
if anchor.KeptBuilds["postgres"] != "c1111111" {
|
||||
t.Fatalf("anchor keeps %v, want postgres at c1111111", anchor.KeptBuilds)
|
||||
}
|
||||
kept, found := anchor.Kept["postgres"]
|
||||
if !found {
|
||||
t.Fatalf("the kept manifest of postgres is not in anchor's Kept: %v", anchor.Kept)
|
||||
}
|
||||
current, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(in.Catalogue["postgres"], current["postgres"]) {
|
||||
t.Error("the Input's Catalogue is not the current shelf")
|
||||
}
|
||||
if reflect.DeepEqual(kept, current["postgres"]) {
|
||||
t.Error("the kept manifest is the current one: nothing is kept")
|
||||
}
|
||||
if laptop, _ := nodeOf(in.Input, "laptop"); len(laptop.Kept) != 0 || len(laptop.KeptBuilds) != 0 {
|
||||
t.Errorf("laptop keeps what only anchor runs: %v", laptop.KeptBuilds)
|
||||
}
|
||||
|
||||
// A person's push keeps nothing.
|
||||
pushed, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a, _ := nodeOf(pushed.Input, "anchor"); len(a.Kept) != 0 || len(a.KeptBuilds) != 0 {
|
||||
t.Errorf("a person's push keeps %v", a.KeptBuilds)
|
||||
}
|
||||
}
|
||||
|
||||
// 1382/AC3: the seats on record, the tunnels, the adopted tunnel, its carried peers and the nodes joining equal the
|
||||
// inventory's answers; the range and the suffix are read from the environment once, here.
|
||||
func TestTheAdaptersInputCarriesTheNetworkAsTheInventoryHoldsIt(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
t.Setenv(OverlayCIDRVar, "10.99.0.0/16")
|
||||
t.Setenv("MESH_INTERNAL_SUFFIX", "mesh.test")
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
holdings, err := inv.Holdings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(in.Holdings, holdings) {
|
||||
t.Errorf("Holdings are %v, the inventory's %v", in.Holdings, holdings)
|
||||
}
|
||||
tunnels, err := inv.Tunnels(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tunnels) != len(in.Tunnels) {
|
||||
t.Errorf("Tunnels are %v, the inventory's %v", in.Tunnels, tunnels)
|
||||
}
|
||||
if _, hub, _, err := inv.AdoptedTunnel(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if (hub == "") != (in.Adopted == nil) {
|
||||
t.Errorf("Adopted is %v, the inventory's hub is %q", in.Adopted, hub)
|
||||
}
|
||||
carried, err := inv.CarriedPeers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(carried) != len(in.CarriedPeers) {
|
||||
t.Errorf("CarriedPeers are %v, the inventory's %v", in.CarriedPeers, carried)
|
||||
}
|
||||
joining, err := inv.NodesWithALiveToken(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(joining) != len(in.Joining) {
|
||||
t.Errorf("Joining is %v, the inventory's %v", in.Joining, joining)
|
||||
}
|
||||
if in.OverlayCIDR != "10.99.0.0/16" || in.InternalSuffix != "mesh.test" {
|
||||
t.Errorf("the range and the suffix are %q and %q", in.OverlayCIDR, in.InternalSuffix)
|
||||
}
|
||||
// Read once: what the environment says afterwards moves nothing already read.
|
||||
t.Setenv(OverlayCIDRVar, "10.1.0.0/16")
|
||||
if in.OverlayCIDR != "10.99.0.0/16" {
|
||||
t.Error("the range moved with the environment")
|
||||
}
|
||||
}
|
||||
|
||||
// The lead's equality finding (build 1792487777757012513): a provider on one machine serves a fact that says ${setting:domain},
|
||||
// the setting is set for it, and a consumer on another machine takes that fact. The first pass of the adapter resolved every
|
||||
// node with no settings in the Input, so the served fact could not be settled and the whole Input failed. The settings are in
|
||||
// the Input before any node is resolved, and the Input builds, with the same plan for the consumer as the controller's own
|
||||
// composition. It failed first, with the finding's words.
|
||||
func TestAServedFactThatSaysASettingIsResolvedWithTheSettingInTheInput(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"acme-ca": {"domain": "${setting:domain}"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1", Requires: []string{"acme-ca"}})
|
||||
// The setting is there before the machines are assigned: an assignment resolves the mesh, and a served fact that says
|
||||
// ${setting:domain} resolves only with it.
|
||||
if err := open.inventory.KeepSettings(ctx, "", "step-ca", map[string]any{"domain": "example.test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "route-proxy"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatalf("the Input cannot be built: %v", err)
|
||||
}
|
||||
if anchor, _ := nodeOf(in.Input, "anchor"); len(anchor.Settings["step-ca"]) == 0 {
|
||||
t.Fatalf("the Input does not carry the setting of the provider: %v", anchor.Settings)
|
||||
}
|
||||
oldPlan, _, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newPlan, _, err := resolveNode(withInputCache(ctx), open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("the consumer does not resolve: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(oldPlan.Needs, newPlan.Needs) {
|
||||
t.Errorf("the consumer's needs are %+v, the controller's own %+v", newPlan.Needs, oldPlan.Needs)
|
||||
}
|
||||
t.Logf("a served fact that says a setting resolves with the setting in the Input: the Input builds and the consumer's needs equal planFor's")
|
||||
}
|
||||
|
||||
// A setting that nothing sets, in a fact a provider serves, is not the adapter's to hide: the controller's own composition
|
||||
// refuses it as well (theRestOfTheMesh returns servedOnNode's error), and the adapter refuses with the same words rather than
|
||||
// building an Input that composes a placeholder.
|
||||
func TestAServedFactThatSaysAnUnsetSettingIsRefusedAsTheControllerRefusesIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"acme-ca": {"domain": "${setting:domain}"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1", Requires: []string{"acme-ca"}})
|
||||
// Assigned with the setting there, then the setting is cleared: the mesh the controller holds when someone clears it.
|
||||
if err := open.inventory.KeepSettings(ctx, "", "step-ca", map[string]any{"domain": "example.test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "route-proxy"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.ClearSettings(ctx, "", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, oldErr := planFor(ctx, open, "laptop")
|
||||
_, newErr := readComposeInput(ctx, open)
|
||||
if (oldErr == nil) != (newErr == nil) {
|
||||
t.Fatalf("the controller's composition says %v, the adapter says %v", oldErr, newErr)
|
||||
}
|
||||
if oldErr != nil && !strings.Contains(newErr.Error(), "nothing sets \"domain\"") {
|
||||
t.Errorf("the adapter's words are %q, the controller's %q", newErr, oldErr)
|
||||
}
|
||||
t.Logf("an unset setting in a served fact is refused alike: the controller says %v, the adapter says %v", oldErr != nil, newErr != nil)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
)
|
||||
|
||||
// 1382/AC5: composing every node of one operation builds the Input once; without an operation's cache, once for each.
|
||||
func TestComposingEveryNodeOfAnOperationBuildsTheInputOnce(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
built := 0
|
||||
was := composeInputBuilt
|
||||
composeInputBuilt = func() { built++ }
|
||||
t.Cleanup(func() { composeInputBuilt = was })
|
||||
ctx := withInputCache(t.Context())
|
||||
nodes, err := open.inventory.Nodes(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if _, _, err := resolveNode(ctx, open, n.Name); err != nil && !unresolvable(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := composeQuestion(ctx, open, n.Name, compose.Reading, false); err != nil && !unresolvable(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := generatorsOf(ctx, open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if built != 1 {
|
||||
t.Fatalf("an operation over %d node(s) built the Input %d time(s)", len(nodes), built)
|
||||
}
|
||||
built = 0
|
||||
if _, _, err := resolveNode(t.Context(), open, nodes[0].Name); err != nil && !unresolvable(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if built != 1 {
|
||||
t.Fatalf("a composition with no cache built the Input %d time(s)", built)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// perform makes what a composition named foreseen (novox/hq ADR 0453 clause 3, design 47 R4b, issue 1385).
|
||||
//
|
||||
// **The composition makes nothing; a send does.** mesh-sdk's composition over an Input names what the next send would make
|
||||
// (the ports it would assign, the credentials it would seal, the core boxes it would ask the vault for, the seats it would
|
||||
// pass on, the certificate it would issue) and stands in for each. A send composes, performs, reads the stores again and
|
||||
// composes again (send in compose_sdk.go), and refuses to send a declaration that still names anything foreseen. A question
|
||||
// (status, facts, probes, the merge gate's reading) never reaches this file.
|
||||
//
|
||||
// What is made is exactly what the controller's own composition made on the way to a declaration it was going to send:
|
||||
// - every pair credential no row holds, for every machine that resolves, since planning one node resolved them all;
|
||||
// - the seats the mesh passes on, with the hand act (not on a throwaway mesh, whose hand act has nowhere to go);
|
||||
// - the machine ports the node's modules were given none for;
|
||||
// - the node's own secrets, by the controller's own rules (a bus credential nobody issued was refused by the composition);
|
||||
// - the core credentials, asked of the vault every send, so a rotated one reaches the machine;
|
||||
// - the certificate of a machine that was never certified.
|
||||
//
|
||||
// perform returns whether it made or recorded anything: the reason the caller reads the stores again.
|
||||
func perform(ctx context.Context, open *stores, in *meshInput, node string, d compose.Declared) (bool, error) {
|
||||
did := false
|
||||
inv := open.inventory
|
||||
|
||||
// The seats passed on: recorded in the store with the controller's own hand act (ADR 0449 §4, issue 1317).
|
||||
if len(d.Passes.Passes) > 0 {
|
||||
var w io.Writer = os.Stderr
|
||||
if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok {
|
||||
w = lw
|
||||
}
|
||||
for _, line := range d.Passes.Said {
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
sp := seatPassingOn(inv, w)
|
||||
for _, p := range d.Passes.Passes {
|
||||
moved, err := inv.PassSeat(ctx, p.Seat.Name, p.Seat.Scope, p.From, p.To)
|
||||
if err != nil {
|
||||
// Said, and the record stays as it was: a composition is never refused for it.
|
||||
fmt.Fprintf(w, "%s could not be passed from %s on %s to %s on %s: %v\n", p.Seat.Name,
|
||||
p.From.Module, p.From.Node, p.To.Module, p.To.Node, err)
|
||||
continue
|
||||
}
|
||||
if !moved {
|
||||
continue // another composition passed it first, and wrote the act
|
||||
}
|
||||
did = true
|
||||
if inv.Throwaway() {
|
||||
continue
|
||||
}
|
||||
if err := sp.act(ctx, p); err != nil {
|
||||
fmt.Fprintf(w, "%s passed from %s on %s to %s on %s, and the hand act could NOT be recorded: %v\n",
|
||||
p.Seat.Name, p.From.Module, p.From.Node, p.To.Module, p.To.Node, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every pair credential the stores hold none for: made once and kept, and sealed to both ends before it was ever written
|
||||
// down. The error is the one planFor met: the machine that needs it, and why none could be made.
|
||||
for _, p := range in.wouldMake {
|
||||
if err := makePair(ctx, inv, p); err != nil {
|
||||
return did, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %w",
|
||||
p.ConsumerModule, p.Consumer, p.Name, p.Provider, err)
|
||||
}
|
||||
did = true
|
||||
}
|
||||
|
||||
// The ports this node's modules were given none for.
|
||||
if len(d.ForeseenPorts) > 0 {
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return did, err
|
||||
}
|
||||
for _, one := range d.ForeseenPorts {
|
||||
module, port, ok := strings.Cut(one, ":")
|
||||
wanted, perr := strconv.Atoi(port)
|
||||
if !ok || perr != nil {
|
||||
return did, fmt.Errorf("the composition named a port the controller cannot read: %q", one)
|
||||
}
|
||||
fixed := false
|
||||
for _, l := range nodeShelf(in, shelf, node)[module].Listens {
|
||||
if l.Port == wanted {
|
||||
fixed = l.Fixed
|
||||
}
|
||||
}
|
||||
if _, err := inv.PortFor(ctx, node, module, wanted, fixed); err != nil {
|
||||
return did, fmt.Errorf("%s needs %d reachable on %s and it could not be assigned: %w", module, wanted, node, err)
|
||||
}
|
||||
did = true
|
||||
}
|
||||
}
|
||||
|
||||
// The node's own secrets: a module's superuser password, an administrator, an account. The composition refused what the
|
||||
// send refuses (a bus credential nobody issued, a given value under an old key).
|
||||
for _, one := range d.StoodIn {
|
||||
module, name, ok := strings.Cut(one, "/")
|
||||
if !ok || strings.Contains(one, " ") {
|
||||
continue // a grant's sealed copy, the certificate or the bus's list: made below or by nobody
|
||||
}
|
||||
if _, err := inv.SecretForModule(ctx, node, module, name); err != nil {
|
||||
return did, err
|
||||
}
|
||||
did = true
|
||||
}
|
||||
|
||||
// The certificate of a machine that was never certified: issued and kept, with the authority when there is none yet.
|
||||
for _, one := range d.StoodIn {
|
||||
if strings.HasPrefix(one, "the certificate and the authority of ") {
|
||||
if _, _, err := certificateFor(ctx, open, node); err != nil {
|
||||
return did, err
|
||||
}
|
||||
did = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return did, nil
|
||||
}
|
||||
|
||||
// makePair makes one pair credential by the controller's own read of it: the credential for the pair, or the provider's one
|
||||
// credential sealed to this consumer too. A credential only a person gives is never made (it is not a would-make).
|
||||
func makePair(ctx context.Context, inv *inventory.Inventory, p inventory.PairCredential) error {
|
||||
var err error
|
||||
switch p.Kind {
|
||||
case inventory.PairShared:
|
||||
_, err = inv.SharedSecretFor(ctx, p.Name, p.Consumer, p.ConsumerModule, p.Provider, p.ProviderModule, p.Local, p.Own)
|
||||
case inventory.PairPlain:
|
||||
_, err = inv.SecretFor(ctx, p.Name, p.Consumer, p.ConsumerModule, p.Provider, p.Local)
|
||||
default:
|
||||
return errors.New("a credential only a person gives is not made")
|
||||
}
|
||||
if inventory.IsNotGiven(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// stillForeseen says what a composed declaration still stands in for: the lists the composition names, and the stand-in itself
|
||||
// anywhere in the body (a pair credential's stand-in is not in any list). A declaration that has any is never sent.
|
||||
func stillForeseen(d compose.Declared) []string {
|
||||
var left []string
|
||||
left = append(left, d.Foreseen...)
|
||||
left = append(left, d.StoodIn...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
left = append(left, "the port "+p)
|
||||
}
|
||||
for _, c := range d.CoreBoxesWanted {
|
||||
left = append(left, "the vault's box for "+c.Module+"/"+c.Name)
|
||||
}
|
||||
for _, p := range d.Passes.Passes {
|
||||
left = append(left, "the pass of "+p.Seat.Name+" to "+p.To.Module+" on "+p.To.Node)
|
||||
}
|
||||
if body, err := d.Body(); err == nil {
|
||||
for _, marker := range []string{catalogue.ForeseenSealed, "asked of the vault by the next send", compose.ForeseenCertificate,
|
||||
compose.ForeseenBusUsers} {
|
||||
if strings.Contains(string(body), marker) {
|
||||
left = append(left, "a stand-in: "+marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(left)
|
||||
return slicesCompact(left)
|
||||
}
|
||||
|
||||
func slicesCompact(in []string) []string {
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if len(out) == 0 || out[len(out)-1] != s {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeShelf is the shelf as one node composes its own set: the current one with each module it keeps at the build it runs put
|
||||
// in place of the current.
|
||||
func nodeShelf(in *meshInput, shelf map[string]catalogue.Manifest, node string) map[string]catalogue.Manifest {
|
||||
n, _ := nodeOf(in.Input, node)
|
||||
return shelfWith(shelf, n.Kept)
|
||||
}
|
||||
|
||||
// prepareSend is what a send does before it composes, every send and whatever the composition would have named: the
|
||||
// controller's own composition did it on the way, so a module's file that names an own secret composes (mesh-sdk's
|
||||
// composition can stand in for none: a file naming a secret not made yet is refused in every mode), and the core's credentials
|
||||
// are asked of the vault on every send, so a rotated one reaches the machine and a silent vault is said (core_box.go).
|
||||
//
|
||||
// - Each own secret of a module the node runs (not one handed to the provisioner, not one issued outside the mesh, not one a
|
||||
// person gives) that no row holds is made, unless the composition would refuse it (a bus credential nobody issued): that
|
||||
// refusal is the composition's to say, in its words.
|
||||
// - The core's credentials are asked of the vault, the boxes newly answered are kept, and the modules the vault did not answer
|
||||
// for with no box kept are returned: the controller seals those itself until the vault holds them (ADR 0396).
|
||||
func prepareSend(ctx context.Context, open *stores, node string) ([]string, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.refused[node] != nil {
|
||||
return nil, nil // empty-on-error: refused alone for what it keeps; the composition that follows says so, in its words
|
||||
}
|
||||
resolved, err := compose.Resolve(in.Input, node)
|
||||
if err != nil {
|
||||
return nil, nil // empty-on-error: it does not compose for its own reasons; the composition that follows says them
|
||||
}
|
||||
n, _ := nodeOf(in.Input, node)
|
||||
hands := n.Stated && n.Contract >= compose.HandedContract
|
||||
handing := catalogue.Rendering{Hands: hands, Provisioner: provisionerAccount(resolved.Plan)}
|
||||
inv := open.inventory
|
||||
did := false
|
||||
if n.SealingKey != "" {
|
||||
held := map[string]bool{}
|
||||
for _, r := range in.OwnSecrets {
|
||||
if r.Node == node {
|
||||
held[r.Module+"/"+r.Name] = true
|
||||
}
|
||||
}
|
||||
for _, m := range resolved.Plan.Modules {
|
||||
if handing.Handing(m) {
|
||||
continue
|
||||
}
|
||||
plain := m.OwnSecrets.Plain()
|
||||
names := make([]string, 0, len(plain))
|
||||
for name := range plain {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if plain[name].IssuedBy == catalogue.IssuedOutside || held[m.Module+"/"+name] {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "broker":
|
||||
if !slices.Contains(in.BusMinted, node+"."+m.Module) {
|
||||
continue
|
||||
}
|
||||
case compose.ProvisionerSecret:
|
||||
if !slices.Contains(in.BusMinted, "provisioner."+node) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := inv.SecretForModule(ctx, node, m.Module, name); err == nil {
|
||||
did = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var byController []string
|
||||
if n.Stated && n.Contract >= compose.VaultBoxContract {
|
||||
boxes, err := coreBoxesFor(ctx, inv, node, resolved.Plan.Modules, func(m catalogue.Manifest) bool {
|
||||
return handing.Handing(m)
|
||||
}, Allocating)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
did = did || len(boxes.keep) > 0
|
||||
byController = boxes.byController
|
||||
}
|
||||
if did {
|
||||
// What was made is in the stores now: the composition reads them afresh.
|
||||
forgetInput(ctx)
|
||||
}
|
||||
return byController, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
func aWebModule() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "web", Version: "1",
|
||||
Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromEverywhere}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"admin": {Path: "/etc/web/admin"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"},
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)},
|
||||
}}
|
||||
}
|
||||
|
||||
// 1385/AC1: a port the composition names foreseen is allocated with the inventory's PortFor for that module and port, a second
|
||||
// composition names it no more, and the number is the one the controller's own Allocating composition gives in the same store.
|
||||
func TestPerformAllocatesAForeseenPortAndTheNumberIsTheControllers(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, aWebModule())
|
||||
if _, err := assign(ctx, open, "laptop", "web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" {
|
||||
t.Fatalf("the composition names the ports %v foreseen, want web:8080", d.ForeseenPorts)
|
||||
}
|
||||
did, err := perform(ctx, open, in, "laptop", d)
|
||||
if err != nil || !did {
|
||||
t.Fatalf("perform did %v: %v", did, err)
|
||||
}
|
||||
held, err := open.inventory.PortsFor(ctx, "laptop")
|
||||
if err != nil || len(held) != 1 || held[0].Module != "web" || held[0].Wanted != 8080 {
|
||||
t.Fatalf("the port perform allocated reads %+v: %v", held, err)
|
||||
}
|
||||
in, err = readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing); err != nil || len(again.ForeseenPorts) != 0 {
|
||||
t.Fatalf("a second composition names %v foreseen: %v", again.ForeseenPorts, err)
|
||||
}
|
||||
// The number is the one the controller allocates in the same store.
|
||||
plan, settings, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := declarationFor(ctx, open, "laptop", plan, settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := portsOf(after.Resources), portsOf(old.Resources); got != want {
|
||||
t.Fatalf("the declaration publishes %s; the controller's allocation publishes %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func portsOf(resources []map[string]any) string {
|
||||
var out []string
|
||||
for _, r := range resources {
|
||||
if r["type"] == "container" {
|
||||
out = append(out, fmt.Sprint(r["name"], r["ports"]))
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ";")
|
||||
}
|
||||
|
||||
// 1385/AC2: an own secret named foreseen is made with SecretForModule, and a pair credential the Input names would-make is
|
||||
// made as planFor chose; both exist after the send's composition and a second composition names neither.
|
||||
func TestPerformMakesTheOwnSecretsAndThePairCredentialsTheCompositionNamed(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, aWebModule())
|
||||
register(t, open, aConsumerOfTheBucket("reports"))
|
||||
for _, m := range []string{"web", "reports"} {
|
||||
if _, err := assign(ctx, open, "laptop", m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before, err := madeIn(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared, err := composeSend(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("the send is refused: %v", err)
|
||||
}
|
||||
if len(declared.foreseen) != 0 {
|
||||
t.Fatalf("the declaration still names %v foreseen", declared.foreseen)
|
||||
}
|
||||
after, err := madeIn(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
madeNow := func(have, was []string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, s := range was {
|
||||
seen[s] = true
|
||||
}
|
||||
var out []string
|
||||
for _, s := range have {
|
||||
if !seen[s] {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := madeNow(after.OwnSecrets, before.OwnSecrets); len(got) != 1 || !strings.Contains(got[0], "web/admin") {
|
||||
t.Errorf("the own secrets made are %v, want laptop web/admin", got)
|
||||
}
|
||||
if got := madeNow(after.Pairs, before.Pairs); len(got) != 1 || !strings.Contains(got[0], "reports") {
|
||||
t.Errorf("the pair credentials made are %v, want the one of reports", got)
|
||||
}
|
||||
// And the credential is the one a second composition reads as held.
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(in.wouldMake) != 0 {
|
||||
t.Errorf("after the send the next send would still make %v", in.wouldMake)
|
||||
}
|
||||
if _, err := inv.SecretsFrom(ctx, "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1385/AC3 (the lead's answer 1): with the vault unreachable and no box of the credential kept, the send carries the
|
||||
// controller's own seal for it (ADR 0396), says so as a condition, and keeps no box.
|
||||
func TestAVaultThatIsSilentSealsTheSendByTheControllerWhereNoBoxIsKept(t *testing.T) {
|
||||
open, vault := aCoreMesh(t, link.VaultBoxContract)
|
||||
vault.silent = errors.New("no responders")
|
||||
declared, err := composeSend(t.Context(), open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("the send is refused: %v", err)
|
||||
}
|
||||
f := clientsBroker(declared.Resources)
|
||||
if f == nil || f["sealed"] == nil || f[catalogue.VaultSealedField] != nil {
|
||||
t.Fatalf("the core credential is composed as %v: the controller's own seal, until the vault holds it", f)
|
||||
}
|
||||
if len(declared.foreseen) != 0 {
|
||||
t.Fatalf("the declaration still names %v foreseen", declared.foreseen)
|
||||
}
|
||||
if c, found := vaultSilence(t, "laptop"); !found || !strings.Contains(c.Summary, "sealed by the controller until the vault holds it") {
|
||||
t.Fatalf("no condition says why: %+v", c)
|
||||
}
|
||||
kept, err := open.inventory.KeptCoreBoxes(t.Context(), "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(kept["mesh-provisioner-client"]) != 0 {
|
||||
t.Fatalf("a box was kept for a credential the vault never boxed: %v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// 1385/AC6: perform is idempotent: a second send's composition over the stores it left makes and records nothing.
|
||||
func TestAFinishedSendMakesNothingTheSecondTime(t *testing.T) {
|
||||
open, _ := aMeshWithSecrets(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, aWebModule())
|
||||
if _, err := assign(ctx, open, "laptop", "web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := composeSend(ctx, open, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := tableState(t, open.inventory)
|
||||
if _, err := composeSend(ctx, open, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range tableState(t, open.inventory) {
|
||||
if before[k] != v {
|
||||
t.Errorf("a second send of the same machine moved %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// The controller's composition on mesh-sdk's (novox/hq ADR 0453 clause 3, design 47 R4b, issues 1386 to 1388): every
|
||||
// caller that planned or composed a node reads the Input once for its operation (compose_input.go), asks the SDK to
|
||||
// compose over it, and, when it sends, performs what the composition names foreseen (compose_perform.go) and composes again.
|
||||
|
||||
// inputCacheKey carries an operation's Input in its context, so the planning of every node of one verb reads the stores once.
|
||||
type inputCacheKey struct{}
|
||||
|
||||
type inputCache struct {
|
||||
mu sync.Mutex
|
||||
by map[string]*meshInput
|
||||
}
|
||||
|
||||
// withInputCache is ctx for an operation: the Input read for it is kept until a send changes what it read. A context that
|
||||
// carries none reads the stores for each composition, which is correct and slow.
|
||||
func withInputCache(ctx context.Context) context.Context {
|
||||
if _, ok := ctx.Value(inputCacheKey{}).(*inputCache); ok {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, inputCacheKey{}, &inputCache{by: map[string]*meshInput{}})
|
||||
}
|
||||
|
||||
// freshInput is ctx for one composition whose Input is read afresh and kept only until the composition makes something: a
|
||||
// send that is composed after another one made and recorded things (a push's machines, one after the other).
|
||||
func freshInput(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, inputCacheKey{}, &inputCache{by: map[string]*meshInput{}})
|
||||
}
|
||||
|
||||
// keptSignature is what the Input read depends on in ctx: which recorded modules each machine keeps at the build it runs
|
||||
// (recorded_kept.go), so an Input read for a person's push is never taken for one read for a gated send.
|
||||
func keptSignature(ctx context.Context) string {
|
||||
skip, on := keptExcept(ctx)
|
||||
var names []string
|
||||
for m := range skip {
|
||||
names = append(names, m)
|
||||
}
|
||||
sort.Strings(names)
|
||||
every, _ := ctx.Value(keepEveryBuildKey{}).(string)
|
||||
settings, _ := ctx.Value(keepForSettingsKey{}).(string)
|
||||
return fmt.Sprintf("kept=%v;except=%v;every=%s;settings=%s", on, names, every, settings)
|
||||
}
|
||||
|
||||
// inputFor is the Input for ctx's operation.
|
||||
func inputFor(ctx context.Context, open *stores) (*meshInput, error) {
|
||||
cache, ok := ctx.Value(inputCacheKey{}).(*inputCache)
|
||||
if !ok {
|
||||
return readComposeInput(ctx, open)
|
||||
}
|
||||
sig := keptSignature(ctx)
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
if in := cache.by[sig]; in != nil {
|
||||
return in, nil
|
||||
}
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cache.by[sig] = in
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// forgetInput drops what the operation read, because a send made or recorded something it read.
|
||||
func forgetInput(ctx context.Context) {
|
||||
if cache, ok := ctx.Value(inputCacheKey{}).(*inputCache); ok {
|
||||
cache.mu.Lock()
|
||||
cache.by = map[string]*meshInput{}
|
||||
cache.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// resolveNode works out everything a node should run, from what was assigned to it (planFor on the SDK): over the Input,
|
||||
// writing nothing. A failure to compose the node's own modules is marked notResolvable, as planFor marked it; every other
|
||||
// failure is returned as it is.
|
||||
func resolveNode(ctx context.Context, open *stores, node string) (catalogue.Resolution, catalogue.SettingsBy, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
if err := in.refused[node]; err != nil {
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
resolved, err := compose.Resolve(in.Input, node)
|
||||
if err != nil {
|
||||
if compose.IsUnresolvable(err) {
|
||||
return catalogue.Resolution{}, nil, notResolvable{err}
|
||||
}
|
||||
return catalogue.Resolution{}, nil, err
|
||||
}
|
||||
logUnheld(node, resolved.Plan.Unheld)
|
||||
return resolved.Plan, resolved.Settings, nil
|
||||
}
|
||||
|
||||
// generatorsOf is what every node's declaration is composed with, over the whole mesh (generators on the SDK).
|
||||
func generatorsOf(ctx context.Context, open *stores) (map[string]catalogue.Generator, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compose.Generators(in.Input)
|
||||
}
|
||||
|
||||
// whoResolvesOn is the machines whose resolution answers a requirement, and why the others did not (whoResolves over the
|
||||
// Input): by what a module provides, not by its name. A machine whose set does not compose is in refused, said; one the mesh
|
||||
// could not be asked about is an error, never a refusal (novox/hq 04-ISSUES/152).
|
||||
func whoResolvesOn(ctx context.Context, open *stores, requirement string) (map[string]bool, map[string]string, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
on := map[string]bool{}
|
||||
refused := map[string]string{}
|
||||
for _, n := range in.sortedNodes() {
|
||||
if err := in.refused[n.Name]; err != nil {
|
||||
return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err)
|
||||
}
|
||||
resolved, err := compose.Resolve(in.Input, n.Name)
|
||||
switch {
|
||||
case compose.IsUnresolvable(err):
|
||||
refused[n.Name] = err.Error()
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err)
|
||||
}
|
||||
for _, m := range resolved.Plan.Modules {
|
||||
for _, offered := range m.Offers() {
|
||||
if offered == requirement {
|
||||
on[n.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return on, refused, nil
|
||||
}
|
||||
|
||||
// sortedNodes is the Input's nodes by name, the order the controller reads them in.
|
||||
func (in *meshInput) sortedNodes() []compose.Node {
|
||||
out := append([]compose.Node(nil), in.Nodes...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// composeQuestion composes a node's declaration as a question asks it: nothing is made, and what a send would make is a
|
||||
// stand-in (Foreseeing) or left out (Reading). It also answers what the node-engine's validator refuses of it. The lines the
|
||||
// composition found are said where the controller says its own (standard error, or the writer an act gave).
|
||||
//
|
||||
// withOrder says the declaration carries the order it was last sent under (composedAndValidated's, which a comparison with the
|
||||
// last send needs); without it the order is none, as declarationWith left it for the sender to stamp.
|
||||
func composeQuestion(ctx context.Context, open *stores, node string, mode compose.Mode, withOrder bool) (sendable, []string, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
if err := in.refused[node]; err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
d, problems, err := compose.ComposeIn(in.Input, node, mode)
|
||||
if err != nil {
|
||||
return sendable{}, nil, markUnresolvable(err)
|
||||
}
|
||||
if err := refusedBus(in, node); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
sayFound(ctx, d)
|
||||
out := sendableOf(d)
|
||||
if !withOrder {
|
||||
out.Sequence, out.Epoch, out.Generation = 0, 0, 0
|
||||
}
|
||||
return out, problems, nil
|
||||
}
|
||||
|
||||
// refusedBus is why the node cannot be composed because it runs the bus and the bus's user list cannot be: no user has a
|
||||
// credential, so the list would refuse every connection in the mesh (busUserList). The controller's own composition refused that
|
||||
// machine with those words; no other machine is refused for it. nil when nothing refuses the node.
|
||||
func refusedBus(in *meshInput, node string) error {
|
||||
if in.busRefused == nil {
|
||||
return nil
|
||||
}
|
||||
resolved, err := compose.Resolve(in.Input, node)
|
||||
if err != nil {
|
||||
return nil // it does not compose for its own reasons, which are said
|
||||
}
|
||||
for _, m := range resolved.Plan.Modules {
|
||||
if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
|
||||
return in.busRefused
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markUnresolvable is an error of a node whose own set does not compose, as planFor marked it.
|
||||
func markUnresolvable(err error) error {
|
||||
if compose.IsUnresolvable(err) {
|
||||
return notResolvable{err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// composeSend composes a node's declaration to send it, as the Allocating composition did: composed over what is read,
|
||||
// then what it named foreseen is made (perform), the stores are read again and it is composed again. A declaration that
|
||||
// still names anything foreseen is refused, said, and never sent.
|
||||
func composeSend(ctx context.Context, open *stores, node string) (sendable, error) {
|
||||
declared, _, err := composeSendValidated(ctx, open, node)
|
||||
return declared, err
|
||||
}
|
||||
|
||||
// composeSendValidated is composeSend and what the node-engine's validator refuses of the declaration it composed, for the
|
||||
// merge gate's two passes (composedAndValidated in its Allocating mode).
|
||||
func composeSendValidated(ctx context.Context, open *stores, node string) (sendable, []string, error) {
|
||||
// The core modules whose credentials the controller sealed itself because the vault was silent and no box was kept
|
||||
// (ADR 0396): the composition stands in a vault box for each, and a send carries the controller's own seal instead.
|
||||
byController, err := prepareSend(ctx, open, node)
|
||||
if err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
for attempt := 0; ; attempt++ {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
if err := in.refused[node]; err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
d, problems, err := compose.ComposeIn(in.Input, node, compose.Foreseeing)
|
||||
if err != nil {
|
||||
return sendable{}, nil, markUnresolvable(err)
|
||||
}
|
||||
if err := refusedBus(in, node); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
if len(byController) > 0 {
|
||||
if d, problems, err = sealedByTheController(in, node, d, byController); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
}
|
||||
left := stillForeseen(d)
|
||||
resolved, rerr := compose.Resolve(in.Input, node)
|
||||
if rerr != nil {
|
||||
return sendable{}, nil, markUnresolvable(rerr)
|
||||
}
|
||||
if len(resolved.Foreseen) > 0 {
|
||||
left = append(left, resolved.Foreseen...)
|
||||
}
|
||||
if len(left) == 0 && len(in.wouldMake) == 0 {
|
||||
sayFound(ctx, d)
|
||||
out := sendableOf(d)
|
||||
// The sender stamps the order it allots (order.stamp); a composition for a send carries none of its own.
|
||||
out.Sequence, out.Epoch, out.Generation = 0, 0, 0
|
||||
here, _ := nodeOf(in.Input, node)
|
||||
out.Builds = buildsOf(in.Input, here, resolved.Plan, d.LeftOutWhy)
|
||||
out.Bindings = boundToData(resolved.Plan, d.LeftOutWhy)
|
||||
if len(d.Filled) > 0 {
|
||||
if out.plans, err = handedPlans(in, node, resolved); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
}
|
||||
return out, problems, nil
|
||||
}
|
||||
if attempt > 0 {
|
||||
return sendable{}, nil, fmt.Errorf("%s was composed twice and still names what only a send makes (%s): nothing is sent "+
|
||||
"that stands in for a value", node, strings.Join(left, "; "))
|
||||
}
|
||||
if _, err := perform(ctx, open, in, node, d); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
// What was made is in the stores now: the next read is a fresh one.
|
||||
forgetInput(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// restOfTheMesh is what every other machine holds and offers (theRestOfTheMesh over the Input), for a question: the lines it
|
||||
// finds are said where the controller says its own, and a seat it would pass on is passed by the next send (perform), not by
|
||||
// the question. shelf, when given, replaces the Input's catalogue: a caller composing a change's shelf.
|
||||
func restOfTheMesh(ctx context.Context, open *stores, shelf map[string]catalogue.Manifest, exclude string) (catalogue.World, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return catalogue.World{}, err
|
||||
}
|
||||
input := in.Input
|
||||
if shelf != nil {
|
||||
input.Catalogue = shelf
|
||||
}
|
||||
world, passes, left, err := compose.TheRestOfTheMesh(input, exclude)
|
||||
if err != nil {
|
||||
return catalogue.World{}, err
|
||||
}
|
||||
var w io.Writer = os.Stderr
|
||||
if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok {
|
||||
w = lw
|
||||
}
|
||||
for _, l := range left {
|
||||
fmt.Fprintln(w, l.String())
|
||||
}
|
||||
for _, line := range passes.Said {
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
return world, nil
|
||||
}
|
||||
|
||||
// restOfTheMeshOf is restOfTheMesh for a caller that holds the inventory alone: the other stores the Input reads are opened for
|
||||
// the call and let go of again.
|
||||
func restOfTheMeshOf(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest, exclude string) (catalogue.World, error) {
|
||||
open := &stores{inventory: inv}
|
||||
defer func() {
|
||||
if open.licences != nil {
|
||||
open.licences.Close()
|
||||
}
|
||||
if open.identity != nil {
|
||||
open.identity.Close()
|
||||
}
|
||||
}()
|
||||
return restOfTheMesh(ctx, open, shelf, exclude)
|
||||
}
|
||||
|
||||
// sayFound says the lines a composition found while composing, once each: a machine left out of the rest of the mesh, a seat
|
||||
// that two claimants could take, the bus's users left out for want of a credential.
|
||||
func sayFound(ctx context.Context, d compose.Declared) {
|
||||
if len(d.Said) == 0 {
|
||||
return
|
||||
}
|
||||
var w io.Writer = os.Stderr
|
||||
if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok {
|
||||
w = lw
|
||||
}
|
||||
for _, line := range d.Said {
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
}
|
||||
|
||||
// sendableOf is a composed declaration as the controller's sends know it.
|
||||
func sendableOf(d compose.Declared) sendable {
|
||||
out := sendable{Resources: d.Resources, Sequence: d.Sequence, Epoch: d.Epoch, Generation: d.Generation,
|
||||
ReadsModules: d.ReadsModules, moduleOf: d.ModuleOf, buildIDs: d.BuildIDs, modules: d.Modules,
|
||||
localNetwork: d.LocalNetwork, Received: d.Received, Mesh: d.Mesh, BusUsers: d.BusUsers,
|
||||
LeftOut: d.LeftOut, leftOutWhy: d.LeftOutWhy, withheld: d.Withheld, unbound: d.Unbound, foreseen: d.Foreseen,
|
||||
unplaced: d.Unplaced, handedOnly: d.HandedOnly, Filled: d.Filled, Provisioner: d.Provisioner,
|
||||
requiredBy: d.RequiredBy}
|
||||
if d.Adoption != nil {
|
||||
out.Adoption = &adoptionEnvelope{Taken: d.Adoption.Taken, Untaken: d.Adoption.Untaken}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildsOf is the build of each module a declaration for the node carries, as a send records it (novox/hq issue 259): the
|
||||
// module's current build for each module in it, the build it was last sent for one left out of it, and a module kept at the
|
||||
// build the machine runs carried at that one (ADR 0245). Never nil.
|
||||
func buildsOf(in compose.Input, node compose.Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string {
|
||||
before := node.SentBuilds
|
||||
if !node.SentKnown {
|
||||
before = nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, m := range plan.Modules {
|
||||
if _, left := leftOut[m.Module]; left {
|
||||
if was, kept := before[m.Module]; kept {
|
||||
out[m.Module] = was
|
||||
}
|
||||
continue
|
||||
}
|
||||
out[m.Module] = in.CurrentBuilds[m.Module]
|
||||
}
|
||||
for m, was := range node.KeptBuilds {
|
||||
if _, carried := out[m]; carried {
|
||||
out[m] = was
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handedPlans is the plan of each module handed to the provisioner, which the assignments mirror keeps with the send (design
|
||||
// 57 P6): the composition's declaration does not carry it, so it is composed from the same rendering again.
|
||||
func handedPlans(in *meshInput, node string, resolved compose.Resolved) (map[string]catalogue.Plan, error) {
|
||||
rendered, err := compose.Render(in.Input, node, compose.Foreseeing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gens, err := compose.Generators(in.Input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
with := rendered.Rendering
|
||||
with.Generators = gens
|
||||
composed, err := resolved.Plan.Compose(with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return composed.Plans, nil
|
||||
}
|
||||
|
||||
// sealedByTheController is the declaration with the controller's own seal in place of the vault's box for each core module the
|
||||
// vault did not answer for and no box was kept for (core_box.go, ADR 0396): the file carries "sealed", as the controller's
|
||||
// composition sent it, until the vault holds the credential. What the composition stood in for the box is replaced and no longer
|
||||
// wanted; the validator is run again over the body.
|
||||
func sealedByTheController(in *meshInput, node string, d compose.Declared, modules []string) (compose.Declared, []string, error) {
|
||||
rendered, err := compose.Render(in.Input, node, compose.Foreseeing)
|
||||
if err != nil {
|
||||
return d, nil, err
|
||||
}
|
||||
stood, _ := compactBox(catalogue.ForeseenVaultBox)
|
||||
var wanted []compose.CoreBoxWanted
|
||||
var foreseen []string
|
||||
for _, w := range d.CoreBoxesWanted {
|
||||
if !slices.Contains(modules, w.Module) {
|
||||
wanted = append(wanted, w)
|
||||
continue
|
||||
}
|
||||
sealed := rendered.Rendering.Needed[w.Module][w.Name]
|
||||
if sealed == "" {
|
||||
return d, nil, fmt.Errorf("%s/%s on %s has no seal of the controller's to carry until the vault holds it", w.Module, w.Name, node)
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
box, isBox := r[catalogue.VaultSealedField]
|
||||
// By the resource's id alone: its path is the manifest's with the machine's words filled in, and so is not the
|
||||
// manifest's own.
|
||||
if !isBox || r["id"] != w.Module+"."+catalogue.NeedID(w.Name) {
|
||||
continue
|
||||
}
|
||||
if had, err := compactBox(box); err != nil || had != stood {
|
||||
continue
|
||||
}
|
||||
delete(r, catalogue.VaultSealedField)
|
||||
r["sealed"] = sealed
|
||||
}
|
||||
}
|
||||
// A stand-in box that is left is said by its resource, never sent and never guessed at.
|
||||
for _, r := range d.Resources {
|
||||
if box, has := r[catalogue.VaultSealedField]; has {
|
||||
if had, err := compactBox(box); err == nil && had == stood {
|
||||
if id, _ := r["id"].(string); slices.ContainsFunc(modules, func(m string) bool { return strings.HasPrefix(id, m+".") }) {
|
||||
return d, nil, fmt.Errorf("%s on %s still carries a stand-in for the vault's box, and the controller has "+
|
||||
"no seal of its own for it", id, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.CoreBoxesWanted = wanted
|
||||
for _, f := range d.Foreseen {
|
||||
module, _, _ := strings.Cut(f, "/")
|
||||
if !slices.Contains(modules, module) {
|
||||
foreseen = append(foreseen, f)
|
||||
}
|
||||
}
|
||||
d.Foreseen = foreseen
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return d, nil, err
|
||||
}
|
||||
return d, validate.Declaration(body), nil
|
||||
}
|
||||
|
||||
// compactBox is a value as the compact JSON it marshals to, so a box is compared by what it says and not by the type that
|
||||
// holds it.
|
||||
func compactBox(v any) (string, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := json.Compact(&out, raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A declaration as the SDK composes it is the one the controller sends, byte for byte, whatever it carries: the body is the
|
||||
// envelope's only marshaller on either side (design 47 R4b, issue 1386).
|
||||
func TestASendableIsTheComposedDeclarationByteForByte(t *testing.T) {
|
||||
d := compose.Declared{
|
||||
Resources: []map[string]any{{"id": "files.config", "type": "file"}, {"id": "mesh.hosts", "type": "file"}},
|
||||
Sequence: 7, Epoch: 3, Generation: 9, ReadsModules: true,
|
||||
ModuleOf: map[string]string{"files.config": "files"}, BuildIDs: map[string]string{"files": "b-1"},
|
||||
Adoption: &compose.Adoption{Taken: []string{"files"}, Untaken: map[string][]string{"nats": {"nats.server"}}},
|
||||
Filled: []string{"/etc/files/filled.json"}, Provisioner: "mesh-provisioner",
|
||||
LeftOut: []string{"cache"}, LeftOutWhy: map[string]string{"cache": "a setting cannot compose"},
|
||||
}
|
||||
want, err := d.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := sendableOf(d).Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("the controller would send\n%s\nand the composition made\n%s", got, want)
|
||||
}
|
||||
if empty, err := sendableOf(compose.Declared{}).Body(); err != nil || !strings.Contains(string(empty), `"owns_nothing":true`) {
|
||||
t.Fatalf("an empty declaration is not deliberate: %s %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1386: what a send records as carried is what the controller's own carriedOn recorded.
|
||||
func TestWhatASendRecordsAsCarriedIsWhatTheControllerRecorded(t *testing.T) {
|
||||
plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "postgres"}, {Module: "files"}, {Module: "cache"}}}
|
||||
current := map[string]inventory.CurrentBuild{"postgres": {Commit: "p2"}, "files": {Commit: "f2"}, "cache": {Commit: "c2"}}
|
||||
leftOut := map[string]string{"cache": "a setting cannot compose"}
|
||||
for name, c := range map[string]struct {
|
||||
sent map[string]string
|
||||
known bool
|
||||
kept map[string]string
|
||||
}{
|
||||
"nothing sent": {},
|
||||
"sent and kept": {sent: map[string]string{"postgres": "p1", "cache": "c1", "files": "f1"}, known: true, kept: map[string]string{"postgres": "p1"}},
|
||||
"sent but not known": {sent: map[string]string{"cache": "c1"}},
|
||||
"kept a module not in": {known: true, kept: map[string]string{"mail": "m1"}},
|
||||
} {
|
||||
var names []string
|
||||
for _, m := range plan.Modules {
|
||||
names = append(names, m.Module)
|
||||
}
|
||||
before := c.sent
|
||||
if !c.known {
|
||||
before = nil
|
||||
}
|
||||
want := carriedBuilds(names, leftOut, current, before)
|
||||
for m, was := range c.kept {
|
||||
if _, carried := want[m]; carried {
|
||||
want[m] = was
|
||||
}
|
||||
}
|
||||
in := compose.Input{CurrentBuilds: map[string]string{"postgres": "p2", "files": "f2", "cache": "c2"}}
|
||||
node := compose.Node{SentBuilds: c.sent, SentKnown: c.known, KeptBuilds: c.kept}
|
||||
if got := buildsOf(in, node, plan, leftOut); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: a send records %v, the controller recorded %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1385: a declaration that stands in for a value anywhere is still foreseen, in a list the composition names or in the body
|
||||
// alone (a pair credential's stand-in is in no list); one that stands in for nothing is not. It failed first, before the body
|
||||
// was scanned.
|
||||
func TestADeclarationThatStandsInForAValueIsStillForeseen(t *testing.T) {
|
||||
clean := compose.Declared{Resources: []map[string]any{{"id": "files.config", "type": "file", "content": "ok"}}}
|
||||
if left := stillForeseen(clean); len(left) != 0 {
|
||||
t.Fatalf("a declaration that stands in for nothing is foreseen: %v", left)
|
||||
}
|
||||
for name, d := range map[string]compose.Declared{
|
||||
"an own secret": {StoodIn: []string{"files/admin"}},
|
||||
"a port": {ForeseenPorts: []string{"files:80"}},
|
||||
"a core box": {CoreBoxesWanted: []compose.CoreBoxWanted{{Node: "anchor", Module: "mesh-vault", Name: "root"}}},
|
||||
"a pass of a seat": {Passes: compose.Passes{Passes: []catalogue.SeatPass{{Seat: catalogue.Seat{Name: "mesh-store"}}}}},
|
||||
"a pair credential": {Resources: []map[string]any{{"id": "files.needs-db", "sealed": catalogue.ForeseenSealed}}},
|
||||
"the certificate": {Resources: []map[string]any{{"id": "proxy.cert", "content": compose.ForeseenCertificate}}},
|
||||
"the bus's list": {Resources: []map[string]any{{"id": "nats.users", "content": compose.ForeseenBusUsers}}},
|
||||
"the vault's box": {Resources: []map[string]any{{"id": "x", "box": catalogue.ForeseenVaultBox}}},
|
||||
} {
|
||||
if left := stillForeseen(d); len(left) == 0 {
|
||||
t.Errorf("%s: a declaration that stands in for it is not foreseen", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1386: an Input read for a person's push is never the one read for a send that keeps recorded builds, and the cache is
|
||||
// emptied by what a send made.
|
||||
func TestAnInputIsCachedPerWhatItKeepsAndForgottenAfterASend(t *testing.T) {
|
||||
ctx := withInputCache(context.Background())
|
||||
plain := keptSignature(ctx)
|
||||
kept := keptSignature(keepingRecorded(ctx))
|
||||
bus := keptSignature(keepingRecorded(ctx, "nats"))
|
||||
every := keptSignature(keepingEveryBuild(ctx, "anchor"))
|
||||
for name, sig := range map[string]string{"a push": plain, "a kept send": kept, "the bus step": bus, "the grants step": every} {
|
||||
for other, o := range map[string]string{"a push": plain, "a kept send": kept, "the bus step": bus, "the grants step": every} {
|
||||
if name != other && sig == o {
|
||||
t.Errorf("%s and %s read the same Input", name, other)
|
||||
}
|
||||
}
|
||||
}
|
||||
cache := ctx.Value(inputCacheKey{}).(*inputCache)
|
||||
cache.by[plain] = &meshInput{}
|
||||
forgetInput(ctx)
|
||||
if len(cache.by) != 0 {
|
||||
t.Fatal("what a send made left the Input it read in the cache")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
func aConfiguredMesh(t *testing.T) (*stores, configuredMesh, map[string]map[string]any) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
@@ -152,6 +152,8 @@ type consumerCopy struct {
|
||||
|
||||
// probeData is D13.
|
||||
func probeData(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
// One reading of the mesh for every machine this looks at.
|
||||
ctx = withInputCache(ctx)
|
||||
if d.js == nil {
|
||||
return nil, errors.New("no bus to ask the machines over")
|
||||
}
|
||||
@@ -180,7 +182,7 @@ func probeData(ctx context.Context, d *doctor) ([]conditions.Observation, error)
|
||||
}
|
||||
var machines []*machine
|
||||
for _, n := range nodes {
|
||||
plan, _, err := planFor(ctx, open, n.Name)
|
||||
plan, _, err := resolveNode(ctx, open, n.Name)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
@@ -849,7 +851,7 @@ func deleteRetiredData(ctx context.Context, conn *nats.Conn, open *stores, r inv
|
||||
return fmt.Errorf("%s of %s on %s was never measured, so where it is was never said; nothing was deleted",
|
||||
r.Item, r.Module, r.Machine)
|
||||
}
|
||||
if plan, _, err := planFor(ctx, open, r.Machine); err == nil {
|
||||
if plan, _, err := resolveNode(ctx, open, r.Machine); err == nil {
|
||||
for _, m := range plan.Modules {
|
||||
if _, still := m.DataItem(r.Item); still && m.Module == r.Module {
|
||||
return fmt.Errorf("%s runs on %s again and declares %s: it is not retired any more. Nothing was done",
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
@@ -56,13 +56,19 @@ func TestNatsUnassigningIrreplaceableDataRetiresItAndAnEmptyReplacementIsUrgent(
|
||||
if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
register(t, open, catalogue.Manifest{Module: "keeper", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.BackupSeat, Scope: catalogue.ScopeNode, Serves: []string{"backed-up", "now", "restore"}}}})
|
||||
// Built from a commit, so that a send carries them: a holder is asked only of what the machine was sent (issue 1109).
|
||||
built := inventory.Source{BuiltFrom: "c1"}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "keeper", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.BackupSeat, Scope: catalogue.ScopeNode, Serves: []string{"backed-up", "now", "restore"}}}}, built); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
house, err := catalogue.ParseManifest([]byte(houseManifest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
register(t, open, house)
|
||||
if err := inv.RegisterModule(ctx, house, built); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "house"); err == nil {
|
||||
t.Fatal("irreplaceable data was assigned to a machine with nothing to back it up")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
+147
-35
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -42,13 +42,53 @@ import (
|
||||
// `delivery order` (a group's order from the graph), `delivery check` (a group's heads composed), `delivery
|
||||
// go`, `delivery stop` and `delivery walks`.
|
||||
|
||||
// onTheControllersPath are the modules whose walk never waits for the delivery's owner, and what each is.
|
||||
// onTheControllersPath are the modules, by name, whose walk never waits for the delivery's owner, and what each is.
|
||||
// The delivery's owner is not among them: it is whichever module claims the seat (ownPathOf, novox/hq ADR 0437 §3).
|
||||
var onTheControllersPath = map[string]string{
|
||||
"mesh-controller": "the controller",
|
||||
"mesh-host": "the node-engine",
|
||||
"node-tools": "the node tools",
|
||||
"nats": "the bus",
|
||||
catalogue.DeliverySeat: "the delivery's owner",
|
||||
"mesh-controller": "the controller",
|
||||
"mesh-host": "the node-engine",
|
||||
"node-tools": "the node tools",
|
||||
"nats": "the bus",
|
||||
}
|
||||
|
||||
// claimantsOf are the modules among the catalogued entries that claim a seat, each once, in name order.
|
||||
func claimantsOf(entries []inventory.Entry, seat string) []string {
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.Manifest.ClaimsSeat(seat) && !slices.Contains(out, e.Manifest.Module) {
|
||||
out = append(out, e.Manifest.Module)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ownPathOf is the controller's own path among these entries: the modules by name, and the modules that claim
|
||||
// the delivery seat, which is how the delivery's owner is found (novox/hq ADR 0437 §3).
|
||||
func ownPathOf(entries []inventory.Entry) map[string]string {
|
||||
own := make(map[string]string, len(onTheControllersPath)+1)
|
||||
for m, what := range onTheControllersPath {
|
||||
own[m] = what
|
||||
}
|
||||
for _, m := range claimantsOf(entries, catalogue.DeliverySeat) {
|
||||
own[m] = "the delivery's owner"
|
||||
}
|
||||
return own
|
||||
}
|
||||
|
||||
// onTheOwnPath is whether one module, with its manifest at hand, is on the controller's own path.
|
||||
func onTheOwnPath(module string, m catalogue.Manifest) bool {
|
||||
_, named := onTheControllersPath[module]
|
||||
return named || m.ClaimsSeat(catalogue.DeliverySeat)
|
||||
}
|
||||
|
||||
// ownPathNow is ownPathOf the catalogue as it is.
|
||||
func ownPathNow(ctx context.Context, inv *inventory.Inventory) (map[string]string, error) {
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ownPathOf(entries), nil
|
||||
}
|
||||
|
||||
// deliverySeatHeld is whether a module claiming the delivery seat is assigned somewhere: the seat has a
|
||||
@@ -69,8 +109,9 @@ func awaitsFor(entries []inventory.Entry, modules []string) *inventory.PlanDeliv
|
||||
if !deliverySeatHeld(entries) {
|
||||
return nil
|
||||
}
|
||||
onPath := ownPathOf(entries)
|
||||
for _, m := range modules {
|
||||
if _, own := onTheControllersPath[m]; own {
|
||||
if _, own := onPath[m]; own {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -327,16 +368,17 @@ func stopInBatch(ctx context.Context, inv *inventory.Inventory, b *inventory.Pla
|
||||
// the stop's one act. The kind is read from what the merges moved; the stopped walk itself is no walk a cut folds.
|
||||
func batchToWalkAgain(ctx context.Context, inv *inventory.Inventory, stopping string, again []inventory.BatchedMerge,
|
||||
now time.Time) (inventory.Plan, []*inventory.Plan, []inventory.MergeMove, error) {
|
||||
own := false
|
||||
for _, n := range movesOf(again) {
|
||||
if _, on := onTheControllersPath[n]; on {
|
||||
own = true
|
||||
}
|
||||
}
|
||||
entries, read, edges, err := theGraph(ctx, inv)
|
||||
if err != nil {
|
||||
return inventory.Plan{}, nil, nil, err
|
||||
}
|
||||
own := false
|
||||
onPath := ownPathOf(entries)
|
||||
for _, n := range movesOf(again) {
|
||||
if _, on := onPath[n]; on {
|
||||
own = true
|
||||
}
|
||||
}
|
||||
joins, _, _, err := relatedBatches(ctx, inv, again, own, stopping, entries, read, edges)
|
||||
if err != nil {
|
||||
return inventory.Plan{}, nil, nil, err
|
||||
@@ -742,6 +784,27 @@ func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[s
|
||||
return out
|
||||
}
|
||||
|
||||
// changeMember is one change as the planner is asked about it, the same for `delivery plan` and `plans --what-if`.
|
||||
func changeMember(repository, base, head string, paths, removed []string, moduleDirs *[]string) orderMember {
|
||||
m := orderMember{ID: repository + "@" + head, Repository: repository, Base: base, Head: head,
|
||||
Paths: paths, Removed: removed}
|
||||
if moduleDirs != nil {
|
||||
m.ModuleDirs, m.ModuleDirsSaid = *moduleDirs, true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// deliveryPlanDocument is the answer of `delivery plan` and of `plans --what-if … --data`: one change's
|
||||
// change plan with its reach, the mesh's own part in it and whether the gate would run. One function, so
|
||||
// the two verbs cannot differ (novox/hq ADR 0450 §2, issue 1324).
|
||||
func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
|
||||
edges []inventory.Edge, policy policyOf) map[string]any {
|
||||
s := pullScope(m.pull(), entries, read, edges)
|
||||
plan := changePlanOf(m.Repository, m.Base, m.Head, s.Reach, entries, policy)
|
||||
return map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
|
||||
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()}
|
||||
}
|
||||
|
||||
// deliveryCommand is `delivery`, the controller's verbs for the delivery's owner:
|
||||
//
|
||||
// delivery plan --repository owner/repo --head <commit> [--base main] --paths a,b [--module-dirs …] [--removed …]
|
||||
@@ -763,6 +826,8 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
moduleDirs := set.String("module-dirs", "", "the directories holding a module.json at the head, comma-separated")
|
||||
removed := set.String("removed", "", "the files it deletes, comma-separated")
|
||||
membersJSON := set.String("members", "", "a group's members, as JSON")
|
||||
membersFile := set.String("members-file", "", "a file of a group's members, as JSON: the same as --members, for a list "+
|
||||
"too long for an argument (novox/hq issue 1272)")
|
||||
group := set.String("group", "", "a delivery group's id")
|
||||
ready := set.Bool("ready", false, "the head's pull request is marked ready: its check is queued before every other check "+
|
||||
"(novox/hq ADR 0406 §3)")
|
||||
@@ -777,6 +842,13 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
var members []orderMember
|
||||
if *membersFile != "" {
|
||||
raw, err := os.ReadFile(*membersFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--members-file: %w", err)
|
||||
}
|
||||
*membersJSON = string(raw)
|
||||
}
|
||||
if *membersJSON != "" {
|
||||
if err := json.Unmarshal([]byte(*membersJSON), &members); err != nil {
|
||||
return fmt.Errorf("the members are not readable: %w", err)
|
||||
@@ -804,22 +876,15 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
if *repository == "" || *head == "" && *paths == "" {
|
||||
return errors.New("delivery plan --repository owner/repo --head <commit> --paths a,b")
|
||||
}
|
||||
m := orderMember{ID: *repository + "@" + *head, Repository: *repository, Base: *base, Head: *head,
|
||||
Paths: splitList(*paths), Removed: splitList(*removed)}
|
||||
if d := moduleDirsOf(*moduleDirs); d != nil {
|
||||
m.ModuleDirs, m.ModuleDirsSaid = *d, true
|
||||
}
|
||||
m := changeMember(*repository, *base, *head, splitList(*paths), splitList(*removed), moduleDirsOf(*moduleDirs))
|
||||
entries, read, edges, err := theGraph(ctx, inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := pullScope(m.pull(), entries, read, edges)
|
||||
plan := changePlanOf(*repository, *base, *head, s.Reach, entries, func(module string) (inventory.Upgrade, bool) {
|
||||
return answer(deliveryPlanDocument(m, entries, read, edges, func(module string) (inventory.Upgrade, bool) {
|
||||
u, err := inv.UpgradeOf(ctx, module)
|
||||
return u, err == nil
|
||||
})
|
||||
return answer(map[string]any{"plan": plan, "reach": orderReach{Moved: s.Modules, Dependents: s.Dependents,
|
||||
New: s.New, Manifests: s.Manifests}, "mesh": s.Mesh, "gated": s.gated()})
|
||||
}))
|
||||
case "order":
|
||||
if len(members) == 0 {
|
||||
return errors.New("delivery order --members <json>: a group has members")
|
||||
@@ -837,10 +902,14 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
if *group == "" && len(members) == 1 {
|
||||
// One head, checked again as the forge's announcement would have it (a recheck): the controller's
|
||||
// own path for a pull request, run because the delivery's owner asked.
|
||||
if err := sayingOnTheBus(ctx, inv, func() error { return (following{open: open, oneShot: true}).PullUpdated(ctx, members[0].pull()) }); err != nil {
|
||||
return err
|
||||
}
|
||||
return answer(map[string]any{"rechecked": members[0].ID})
|
||||
return recheckOneHead(members[0].ID, answer, func() (string, error) {
|
||||
var asked string
|
||||
err := sayingOnTheBus(ctx, inv, func() (err error) {
|
||||
asked, err = (following{open: open, oneShot: true}).pullUpdated(ctx, members[0].pull(), os.Stderr)
|
||||
return err
|
||||
})
|
||||
return asked, err
|
||||
})
|
||||
}
|
||||
if *group == "" || len(members) < 2 {
|
||||
return errors.New("delivery check --group <id> --members <json> (two heads or more), or --members <one head>")
|
||||
@@ -935,7 +1004,7 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
walks[i].Phases = walks[i].WalkPhases(now, appliedFrom(ctx, inv))
|
||||
}
|
||||
return answer(map[string]any{"held": deliverySeatHeld(entries), "walks": walks,
|
||||
"own-path": sortedKeysOf(ownPathWords())})
|
||||
"own-path": sortedKeysOf(ownPathOf(entries))})
|
||||
}
|
||||
return fmt.Errorf("delivery %s: plan, order, check, go, stop, backups-again or walks", sub)
|
||||
}
|
||||
@@ -1006,9 +1075,6 @@ func recordWalkPhases(ctx context.Context, inv *inventory.Inventory, now time.Ti
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownPathWords is the controller's own path as words, for an answer.
|
||||
func ownPathWords() map[string]string { return onTheControllersPath }
|
||||
|
||||
// theGraph is what the planner reads.
|
||||
func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry, map[string][]inventory.ReadRepository,
|
||||
[]inventory.Edge, error) {
|
||||
@@ -1094,7 +1160,7 @@ func askGroupCheck(ctx context.Context, open *stores, group string, members []or
|
||||
if failing, cyclic, err := cycleVerdict(ctx, group, members, reach, edges, entries); err != nil {
|
||||
return "", err
|
||||
} else if cyclic {
|
||||
sayChecked(ctx, failing)
|
||||
sayChecked(ctx, os.Stderr, failing)
|
||||
return failing.ID, nil
|
||||
}
|
||||
primary := groupPrimary(members, reach)
|
||||
@@ -1113,7 +1179,7 @@ func askGroupCheck(ctx context.Context, open *stores, group string, members []or
|
||||
request.Check.Group = group
|
||||
// A group's check is queued as ready when any member's pull request is marked ready.
|
||||
request.Check.Ready = slices.ContainsFunc(members, func(m orderMember) bool { return m.Ready })
|
||||
world, err := theRestOfTheMesh(ctx, open.inventory, shelfOf(entries), "")
|
||||
world, err := restOfTheMesh(ctx, open, shelfOf(entries), "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1181,8 +1247,32 @@ func shelfOf(entries []inventory.Entry) map[string]catalogue.Manifest {
|
||||
// On the save that starts the walk and the one that ends it, the walk also says what it touches, its bound and,
|
||||
// at the end, its outcome (novox/hq ADR 0308 decision 1, walk_said.go), read from facts; nil facts say only what
|
||||
// the walk recorded.
|
||||
//
|
||||
// The plan the announcement is made from is a copy that shares nothing with the walk, taken before the goroutine
|
||||
// starts: the walk goes on writing its steps' maps and pointers while the announcement is marshalled and its
|
||||
// touches read (novox/hq issue 1260), and it says the plan as it was when this was called.
|
||||
func sayPlanMoved(ctx context.Context, bus link.Bus, facts walkFacts, p inventory.Plan) {
|
||||
go publishPlanMoved(ctx, bus, facts, p)
|
||||
kept, err := copyOfPlan(p)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: kept, and could not be said as plan-moved: %v\n", p.ID, err)
|
||||
return
|
||||
}
|
||||
go publishPlanMoved(ctx, bus, facts, kept)
|
||||
}
|
||||
|
||||
// copyOfPlan is the plan with nothing shared with the original: its JSON round trip, which is all of it but what
|
||||
// the plan never keeps (WalkSaid), carried over.
|
||||
func copyOfPlan(p inventory.Plan) (inventory.Plan, error) {
|
||||
raw, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return inventory.Plan{}, err
|
||||
}
|
||||
var kept inventory.Plan
|
||||
if err := json.Unmarshal(raw, &kept); err != nil {
|
||||
return inventory.Plan{}, err
|
||||
}
|
||||
kept.WalkSaid = p.WalkSaid
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
func publishPlanMoved(ctx context.Context, bus link.Bus, facts walkFacts, p inventory.Plan) {
|
||||
@@ -1290,3 +1380,25 @@ func overNATS(conn *nats.Conn) (link.Bus, error) {
|
||||
}
|
||||
return link.OverNATS{Conn: conn, JS: js}, nil
|
||||
}
|
||||
|
||||
// recheckAnswer is what `delivery check` answers for one head: the id of the check asked of the build seat (empty when
|
||||
// the controller said the verdict itself and asked nothing), which mesh-delivery reads, and the head rechecked.
|
||||
func recheckAnswer(asked, member string) map[string]any {
|
||||
return map[string]any{"asked": asked, "rechecked": member}
|
||||
}
|
||||
|
||||
// recheckOneHead runs a one-head `delivery check` and answers it as data. Standard output is the answer's JSON alone,
|
||||
// or the seat verb reads no answer from it (novox/hq issue 1311): while the check runs, whatever it prints to standard
|
||||
// output (the controller's own path prints its prose there, as do the stores it reads) goes to standard error.
|
||||
func recheckOneHead(member string, answer func(any) error, ask func() (string, error)) error {
|
||||
real := os.Stdout
|
||||
os.Stdout = os.Stderr
|
||||
asked, err := func() (string, error) {
|
||||
defer func() { os.Stdout = real }()
|
||||
return ask()
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return answer(recheckAnswer(asked, member))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// `delivery check` with one head answers the id of the check it asked, as data (novox/hq issue 1311): the prose
|
||||
// the check says goes to standard error, so standard output is the document alone and answerOf reads it.
|
||||
func TestDeliveryCheckOfOneHeadAnswersTheRequestID(t *testing.T) {
|
||||
argv := []string{"delivery", "check", "--members", "[]"}
|
||||
doc, err := json.Marshal(recheckAnswer("check-42", "novox/x@abc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seen failing first: the prose the command used to print on standard output before the document.
|
||||
polluted := append([]byte("novox/x#7 (abc): asked the build seat as check-42\n"), doc...)
|
||||
if got := answerOf(argv, polluted, "", true); got.Answer != nil {
|
||||
t.Fatalf("prose before the document should leave no answer; got %v", got.Answer)
|
||||
}
|
||||
got := answerOf(argv, doc, "novox/x#7 (abc): asked the build seat as check-42\n", true)
|
||||
m, ok := got.Answer.(map[string]any)
|
||||
if !ok || m["asked"] != "check-42" || m["rechecked"] != "novox/x@abc" {
|
||||
t.Fatalf("the answer is %v; want asked check-42 and rechecked novox/x@abc", got.Answer)
|
||||
}
|
||||
if !strings.Contains(got.Output, "asked the build seat") {
|
||||
t.Fatalf("the prose is still said in the text: %q", got.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// The text of the other callers does not change: sayChecked writes the same line to the writer it is given, and
|
||||
// the controller's own path still gives PullUpdated standard output.
|
||||
func TestSayCheckedWritesTheSameLineToItsWriter(t *testing.T) {
|
||||
var w bytes.Buffer
|
||||
sayChecked(context.Background(), &w, link.Checked{ID: "c1", Owner: "novox", Repo: "x", Number: 7, Commit: "0123456789abcdef",
|
||||
Verdict: "pass", Summary: "fine"})
|
||||
want := "c1: novox/x#7 at 01234567 checked on a machine that did not say which: gate PASS — fine; repository none\n"
|
||||
if w.String() != want {
|
||||
t.Fatalf("sayChecked said %q; want %q", w.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the check prints to standard output while it runs (checks.go namedForTheCheck, the inventory's pointer
|
||||
// repair) must not reach the answer's standard output: it is exactly one JSON document (novox/hq issue 1311).
|
||||
func TestOneHeadRecheckLeavesStandardOutputTheAnswerAlone(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, ew, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realOut, realErr := os.Stdout, os.Stderr
|
||||
os.Stdout, os.Stderr = w, ew
|
||||
runErr := recheckOneHead("novox/x@abc", func(v any) error { return json.NewEncoder(os.Stdout).Encode(v) },
|
||||
func() (string, error) {
|
||||
fmt.Println(" the earlier checks' builds could not be read, so the check builds")
|
||||
fmt.Fprintln(os.Stdout, "repaired a pointer")
|
||||
return "check-42", nil
|
||||
})
|
||||
os.Stdout, os.Stderr = realOut, realErr
|
||||
w.Close()
|
||||
ew.Close()
|
||||
if runErr != nil {
|
||||
t.Fatal(runErr)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
_, _ = out.ReadFrom(r)
|
||||
_, _ = errOut.ReadFrom(e)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil || doc["asked"] != "check-42" || doc["rechecked"] != "novox/x@abc" {
|
||||
t.Fatalf("standard output %q is not the answer alone (%v)", out.String(), err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "repaired a pointer") {
|
||||
t.Fatalf("the prose should be on standard error: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -55,6 +55,17 @@ type stalledLine struct {
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
// Severity is "urgent" for a group held or half merged and a refused member (novox/hq issues 1275, 1278); empty
|
||||
// or anything else is a warning, as before.
|
||||
Severity string `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
// severity is the condition's severity a stalled line asks: urgent when it says so, a warning otherwise.
|
||||
func (l stalledLine) severity() conditions.Severity {
|
||||
if l.Severity == string(conditions.Urgent) {
|
||||
return conditions.Urgent
|
||||
}
|
||||
return conditions.Warning
|
||||
}
|
||||
|
||||
// waitingCheck is one merge check a stalled line waits on, as mesh-delivery gives it: its context, its state —
|
||||
@@ -165,7 +176,7 @@ func stalledObservations(lines []stalledLine) []conditions.Observation {
|
||||
out := make([]conditions.Observation, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
o := conditions.Observation{Scope: conditions.ScopeDelivery, ID: l.ID, Kind: kindDeliveryStalled,
|
||||
Severity: conditions.Warning,
|
||||
Severity: l.severity(),
|
||||
Summary: fmt.Sprintf("the delivery %s has been %s for %s, past its bound of %s (%s): healer H2 may %s — "+
|
||||
"`mesh-delivery.show %s`", l.ID, l.State, l.For, l.Bound, l.Says, l.H2, l.ID),
|
||||
Said: fmt.Sprintf("%s for %s", l.State, l.For)}
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
func severityRaisedFor(t *testing.T, line string) conditions.Severity {
|
||||
t.Helper()
|
||||
var l stalledLine
|
||||
if err := json.Unmarshal([]byte(line), &l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := stalledObservations([]stalledLine{l})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("%d conditions", len(got))
|
||||
}
|
||||
return got[0].Severity
|
||||
}
|
||||
|
||||
func TestAnUrgentStalledLineRaisesAnUrgentCondition(t *testing.T) { // 1278/AC1
|
||||
line := `{"id":"novox/mesh-delivery#5","state":"held","for":"3m0s","bound":"0s","h2":"close","says":"held","severity":"urgent"}`
|
||||
if got := severityRaisedFor(t, line); got != conditions.Urgent {
|
||||
t.Errorf("an urgent line raised %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStalledLineWithoutOrWithAnUnknownSeverityRaisesAWarning(t *testing.T) { // 1278/AC2
|
||||
for _, extra := range []string{``, `,"severity":""`, `,"severity":"critical"`, `,"severity":"warning"`} {
|
||||
line := `{"id":"novox/a#1","state":"unanswered","for":"3m0s","bound":"2m0s","h2":"close","says":"x"` + extra + `}`
|
||||
if got := severityRaisedFor(t, line); got != conditions.Warning {
|
||||
t.Errorf("the line %s raised %q", extra, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -120,6 +120,16 @@ var probeRegistry = []probe{
|
||||
{ID: probeDeliveriesID, Asserts: "no delivery is held past its state's bound unsaid: mesh-delivery's " +
|
||||
"`stalled`, each with the transition its table lets healer H2 take", From: "ADR 0239",
|
||||
Kind: kindDeliveryStalled, Phase: 3, run: probeDeliveries},
|
||||
// What the watchman says stopped moving (ADR 0433 §2, issue 1156): each key of its stuck bucket not moved, but a
|
||||
// node's presence, which the silent check takes.
|
||||
{ID: probeStuckID, Asserts: "no key the watchman says stopped moving is left unsaid: each key of its stuck bucket " +
|
||||
"not moved is a condition of the delivery scope until it says moved", From: "ADR 0433 §2, issue 1156",
|
||||
Kind: kindStuck, Phase: 3, run: probeStuck},
|
||||
// A first merge into a repository the mesh builds nothing from, kept waiting for mesh-delivery's word on the
|
||||
// operator's Approve (novox/hq ADR 0432, issue 1146): said past a bound, never only in a log line (ADR 0426 §3, §8).
|
||||
{ID: probeFirstBuildsID, Asserts: "no first merge into a repository the mesh builds nothing from waits more than ten " +
|
||||
"minutes for mesh-delivery's announcement of the operator's Approve of its head", From: "ADR 0432, issue 1146",
|
||||
Kind: kindFirstBuildUnasked, Phase: 3, run: probeFirstBuilds},
|
||||
// The delivery budgets (novox/hq ADR 0282 decision 7): the newest delivery of each class within its budget,
|
||||
// read from mesh-delivery's `times`; a measurement said, never a delivery held.
|
||||
{ID: probeBudgetsID, Asserts: "the newest delivery of a leaf module ran on every machine within three minutes of " +
|
||||
@@ -178,6 +188,10 @@ var probeRegistry = []probe{
|
||||
{ID: backupHeldProbe, Asserts: "no delivery plan is held for a backup before its send that failed or did not " +
|
||||
"answer in ten minutes", From: "ADR 0404 decision 4, issue 1066", Kind: kindBackupHeld, Phase: 1,
|
||||
run: probeBackupHeld},
|
||||
// A plan's send key the bus refused (ADR 0433 §4, issue 1157): in place of the timer that stood in for it.
|
||||
{ID: sendKeyRefusedID, Asserts: "no delivery plan's key that watches a machine for its report was refused by the " +
|
||||
"bus at the newest look", From: "ADR 0433 §4, issue 1157", Kind: kindSendKeyRefused, Phase: 1,
|
||||
run: probeSendKeyRefused},
|
||||
}
|
||||
|
||||
// probeVerdict is one probe's outcome in a run.
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -72,8 +72,6 @@ func summarise(ds []inventory.Duration) []durationSummary {
|
||||
switch k.kind {
|
||||
case inventory.DurationApply:
|
||||
s.Suggests = "S2 bound max(2m, 3×last apply) ≈ " + round(max(2*time.Minute, 3*at(0.9))) + " at the p90"
|
||||
case inventory.DurationHeartbeatGap:
|
||||
s.Suggests = "S1 bound 3×interval ≈ " + round(3*at(0.5))
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
@@ -138,7 +136,7 @@ func durationsCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
if len(summary) == 0 {
|
||||
fmt.Printf("nothing measured in the last %d day(s): the serving controller records apply, heartbeat-gap, "+
|
||||
fmt.Printf("nothing measured in the last %d day(s): the serving controller records apply, "+
|
||||
"plan-tier and build durations as it hears them\n", *days)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,13 +14,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/artifacts"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -170,10 +171,15 @@ func factsStore(ctx context.Context, open *stores) (string, error) {
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// writtenFormat is the format of the snapshot this controller writes: 1, the format its own reader knew before mesh-sdk's
|
||||
// go/facts took the type over (issue 1390, novox/hq ADR 0456). The readers (the merge gate, the check) read formats 1 and
|
||||
// 2; the writer moves to 2, which also carries each machine's grants and bindings, in a later release of its own.
|
||||
const writtenFormat = 1
|
||||
|
||||
// gatherFacts composes one snapshot from the store: read only, nothing made, nothing sent.
|
||||
func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot.Facts, error) {
|
||||
inv := open.inventory
|
||||
f := snapshot.Facts{Format: snapshot.Format, Taken: time.Now().UTC(), Controller: snapshot.Build{Version: version}}
|
||||
f := snapshot.Facts{Format: writtenFormat, Taken: time.Now().UTC(), Controller: snapshot.Build{Version: version}}
|
||||
f.Versions.Bus = busVersion
|
||||
storeVersion, err := inv.ServerVersion(ctx)
|
||||
if err != nil {
|
||||
@@ -263,7 +269,9 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
f.Controller.Commit = c.Commit
|
||||
}
|
||||
|
||||
gens, gensErr := generators(ctx, open)
|
||||
// The Input every machine's declaration below is composed over is read once for the whole snapshot.
|
||||
ctx = withInputCache(ctx)
|
||||
_, gensErr := generatorsOf(ctx, open)
|
||||
hostShelf := shelf[hostModule]
|
||||
meshWide := map[string]bool{}
|
||||
engines := map[string]bool{}
|
||||
@@ -361,7 +369,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
m.Settings = append(m.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Whole())})
|
||||
}
|
||||
}
|
||||
m.Declaration = declarationFacts(ctx, open, n.Name, gens, gensErr, scrub)
|
||||
m.Declaration = declarationFacts(ctx, open, n.Name, gensErr, scrub)
|
||||
if ctx.Err() != nil {
|
||||
return snapshot.Facts{}, ctx.Err()
|
||||
}
|
||||
@@ -456,14 +464,14 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
|
||||
// declarationFacts is how one machine's declaration composes now, as the next push would compose it and
|
||||
// without making anything (D1's composition), and whether the node-engine's validator takes it.
|
||||
func declarationFacts(ctx context.Context, open *stores, node string, gens map[string]catalogue.Generator,
|
||||
gensErr error, scrub *snapshot.Scrubber) snapshot.Declaration {
|
||||
func declarationFacts(ctx context.Context, open *stores, node string, gensErr error,
|
||||
scrub *snapshot.Scrubber) snapshot.Declaration {
|
||||
var d snapshot.Declaration
|
||||
if gensErr != nil {
|
||||
d.Problems = []string{scrub.Text("the private network cannot be computed: " + oneLine(gensErr.Error()))}
|
||||
return d
|
||||
}
|
||||
declared, problems, err := composedAndValidated(ctx, open, node, gens, Foreseeing)
|
||||
declared, problems, err := composeQuestion(ctx, open, node, compose.Foreseeing, true)
|
||||
if err != nil {
|
||||
d.Problems = []string{scrub.Text(oneLine(err.Error()))}
|
||||
return d
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
)
|
||||
|
||||
// 1390 (novox/hq ADR 0456): the snapshot this controller writes is format 1, and the reader it shares with the build seat
|
||||
// (mesh-sdk's go/facts) takes formats 1 and 2 and refuses one newer than it knows. The writer moves to format 2 in a release
|
||||
// of its own. It failed first with the controller's own reader, which refused format 2 as newer than it knew.
|
||||
func TestTheWriterWritesFormatOneAndTheReaderTakesBothFormats(t *testing.T) {
|
||||
if writtenFormat != 1 {
|
||||
t.Fatalf("the writer writes format %d, not 1", writtenFormat)
|
||||
}
|
||||
taken := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
|
||||
for format, want := range map[int]bool{1: true, 2: true, 3: false} {
|
||||
body, err := (snapshot.Facts{Format: format, Taken: taken}).Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := snapshot.Decode(body)
|
||||
switch {
|
||||
case want && err != nil:
|
||||
t.Errorf("format %d is refused: %v", format, err)
|
||||
case want && f.Format != format:
|
||||
t.Errorf("format %d is read as %d", format, f.Format)
|
||||
case !want && (err == nil || !strings.Contains(err.Error(), "newer")):
|
||||
t.Errorf("format %d is not refused as newer: %v", format, err)
|
||||
}
|
||||
}
|
||||
// What the writer composes carries no field of format 2: a reader of 1 and of 2 read it alike.
|
||||
body, err := (snapshot.Facts{Format: writtenFormat, Taken: taken, Machines: []snapshot.Machine{{Name: "abcdef", Length: 6}}}).Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw struct {
|
||||
Format int `json:"facts"`
|
||||
Machines []map[string]json.RawMessage `json:"machines"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw.Format != 1 {
|
||||
t.Errorf("the snapshot says format %d", raw.Format)
|
||||
}
|
||||
for _, m := range raw.Machines {
|
||||
if _, has := m["grants"]; has {
|
||||
t.Error("a format 1 snapshot carries grants")
|
||||
}
|
||||
if _, has := m["bindings"]; has {
|
||||
t.Error("a format 1 snapshot carries bindings")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
)
|
||||
|
||||
// The facts snapshot (novox/hq to-be 45 §9): composed from the store, every machine under a pseudonym
|
||||
@@ -68,6 +68,9 @@ func TestTheFactsCarryNoSecretNoAddressAndNoName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Format != 1 {
|
||||
t.Fatalf("the writer wrote format %d: it keeps format 1 until its own release moves it (issue 1390)", f.Format)
|
||||
}
|
||||
body, err := f.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// aForgeAndABuildSeat gives a test what a real ask of a build needs: the forge holding the git seat, assigned to anchor, so
|
||||
// the clone URL composes; the identity store; the build seat's queue on a real bus; and the environment of the serving controller, which is
|
||||
// never the controller's terminal (a verb's, in effect: startedAtTheTerminal is false).
|
||||
func aForgeAndABuildSeat(t *testing.T, open *stores) *broker.JetStream {
|
||||
t.Helper()
|
||||
js, _ := aBuildQueue(t)
|
||||
// The ask signs what it sends with the controller's identity, as the serving controller does.
|
||||
identity.ForTest(t)
|
||||
forge, err := catalogue.ParseManifest([]byte(`{"module":"gitea","version":"1",
|
||||
"claims":[{"name":"git","scope":"mesh"}],"provides":[{"name":"git","scope":"mesh"}],
|
||||
"serves":{"git":{"scheme":"http","port":3000}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
register(t, open, forge)
|
||||
if _, err := assign(t.Context(), open, "anchor", "gitea"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(servedVar, "1")
|
||||
return js
|
||||
}
|
||||
|
||||
// 1146/AC9, through the real ask (askABuild, buildOneAsked, verbMayAsk) and a real bus: the first build of a module a
|
||||
// merge adds on the operator's Approve is queued on the build seat; the same ask without that warrant, and the warrant's
|
||||
// ask for a path the merge does not hold, are refused by the guard of a build through a verb.
|
||||
func TestTheApprovedFirstBuildPassesTheGuardOfAVerbAndIsQueued(t *testing.T) {
|
||||
const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000"
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
js := aForgeAndABuildSeat(t, open)
|
||||
|
||||
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
|
||||
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
|
||||
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
|
||||
return a.merge, true, nil
|
||||
}
|
||||
routerAskOf = func(context.Context, catalogueReader, string, string) (*asks.Ask, *asks.Warrant, error) {
|
||||
return &a.ask, &a.warrant, nil
|
||||
}
|
||||
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
|
||||
|
||||
// Without the warrant the guard refuses, whatever the repository holds.
|
||||
_, err := askABuild(ctx, buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "", "main")
|
||||
var policy *heldAtTheTerminal
|
||||
if !errors.As(err, &policy) {
|
||||
t.Fatalf("1146/AC9: a first build asked without the warrant was not refused: %v", err)
|
||||
}
|
||||
// And a path the merge does not hold is refused on the warrant.
|
||||
warranted := withFirstBuildWarrant(ctx, "novox/mesh-delivery", []string{""}, "the Approve")
|
||||
if _, err := askABuild(warranted, buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "other", "main"); !errors.As(err, &policy) {
|
||||
t.Fatalf("1146/AC9: the warrant covered a path the merge does not hold: %v", err)
|
||||
}
|
||||
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: commit, Number: 7,
|
||||
MergedAt: time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), Paths: []string{"module.json", "main.go"},
|
||||
ModuleDirs: []string{"."}, ModuleDirsSaid: true}
|
||||
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(queue.Asks) != 1 {
|
||||
t.Fatalf("1146/AC9: the build queue holds %+v, want the one first build", queue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// 1146/AC9: the warrant the controller checked covers exactly the repository on the git seat and the module paths the
|
||||
// merge holds; nothing else passes verbMayAsk on it.
|
||||
func TestTheFirstBuildWarrantCoversOnlyItsRepositoryAndItsModulePaths(t *testing.T) {
|
||||
ctx := withFirstBuildWarrant(t.Context(), "novox/mesh-delivery", []string{"", "view"}, "the Approve")
|
||||
w := firstBuildWarrantIn(ctx)
|
||||
for _, c := range []struct {
|
||||
source buildSource
|
||||
path string
|
||||
covers bool
|
||||
}{
|
||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "", true},
|
||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "/view/", true},
|
||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "other", false},
|
||||
{buildSource{Repository: "agent/mesh-delivery", Seat: "git"}, "", false},
|
||||
{buildSource{Repository: "novox/mesh-delivery"}, "", false},
|
||||
{buildSource{Repository: "novox/mesh-delivery", Seat: "elsewhere"}, "", false},
|
||||
} {
|
||||
if got := w.covers(c.source, c.path); got != c.covers {
|
||||
t.Errorf("1146/AC9: %v at %q covered = %v, want %v", c.source, c.path, got, c.covers)
|
||||
}
|
||||
}
|
||||
if firstBuildWarrantIn(t.Context()).covers(buildSource{Repository: "novox/mesh-delivery", Seat: "git"}, "") {
|
||||
t.Error("1146/AC9: a context with no warrant covers an ask")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// firstBuilds asks the first build of every module a merge adds to a repository no registered module is built from, when
|
||||
// the merge was announced as made on the operator's Approve of that head (novox/hq ADR 0432, issue 1146): the modules'
|
||||
// directories are the ones the announcement says hold a module.json at the merge commit, as touchedBy reads them. The
|
||||
// merge is kept, so the build's outcome is judged by the rule that registers it (mayRegisterFrom, approvedMove).
|
||||
//
|
||||
// **Complete in either order, with no timer** (ADR 0426): when mesh-delivery's word is kept first, it is found here; when
|
||||
// the forge's merge is heard first, the merge is kept as a first merge waiting for the word, and keepingDeliveryMerges
|
||||
// runs this again when the word is kept (resumeFirstBuilds). A word that says no (a Decline, another head) asks nothing and
|
||||
// says why. Answers whether the merge was dealt with here.
|
||||
func (f following) firstBuilds(ctx context.Context, m link.SourceMoved, entries []inventory.Entry,
|
||||
read map[string][]inventory.ReadRepository, now time.Time) (bool, error) {
|
||||
inv := f.open.inventory
|
||||
repository := m.Owner + "/" + m.Repo
|
||||
added, why, err := firstBuildsOf(ctx, inv, m, entries, read)
|
||||
switch {
|
||||
case errors.Is(err, errFirstNotApproved):
|
||||
fmt.Printf("%s merged into %s (%.8s) adds %s, and no build is asked: %v\n", repository, m.Base, m.Commit,
|
||||
strings.Join(addedDirs(m, entries, read), ", "), err)
|
||||
return true, nil
|
||||
case errors.Is(err, errNotAnnounced):
|
||||
// Kept, waiting for mesh-delivery's word: a restart keeps it, and the word's arrival asks the builds.
|
||||
if kerr := keepFirstMerge(ctx, inv, m, now, true); kerr != nil {
|
||||
return true, notNow(kerr)
|
||||
}
|
||||
fmt.Printf("%s merged into %s (%.8s) adds %s; it is kept, and the first build is asked when mesh-delivery "+
|
||||
"announces the merge on the operator's Approve\n", repository, m.Base, m.Commit,
|
||||
strings.Join(addedDirs(m, entries, read), ", "))
|
||||
return true, nil
|
||||
case err != nil:
|
||||
return true, notNow(err)
|
||||
case len(added) == 0:
|
||||
return false, nil
|
||||
}
|
||||
if err := askFirstBuilds(ctx, inv, m, now, added, why); err != nil {
|
||||
return true, notNow(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// keepFirstMerge keeps a first merge as the controller heard it, waiting (no plan answers it).
|
||||
func keepFirstMerge(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, now time.Time, waitingForWord bool) error {
|
||||
merged, err := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||
if err != nil {
|
||||
merged = now
|
||||
}
|
||||
event, err := json.Marshal(keptMerge{SourceMoved: m, FirstBuild: waitingForWord})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = inv.AddMerge(ctx, inventory.BatchedMerge{Repository: m.Owner + "/" + m.Repo, Branch: m.Base, Commit: m.Commit,
|
||||
Merged: merged.UTC(), Heard: now, Event: event})
|
||||
return err
|
||||
}
|
||||
|
||||
// askFirstBuilds keeps the merge, asks the builds, and marks the merge as no longer waiting for them.
|
||||
func askFirstBuilds(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, now time.Time, added []string,
|
||||
why string) error {
|
||||
repository := m.Owner + "/" + m.Repo
|
||||
if err := keepFirstMerge(ctx, inv, m, now, false); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s merged into %s (%.8s) on the operator's Approve (%s): the mesh builds nothing from it yet, so the "+
|
||||
"first build of %s is asked\n", repository, m.Base, m.Commit, why, strings.Join(added, ", "))
|
||||
// The ask carries the warrant just checked, for this repository and these module paths alone: the guard of a build
|
||||
// through a verb (verbMayAsk) lets exactly that through.
|
||||
paths := make([]string, 0, len(added))
|
||||
for _, dir := range added {
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
paths = append(paths, dir)
|
||||
}
|
||||
askBuilds(withFirstBuildWarrant(ctx, repository, paths, why), inv, m, buildSource{Repository: repository, Seat: gitSeat}, added)
|
||||
// Answered: it no longer waits, so the word's arrival again asks nothing twice.
|
||||
return inv.AnswerMerge(ctx, repository, m.Commit, inventory.WaitingRecord, false)
|
||||
}
|
||||
|
||||
// resumeFirstBuilds runs firstBuilds for the first merges waiting for the word mesh-delivery just announced for a pull
|
||||
// request's head: the other order of the same two events.
|
||||
func (f following) resumeFirstBuilds(ctx context.Context, d deliveryMerge) error {
|
||||
actingOnMerges.Lock()
|
||||
defer actingOnMerges.Unlock()
|
||||
inv := f.open.inventory
|
||||
waiting, err := inv.WaitingMerges(ctx)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
for _, w := range waiting {
|
||||
k := announcedOf(w)
|
||||
if !k.FirstBuild || !strings.EqualFold(k.Owner+"/"+k.Repo, d.Owner+"/"+d.Repo) || k.Number != d.Number ||
|
||||
k.HeadSHA != d.HeadSHA {
|
||||
continue
|
||||
}
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
added, why, err := firstBuildsOf(ctx, inv, k.SourceMoved, entries, read)
|
||||
switch {
|
||||
case errors.Is(err, errFirstNotApproved):
|
||||
fmt.Printf("%s/%s merged into %s (%.8s) was waiting for mesh-delivery's word, and no build is asked: %v\n",
|
||||
k.Owner, k.Repo, k.Base, k.Commit, err)
|
||||
if aerr := inv.AnswerMerge(ctx, w.Repository, w.Commit, inventory.WaitingRecord, false); aerr != nil {
|
||||
return notNow(aerr)
|
||||
}
|
||||
case err != nil:
|
||||
return notNow(err)
|
||||
case len(added) > 0:
|
||||
if err := askFirstBuilds(ctx, inv, k.SourceMoved, time.Now().UTC(), added, why); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errFirstNotApproved says a merge into a repository the mesh builds nothing from is not one the operator approved.
|
||||
var errFirstNotApproved = errors.New("it is no merge made on the operator's Approve of its head, so a hand `build` " +
|
||||
"registers a module from a repository the mesh does not build yet")
|
||||
|
||||
// addedDirs are the module directories the merge adds to a repository no registered module is built from, none where
|
||||
// the merge is not into the trunk or some module is built from the repository already.
|
||||
func addedDirs(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) []string {
|
||||
if m.Base != "main" {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.Provided && sameRepository(e.Source.Repository, m) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, added, _ := touchedBy(nil, entries, m, read)
|
||||
return added
|
||||
}
|
||||
|
||||
// firstBuildsOf is the directories whose first build a merge asks, and the warrant it was made on; none when the merge
|
||||
// is no first merge into a repository the mesh builds nothing from. errFirstNotApproved when it is and the word on record
|
||||
// is no Approve of its head; errNotAnnounced when mesh-delivery's word is not kept yet.
|
||||
func firstBuildsOf(ctx context.Context, inv catalogueReader, m link.SourceMoved, entries []inventory.Entry,
|
||||
read map[string][]inventory.ReadRepository) ([]string, string, error) {
|
||||
added := addedDirs(m, entries, read)
|
||||
if len(added) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
why, err := approvedAnnounced(ctx, inv, m.Owner, m.Repo, keptMerge{SourceMoved: m})
|
||||
switch {
|
||||
case err == nil:
|
||||
return added, why, nil
|
||||
case errors.Is(err, errWordNotAnApprove):
|
||||
return nil, "", fmt.Errorf("%w (%v)", errFirstNotApproved, err)
|
||||
}
|
||||
// Not announced yet, or the bus or the store could not be asked: the caller waits or tries again, and asks nothing.
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// probeFirstBuildsID is the probe that reads the first merges waiting for mesh-delivery's word.
|
||||
const probeFirstBuildsID = "D18"
|
||||
|
||||
// kindFirstBuildUnasked is what a first merge waiting too long for mesh-delivery's word raises.
|
||||
const kindFirstBuildUnasked = "first-build-unasked"
|
||||
|
||||
// firstBuildWaitBound is how long a first merge waits for mesh-delivery's announcement of the operator's Approve before
|
||||
// it is said: a bound in the sense of ADR 0426 §3, not a delay. Nothing waits on it: the word's arrival asks the builds
|
||||
// at once, and the merge is said, never dropped, once it is past this (issue 1146).
|
||||
const firstBuildWaitBound = 10 * time.Minute
|
||||
|
||||
// firstBuildObservations are the conditions of the first merges waiting past the bound, one for each; a merge answered is
|
||||
// no longer listed as waiting, so its condition clears with the next look.
|
||||
func firstBuildObservations(waiting []inventory.BatchedMerge, now time.Time) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, w := range waiting {
|
||||
k := announcedOf(w)
|
||||
if !k.FirstBuild || now.Sub(w.Heard) <= firstBuildWaitBound {
|
||||
continue
|
||||
}
|
||||
repository := k.Owner + "/" + k.Repo
|
||||
for_ := now.Sub(w.Heard).Round(time.Minute)
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeMerge,
|
||||
ID: fmt.Sprintf("%s_%.8s", repository, k.Commit), Kind: kindFirstBuildUnasked, Severity: conditions.Warning,
|
||||
Summary: fmt.Sprintf("the merge %.8s of %s adds modules the mesh builds nothing from yet, and has waited %s for "+
|
||||
"mesh-delivery's announcement of the operator's Approve of its head: no build is asked", k.Commit, repository, for_),
|
||||
Said: fmt.Sprintf("%s at %s waits for mesh-delivery's word on pull request %d (head %.8s)", repository, k.Commit,
|
||||
k.Number, k.HeadSHA),
|
||||
Headline: fmt.Sprintf("The first build of %s is not asked: no Approve was announced", repository),
|
||||
Explanation: fmt.Sprintf("The merge %.8s into %s adds modules the mesh builds nothing from yet. It registers them "+
|
||||
"only when mesh-delivery announces that it merged the pull request on your Approve of that head, and no such "+
|
||||
"announcement has come in %s. The merge is kept, and the builds are asked the moment the announcement is kept.",
|
||||
k.Commit, repository, for_),
|
||||
Resolved: "mesh-delivery announces the merge, or a hand `build` registers the modules",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probeFirstBuilds is D18: no first merge waits past its bound for mesh-delivery's word unsaid.
|
||||
func probeFirstBuilds(ctx context.Context, d *doctor) ([]conditions.Observation, error) {
|
||||
waiting, err := d.open.inventory.WaitingMerges(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return firstBuildObservations(waiting, time.Now().UTC()), nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// 1146/AC7, whole against a real bus and the store, in both orders of the two events (novox/hq ADR 0432, issue 1146):
|
||||
// mesh-delivery #7 merged on the operator's Approve into a repository the mesh builds nothing from. The forge's merge,
|
||||
// heard as the controller hears it (SourceMoved), and mesh-delivery's word, published on the subject the controller
|
||||
// consumes with the router's record of the ask in its bucket, come in either order; each way the first build of the
|
||||
// module the merge commit holds is asked exactly once, its outcome registers the module, and it is in the catalogue.
|
||||
// Without the Approve in the record nothing is asked. Store-backed: skips without one.
|
||||
func TestAMergeOnTheApproveIntoANewRepositoryAsksTheFirstBuildAndItsOutcomeRegistersTheModule(t *testing.T) {
|
||||
const head, commit = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "0177ea7a00000000"
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
wordFirst bool
|
||||
approved bool // the router's record holds the operator's Approve of the merged head
|
||||
wantRegiter bool
|
||||
}{
|
||||
{what: "the forge's merge before mesh-delivery's word", approved: true, wantRegiter: true},
|
||||
{what: "mesh-delivery's word before the forge's merge", wordFirst: true, approved: true, wantRegiter: true},
|
||||
{what: "a word whose record holds no Approve", wordFirst: true},
|
||||
} {
|
||||
t.Run(c.what, func(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
// The real ask, guard and queue included: only its answers are kept, so the build's outcome can be matched.
|
||||
aForgeAndABuildSeat(t, open)
|
||||
// Written by the controller's merge path on its own goroutine, read by the test: under one lock.
|
||||
var mu sync.Mutex
|
||||
var asks [][3]string
|
||||
var ids []string
|
||||
asked := func() [][3]string { mu.Lock(); defer mu.Unlock(); return slices.Clone(asks) }
|
||||
realAsk := askABuild
|
||||
askABuild = func(c context.Context, source buildSource, path, ref string) (string, error) {
|
||||
id, err := realAsk(c, source, path, ref)
|
||||
if err == nil {
|
||||
mu.Lock()
|
||||
asks = append(asks, [3]string{source.Repository, path, ref})
|
||||
ids = append(ids, id)
|
||||
mu.Unlock()
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
t.Cleanup(func() { askABuild = realAsk })
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "operator-channel", Version: "1",
|
||||
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.AsksSeat, Records: []string{"asks"}}}},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/operator-channel", BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
js, err := broker.Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: broker.BucketName("operator-channel", "asks")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
|
||||
if c.approved {
|
||||
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
||||
if _, err := records.Put(ctx, theDelivery+"."+a.merge.AskID, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
following := following{open: open}
|
||||
server := link.ConnectNats(js, nil, noListener{})
|
||||
if err := server.KeepsDeliveryMerges(keepingDeliveryMerges{js: server.JetStream, catalog: theDeliveryClaims, kept: following.resumeFirstBuilds}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := broker.AssertServingConsumers(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serving, stop := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); _ = server.Serve(serving) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
servingBus.Store(js)
|
||||
t.Cleanup(func() { servingBus.Store(nil) })
|
||||
|
||||
word := func() {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(a.merge)
|
||||
if _, err := js.Context().Publish(broker.DeliveryMergedSubjectOf(theDelivery), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
becomes(t, 15*time.Second, "the word being kept", func() bool {
|
||||
_, kept, err := keptDeliveryMerge(ctx, "novox", "mesh-delivery", 7, head)
|
||||
return err == nil && kept
|
||||
})
|
||||
time.Sleep(500 * time.Millisecond) // the keeper's hook runs as the word is kept
|
||||
}
|
||||
forge := func() {
|
||||
t.Helper()
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: commit, Number: 7,
|
||||
MergedAt: time.Now().Add(-time.Second).UTC().Format(time.RFC3339Nano), Paths: []string{"module.json", "main.go"},
|
||||
ModuleDirs: []string{"."}, ModuleDirsSaid: true}
|
||||
if err := following.SourceMoved(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if c.wordFirst {
|
||||
word()
|
||||
forge()
|
||||
} else {
|
||||
forge()
|
||||
if len(asked()) != 0 {
|
||||
t.Fatalf("1146/AC7: the merge asked %v before mesh-delivery's word was kept", asked())
|
||||
}
|
||||
word()
|
||||
}
|
||||
if !c.wantRegiter {
|
||||
if len(asked()) != 0 {
|
||||
t.Fatalf("1146/AC7: a merge whose record holds no Approve asked %v", asked())
|
||||
}
|
||||
return
|
||||
}
|
||||
want := [3]string{"novox/mesh-delivery", "", "main"}
|
||||
becomes(t, 10*time.Second, "the first build asked", func() bool { return len(asked()) >= 1 })
|
||||
if got := asked(); len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("1146/AC7: the merge asked %v, want %v once", got, want)
|
||||
}
|
||||
mu.Lock()
|
||||
first := ids[0]
|
||||
mu.Unlock()
|
||||
build := onTrunk(first, "novox/mesh-delivery", "git", "", map[string]any{"module": "mesh-delivery-view", "version": "1"})
|
||||
build.Commit = commit
|
||||
if _, _, err := takeIn(ctx, inv, build); err != nil {
|
||||
t.Fatalf("1146/AC7: the outcome of the first build was refused: %v", err)
|
||||
}
|
||||
src, err := inv.SourceOf(ctx, "mesh-delivery-view")
|
||||
if err != nil || src.Repository != "novox/mesh-delivery" {
|
||||
t.Fatalf("1146/AC7: the module is built from %q (%v)", src.Repository, err)
|
||||
}
|
||||
if _, known := inventoryHolds(t, inv, "mesh-delivery-view"); !known {
|
||||
t.Fatal("1146/AC7: the module is not in the catalogue")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func inventoryHolds(t *testing.T, inv *inventory.Inventory, module string) (inventory.Entry, bool) {
|
||||
t.Helper()
|
||||
entries, err := inv.Catalogued(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Manifest.Module == module {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return inventory.Entry{}, false
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// 1146/AC6: the first merge into a repository no registered module is built from asks the first build of every module
|
||||
// the merge commit holds when it was made on the operator's Approve of that head, and asks nothing without it.
|
||||
func TestAFirstMergeIntoANewRepositoryAsksItsModulesBuildsOnlyOnTheApprove(t *testing.T) {
|
||||
const head = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||
now := time.Date(2026, 10, 19, 0, 6, 0, 0, time.UTC)
|
||||
mergedAt := now.Add(-time.Hour).Format(time.RFC3339Nano)
|
||||
heard := link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main", HeadSHA: head, Commit: "0177ea7a",
|
||||
Number: 1, MergedAt: mergedAt, ModuleDirsSaid: true, ModuleDirs: []string{"watchman", "view"},
|
||||
Paths: []string{"view/module.json", "watchman/module.json", "README.md"}}
|
||||
built := inventory.Entry{Source: inventory.Source{Repository: "novox/mesh-catalog", Seat: "git"}}
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
change func(*anApprovedMerge, *link.SourceMoved, *[]inventory.Entry)
|
||||
unkept bool
|
||||
dirs []string
|
||||
refused bool
|
||||
waits bool
|
||||
}{
|
||||
{what: "the Approve of the merged head", dirs: []string{"view", "watchman"}},
|
||||
{what: "a Decline", refused: true, change: func(a *anApprovedMerge, _ *link.SourceMoved, _ *[]inventory.Entry) {
|
||||
a.warrant.Option, a.warrant.Label, a.merge.Option = "decline", "Decline", "decline"
|
||||
}},
|
||||
{what: "no word of mesh-delivery yet, however long ago the merge was made", unkept: true, waits: true},
|
||||
{what: "a module already built from the repository", change: func(_ *anApprovedMerge, _ *link.SourceMoved, e *[]inventory.Entry) {
|
||||
*e = append(*e, inventory.Entry{Source: inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}})
|
||||
}},
|
||||
{what: "a merge into another branch", change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) { m.Base = "feat" }},
|
||||
{what: "a merge that holds no module.json", change: func(_ *anApprovedMerge, m *link.SourceMoved, _ *[]inventory.Entry) {
|
||||
m.ModuleDirs, m.Paths = nil, []string{"README.md"}
|
||||
}},
|
||||
} {
|
||||
t.Run(c.what, func(t *testing.T) {
|
||||
a := aDeliveryMergeOf(t, head, map[string]string{"novox/mesh-delivery@a1a1a1a1a1a1": head})
|
||||
a.merge.Number = 1
|
||||
m, entries := heard, []inventory.Entry{built}
|
||||
if c.change != nil {
|
||||
c.change(&a, &m, &entries)
|
||||
}
|
||||
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
|
||||
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
|
||||
keptDeliveryMerge = func(context.Context, string, string, int, string) (deliveryMerge, bool, error) {
|
||||
return a.merge, !c.unkept, nil
|
||||
}
|
||||
routerAskOf = func(context.Context, catalogueReader, string, string) (*asks.Ask, *asks.Warrant, error) {
|
||||
return &a.ask, &a.warrant, nil
|
||||
}
|
||||
dirs, _, err := firstBuildsOf(t.Context(), nil, m, entries, nil)
|
||||
switch {
|
||||
case c.waits && !errors.Is(err, errNotAnnounced):
|
||||
t.Fatalf("1146/AC6: %s: want a wait for the word, got %v", c.what, err)
|
||||
case c.refused && !errors.Is(err, errFirstNotApproved):
|
||||
t.Fatalf("1146/AC6: %s: want the Approve missing, got %v (dirs %v)", c.what, err, dirs)
|
||||
case !c.refused && !c.waits && err != nil:
|
||||
t.Fatalf("1146/AC6: %s: %v", c.what, err)
|
||||
case !slices.Equal(dirs, c.dirs):
|
||||
t.Fatalf("1146/AC6: %s: first builds asked of %v, want %v", c.what, dirs, c.dirs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1146/AC8: a first merge waiting past the bound for mesh-delivery's word is said, naming the repository, the commit and
|
||||
// the missing word; one inside the bound, one that is no first merge, and one answered (no longer waiting) are not.
|
||||
func TestAFirstMergeWaitingPastItsBoundIsSaidAndClearsWhenAnswered(t *testing.T) {
|
||||
now := time.Date(2026, 10, 19, 1, 0, 0, 0, time.UTC)
|
||||
waiting := func(commit string, first bool, heard time.Time) inventory.BatchedMerge {
|
||||
event, _ := json.Marshal(keptMerge{SourceMoved: link.SourceMoved{Owner: "novox", Repo: "mesh-delivery", Base: "main",
|
||||
Commit: commit, Number: 1, HeadSHA: "a1a1a1a1a1a1"}, FirstBuild: first})
|
||||
return inventory.BatchedMerge{Repository: "novox/mesh-delivery", Branch: "main", Commit: commit, Heard: heard, Event: event}
|
||||
}
|
||||
got := firstBuildObservations([]inventory.BatchedMerge{
|
||||
waiting("0177ea7a00", true, now.Add(-firstBuildWaitBound-time.Minute)),
|
||||
waiting("1111111100", true, now.Add(-time.Minute)),
|
||||
waiting("2222222200", false, now.Add(-time.Hour)),
|
||||
}, now)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("1146/AC8: %d conditions raised, want the one merge past the bound: %+v", len(got), got)
|
||||
}
|
||||
o := got[0]
|
||||
if o.Kind != kindFirstBuildUnasked || o.Scope != conditions.ScopeMerge || o.Severity != conditions.Warning ||
|
||||
!strings.Contains(o.Summary, "novox/mesh-delivery") || !strings.Contains(o.Summary, "0177ea7a") ||
|
||||
!strings.Contains(o.Summary, "announcement of the operator's Approve") {
|
||||
t.Fatalf("1146/AC8: the condition is %+v", o)
|
||||
}
|
||||
// Answered, the merge is no longer listed as waiting: nothing is observed, and the condition clears.
|
||||
if cleared := firstBuildObservations(nil, now); len(cleared) != 0 {
|
||||
t.Fatalf("1146/AC8: an answered merge still raises %+v", cleared)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -23,6 +23,7 @@ func TestAChangePlanSaysTheClassOfWhatItMoves(t *testing.T) {
|
||||
resolver := entry("dnsmasq", "modules/dnsmasq")
|
||||
resolver.Manifest.Claims = []catalogue.Claim{{Name: resolverSeat}}
|
||||
delivery := entry(catalogue.DeliverySeat, "modules/mesh-delivery")
|
||||
delivery.Manifest.Claims = []catalogue.Claim{{Name: catalogue.DeliverySeat}}
|
||||
webApp := entry("photos", "modules/photos")
|
||||
entries := []inventory.Entry{gitea, resolver, delivery, webApp}
|
||||
// photos stands on gitea: a change to gitea moves photos as its dependent.
|
||||
@@ -44,7 +45,7 @@ func TestAChangePlanSaysTheClassOfWhatItMoves(t *testing.T) {
|
||||
{"a leaf and a core module together: the slowest-budgeted", []string{"modules/gitea/index.ts", "modules/dnsmasq/module.json"}, inventory.ClassCore},
|
||||
{"nothing any module's build reads", []string{"README.md"}, "none"},
|
||||
{"a new leaf module alone", []string{"modules/weather/module.json"}, inventory.ClassLeaf},
|
||||
{"a new module on the controller's own path, by its own class", []string{"next/mesh-delivery/module.json"}, inventory.ClassCore},
|
||||
{"a new module on the controller's own path, by its own class", []string{"next/mesh-host/module.json"}, inventory.ClassCore},
|
||||
{"a new leaf module beside a core one moved", []string{"modules/weather/module.json", "modules/dnsmasq/module.json"}, inventory.ClassCore},
|
||||
} {
|
||||
if got := plan(c.paths...).Class; got != c.want {
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/micro"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
@@ -69,7 +69,7 @@ var (
|
||||
// own path, or one holding the mesh's resolver. Every other module is a leaf. Its class sets the module's
|
||||
// delivery budget; how many readings its gate takes is gateRuleOf's.
|
||||
func coreModule(module string, m catalogue.Manifest) bool {
|
||||
if _, own := onTheControllersPath[module]; own {
|
||||
if onTheOwnPath(module, m) {
|
||||
return true
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
@@ -106,7 +106,7 @@ func gateRuleOf(module string, m catalogue.Manifest) gateRule {
|
||||
case coreComponent(module) == lease.ComponentNodeTools:
|
||||
return gateRule{passes: 1, witnessed: true, said: "the tool runner's rule: one reading once it answers, " +
|
||||
"after the node-engine's witness window (novox/hq ADR 0351)"}
|
||||
case module == catalogue.DeliverySeat:
|
||||
case m.ClaimsSeat(catalogue.DeliverySeat):
|
||||
return gateRule{passes: 1, said: "mesh-delivery's rule: one reading (novox/hq ADR 0351)"}
|
||||
case coreModule(module, m):
|
||||
return gateRule{passes: gatePasses, within: gateWithin, said: fmt.Sprintf("the core's rule: %d readings, "+
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user