Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd20b40dfe | ||
|
|
3d49a971a1 |
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -55,8 +55,6 @@ 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
|
||||
@@ -64,7 +62,7 @@ func consumersFrom(ctx context.Context, open *stores, provider, providerModule,
|
||||
var out []inventory.PairEnd
|
||||
var unresolved []string
|
||||
for _, n := range nodes {
|
||||
plan, _, err := resolveNode(ctx, open, n.Name)
|
||||
plan, _, err := planFor(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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/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 := resolveNode(ctx, open, node)
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err == nil {
|
||||
out := afterAct{plan: plan, unheld: plan.Unheld}
|
||||
for _, m := range plan.Modules {
|
||||
@@ -355,8 +355,6 @@ 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()
|
||||
@@ -366,7 +364,7 @@ func blockedElsewhere(ctx context.Context, open *stores, except string) string {
|
||||
if n.Name == except {
|
||||
continue
|
||||
}
|
||||
if _, _, err := resolveNode(ctx, open, n.Name); err != nil {
|
||||
if _, _, err := planFor(ctx, open, n.Name); err != nil {
|
||||
blocked[n.Name] = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// What an assignment says about the machines it was not about.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"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/overlay"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0100: taking a module is its cutover; converging a node is one act, previewed, and
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"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 +243,7 @@ func take(ctx context.Context, open *stores, node, module string, opts takeOptio
|
||||
return "", err
|
||||
}
|
||||
if !slices.Contains(assigned, module) {
|
||||
if plan, _, err := resolveNode(ctx, open, node); err == nil {
|
||||
if plan, _, err := planFor(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 +649,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plan, _, err := resolveNode(ctx, open, node)
|
||||
plan, settings, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -706,8 +706,11 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
filter)
|
||||
}
|
||||
|
||||
// What the filter is derived from: the rendering of this machine as a question reads it, with the private network.
|
||||
with, err := renderingOf(ctx, open, node)
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Reading)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -773,7 +776,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 := resolveNode(ctx, open, node); err != nil {
|
||||
if _, _, err := planFor(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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,11 +8,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
func TestAModuleAssignedAndNotPushedIsAwaitingAPushNotUncomposable(t *testing.T) {
|
||||
@@ -26,13 +25,21 @@ func TestAModuleAssignedAndNotPushedIsAwaitingAPushNotUncomposable(t *testing.T)
|
||||
|
||||
// The read the rest of the mesh asks still refuses it, with the composer's typed error: nothing
|
||||
// can be compared about a secret that does not exist (status), and nothing here string-matches.
|
||||
_, _, err := composeQuestion(ctx, open, "laptop", compose.Reading, false)
|
||||
plan, settings, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = declarationWith(ctx, open, "laptop", plan, settings, gens, Reading)
|
||||
var notMade *catalogue.NotMadeError
|
||||
if !errors.As(err, ¬Made) || notMade.Module != "keeper" || notMade.Name != "repository" {
|
||||
t.Fatalf("a read composition did not say which secret is not made, typed: %v", err)
|
||||
}
|
||||
// Foreseen, it composes, names what the push makes, and made nothing.
|
||||
foreseen, _, err := composeQuestion(ctx, open, "laptop", compose.Foreseeing, false)
|
||||
foreseen, err := declarationWith(ctx, open, "laptop", plan, settings, gens, Foreseeing)
|
||||
if err != nil || len(foreseen.foreseen) != 1 || foreseen.foreseen[0] != "keeper/repository" {
|
||||
t.Fatalf("foreseen: %v %v", foreseen.foreseen, err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
@@ -29,7 +29,7 @@ func aKeeper() catalogue.Manifest {
|
||||
func pushedBy(t *testing.T, open *stores, node string) string {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
plan, settings, err := resolveNode(ctx, open, node)
|
||||
plan, settings, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,10 +54,11 @@ func pushedBy(t *testing.T, open *stores, node string) string {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
builds = map[string]string{}
|
||||
names := make([]string, 0, len(plan.Modules))
|
||||
for _, m := range plan.Modules {
|
||||
builds[m.Module] = current[m.Module].Commit
|
||||
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)
|
||||
|
||||
@@ -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/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -124,52 +124,6 @@ func durationSetting(v any) (time.Duration, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// mergeFacts is what hearMerge judges a merge by, kept apart so the catch-up (ADR 0457) judges a merge the way hearing it will.
|
||||
type mergeFacts struct {
|
||||
merged time.Time
|
||||
touches []inventory.Entry
|
||||
later []inventory.BatchedMerge
|
||||
owed bool
|
||||
from, packaging []inventory.Entry
|
||||
already int
|
||||
addsModuleToBuilds bool // a first merge into a repository the mesh builds nothing from: firstBuilds acts on it
|
||||
}
|
||||
|
||||
// factsOfMerge reads what hearing a merge turns on: the modules it touches whatever the history, the later merges of its
|
||||
// branch, whether it is owed late, and the modules it moves.
|
||||
func factsOfMerge(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, now time.Time,
|
||||
entries []inventory.Entry, read map[string][]inventory.ReadRepository) (mergeFacts, error) {
|
||||
repository := m.Owner + "/" + m.Repo
|
||||
f := mergeFacts{}
|
||||
var err error
|
||||
f.merged, err = time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||
if err != nil {
|
||||
f.merged = now
|
||||
}
|
||||
// **What it touches, history or not** (review of this change): a merge heard after a later merge of its
|
||||
// branch was cut reads as history for every module that cut marked seen, and was dropped from the record
|
||||
// here, never named by the walk that carried it.
|
||||
raw := m
|
||||
raw.MergedAt = ""
|
||||
f.touches = wouldMove(raw, entries, read)
|
||||
if len(f.touches) > 0 {
|
||||
if f.later, err = inv.LaterMergesOf(ctx, repository, m.Base, f.merged.UTC()); err != nil {
|
||||
return f, err
|
||||
}
|
||||
}
|
||||
if f.owed, err = owedLate(ctx, inv, m, f.merged, f.touches); err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.from, f.packaging, f.already = mergeCandidates(m, entries, read)
|
||||
f.addsModuleToBuilds = f.already == 0 && len(addedDirs(m, entries, read)) > 0
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// plans says hearing the merge puts it in a batch or a walk.
|
||||
func (f mergeFacts) plans() bool {
|
||||
return len(f.from) > 0 || len(f.packaging) > 0 || len(f.later) > 0 || f.owed
|
||||
}
|
||||
|
||||
// hearMerge puts a merge the forge announced into the batch that answers it, at the moment it was heard,
|
||||
// and cuts what is due.
|
||||
func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.Time) error {
|
||||
@@ -194,13 +148,27 @@ func (f following) hearMerge(ctx context.Context, m link.SourceMoved, now time.T
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
facts, err := factsOfMerge(ctx, inv, m, now, entries, read)
|
||||
merged, err := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||
if err != nil {
|
||||
merged = now
|
||||
}
|
||||
// **What it touches, history or not** (review of this change): a merge heard after a later merge of its
|
||||
// branch was cut reads as history for every module that cut marked seen, and was dropped from the record
|
||||
// here, never named by the walk that carried it.
|
||||
raw := m
|
||||
raw.MergedAt = ""
|
||||
touches := wouldMove(raw, entries, read)
|
||||
var later []inventory.BatchedMerge
|
||||
if len(touches) > 0 {
|
||||
if later, err = inv.LaterMergesOf(ctx, repository, m.Base, merged.UTC()); err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
}
|
||||
owed, err := owedLate(ctx, inv, m, merged, touches)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
merged, touches := facts.merged, facts.touches
|
||||
if !facts.plans() {
|
||||
already := facts.already
|
||||
if from, packaging, already := mergeCandidates(m, entries, read); len(from) == 0 && len(packaging) == 0 && len(later) == 0 && !owed {
|
||||
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
||||
// as the second sends somebody looking for a broken trigger when the mesh is up to date.
|
||||
if already > 0 {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
@@ -73,8 +73,6 @@ 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 {
|
||||
@@ -82,7 +80,7 @@ func probeBindings(ctx context.Context, d *doctor) ([]conditions.Observation, er
|
||||
}
|
||||
var out []conditions.Observation
|
||||
for _, n := range nodes {
|
||||
plan, _, err := resolveNode(ctx, d.open, n.Name)
|
||||
plan, _, err := planFor(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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T)
|
||||
assignAll([2]string{"laptop", "store"}, [2]string{"laptop", "resolver"}, [2]string{"laptop", "network"},
|
||||
[2]string{"laptop", "board"})
|
||||
|
||||
plan, _, err := resolveNode(ctx, open, "laptop")
|
||||
plan, _, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T)
|
||||
if err := inv.Unassign(ctx, "laptop", "store"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := resolveNode(ctx, open, "laptop"); err == nil || !unresolvable(err) ||
|
||||
if _, _, err := planFor(ctx, open, "laptop"); err == nil || !unresolvable(err) ||
|
||||
!strings.Contains(err.Error(), "board on laptop is bound to laptop/store") ||
|
||||
!strings.Contains(err.Error(), "pin laptop postgres-database anchor store") {
|
||||
t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err)
|
||||
@@ -74,7 +74,7 @@ func TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T)
|
||||
if err := inv.PinProvision(ctx, "laptop", "postgres-database", "anchor", "store"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, _, err = resolveNode(ctx, open, "laptop")
|
||||
plan, _, err = planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
)
|
||||
|
||||
|
||||
@@ -15,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"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/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 := restOfTheMeshOf(ctx, inv, shelf, ""); err == nil {
|
||||
if read, err := theRestOfTheMesh(ctx, inv, shelf, ""); err == nil {
|
||||
w = read
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,647 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// **The controller catches up on merges it did not hear** (novox/hq ADR 0457, issue 1336). A merge the forge announced while the
|
||||
// controller was being replaced was never heard, and nothing asked for it again: the trunk held a merge no walk answered. At its
|
||||
// start, and each time it hears a merge, the controller reads the last catchUpWindow merge commits of a trunk from the forge, and
|
||||
// hears each one it holds no record of as if the forge had announced it then, on the warrant the router holds for it.
|
||||
// It lasts until release 6 of design 47 deletes delivery plans and batches (ADR 0429 §7, §8).
|
||||
|
||||
// catchUpWindow is how many of a trunk's newest merge commits are read; an older merge is a person's to decide.
|
||||
const catchUpWindow = 50
|
||||
|
||||
// kindMergeNotHeard is the condition of a merge the controller did not hear and holds no warrant to plan.
|
||||
const kindMergeNotHeard = "merge-not-heard"
|
||||
|
||||
// trunkMerge is a merge commit of a trunk as the forge lists it.
|
||||
type trunkMerge struct {
|
||||
Commit string
|
||||
// Head is the merge commit's second parent: the pull request's head that was merged.
|
||||
Head string
|
||||
Subject string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// trunkCommit is a commit of a trunk as the forge lists it, newest first: a merge commit has a second parent.
|
||||
type trunkCommit struct {
|
||||
Commit, SecondParent, Subject string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// trunkCommitsPage is one page (fifty) of a trunk's commits, newest first; empty past the last. A variable so a test needs no
|
||||
// forge.
|
||||
var trunkCommitsPage = func(ctx context.Context, owner, repo, branch string, page int) ([]trunkCommit, error) {
|
||||
var found struct {
|
||||
Result []struct {
|
||||
SHA string `json:"sha"`
|
||||
Parents []struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"parents"`
|
||||
Commit struct {
|
||||
Message string `json:"message"`
|
||||
Committer struct {
|
||||
Date string `json:"date"`
|
||||
} `json:"committer"`
|
||||
} `json:"commit"`
|
||||
} `json:"result"`
|
||||
}
|
||||
apiPath := fmt.Sprintf("/repos/%s/%s/commits?sha=%s&limit=50&page=%d&stat=false&verification=false&files=false",
|
||||
owner, repo, url.QueryEscape(branch), page)
|
||||
missing, err := askForgeApi(ctx, apiPath, &found)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if missing {
|
||||
return nil, fmt.Errorf("the forge holds no branch %s of %s/%s", branch, owner, repo)
|
||||
}
|
||||
var out []trunkCommit
|
||||
for _, c := range found.Result {
|
||||
at, _ := time.Parse(time.RFC3339, c.Commit.Committer.Date)
|
||||
tc := trunkCommit{Commit: c.SHA, Subject: firstLine(c.Commit.Message), At: at.UTC()}
|
||||
if len(c.Parents) >= 2 {
|
||||
tc.SecondParent = c.Parents[1].SHA
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readTrunkMerges is the last catchUpWindow merge commits of a repository's trunk, oldest first: pages are read until that many
|
||||
// merges are held or the trunk ends.
|
||||
func readTrunkMerges(ctx context.Context, owner, repo, branch string) ([]trunkMerge, error) {
|
||||
var merges []trunkMerge
|
||||
for page := 1; page <= 8 && len(merges) < catchUpWindow; page++ {
|
||||
commits, err := trunkCommitsPage(ctx, owner, repo, branch, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(commits) == 0 {
|
||||
break
|
||||
}
|
||||
for _, c := range commits {
|
||||
// A merge of the trunk is a pull request's merge into it. A commit with a second parent that is none is a merge made
|
||||
// inside some branch ("Merge remote-tracking branch 'origin/main' into fix/..."), which the trunk's history lists too and
|
||||
// which no Approve ever covered: read as a gap it would raise a condition for nothing a person decides.
|
||||
if c.SecondParent != "" && mergesIntoTrunk(c.Subject, branch) {
|
||||
merges = append(merges, trunkMerge{Commit: c.Commit, Head: c.SecondParent, Subject: c.Subject, At: c.At})
|
||||
}
|
||||
}
|
||||
}
|
||||
return lastMerges(merges), nil
|
||||
}
|
||||
|
||||
// sinceTheRecord are the merges that can be gaps (novox/hq ADR 0463 §2): those made since the controller's record of merges begins on
|
||||
// the mesh (first, the earliest merge it holds of any repository and branch, less mergeGrace as owedLate judges). A merge before it is
|
||||
// history on every trunk, and a trunk with no merge kept of its own still has the merges made since as gaps. With no merge kept
|
||||
// anywhere there is no record to have a gap in.
|
||||
func sinceTheRecord(merges []trunkMerge, kept bool, first time.Time) []trunkMerge {
|
||||
if !kept {
|
||||
return nil
|
||||
}
|
||||
var out []trunkMerge
|
||||
for _, m := range merges {
|
||||
if !m.At.Before(first.Add(-mergeGrace)) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lastMerges is the newest catchUpWindow of merges, given newest first, oldest first.
|
||||
func lastMerges(newestFirst []trunkMerge) []trunkMerge {
|
||||
if len(newestFirst) > catchUpWindow {
|
||||
newestFirst = newestFirst[:catchUpWindow]
|
||||
}
|
||||
out := slices.Clone(newestFirst)
|
||||
slices.Reverse(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var mergeSubject = regexp.MustCompile(`^Merge pull request '(.*)' \(#(\d+)\) from (\S+) into (\S+)$`)
|
||||
|
||||
// pullOfSubject is the pull request a merge commit's subject names: its title and number.
|
||||
func pullOfSubject(subject string) (title string, number int, ok bool) {
|
||||
m := mergeSubject.FindStringSubmatch(strings.TrimSpace(subject))
|
||||
if m == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(m[2])
|
||||
if err != nil || n <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return m[1], n, true
|
||||
}
|
||||
|
||||
// mergesIntoTrunk says a commit's subject is a pull request's merge into this trunk.
|
||||
func mergesIntoTrunk(subject, branch string) bool {
|
||||
m := mergeSubject.FindStringSubmatch(strings.TrimSpace(subject))
|
||||
return m != nil && m[4] == branch
|
||||
}
|
||||
|
||||
// headBranchOfSubject is the branch a merge commit's subject says was merged.
|
||||
func headBranchOfSubject(subject string) string {
|
||||
if m := mergeSubject.FindStringSubmatch(strings.TrimSpace(subject)); m != nil {
|
||||
return m[3]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// readMergeEvent is the merge as the forge would have announced it: the commit, the pull request, the files it changed and the
|
||||
// directories among them that hold a module.json. A variable so a test needs no forge.
|
||||
var readMergeEvent = func(ctx context.Context, owner, repo, branch string, tm trunkMerge) (link.SourceMoved, error) {
|
||||
m := link.SourceMoved{Owner: owner, Repo: repo, Base: branch, Head: headBranchOfSubject(tm.Subject), HeadSHA: tm.Head,
|
||||
Commit: tm.Commit, MergedAt: tm.At.UTC().Format(time.RFC3339)}
|
||||
if title, number, ok := pullOfSubject(tm.Subject); ok {
|
||||
m.Number, m.Title = number, title
|
||||
}
|
||||
var found struct {
|
||||
Result struct {
|
||||
Files []struct {
|
||||
Filename string `json:"filename"`
|
||||
Status string `json:"status"`
|
||||
} `json:"files"`
|
||||
} `json:"result"`
|
||||
}
|
||||
missing, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/git/commits/"+url.PathEscape(tm.Commit), &found)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
if missing || len(found.Result.Files) == 0 {
|
||||
return m, fmt.Errorf("the forge named no files for the merge %.8s of %s/%s", tm.Commit, owner, repo)
|
||||
}
|
||||
for _, f := range found.Result.Files {
|
||||
m.Paths = append(m.Paths, f.Filename)
|
||||
if f.Status == "removed" || f.Status == "deleted" {
|
||||
m.Removed = append(m.Removed, f.Filename)
|
||||
}
|
||||
}
|
||||
// The forge lists a merge's files by the page; a list as long as a page may be a beginning.
|
||||
m.PathsTruncated = len(m.Paths) >= 300
|
||||
dirs := map[string]bool{}
|
||||
for _, p := range m.Paths {
|
||||
for d := path.Dir(p); ; d = path.Dir(d) {
|
||||
dirs[d] = true
|
||||
if d == "." || d == "/" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dirs) > 64 {
|
||||
return m, nil // too many to look at: the old rule stands, a directory is a module when its manifest changed
|
||||
}
|
||||
var holding []string
|
||||
for d := range dirs {
|
||||
file := "module.json"
|
||||
if d != "." {
|
||||
file = d + "/module.json"
|
||||
}
|
||||
_, exists, err := manifestCached(ctx, owner, repo, file, tm.Commit)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
if exists {
|
||||
holding = append(holding, d)
|
||||
}
|
||||
}
|
||||
sort.Strings(holding)
|
||||
m.ModuleDirs, m.ModuleDirsSaid = holding, true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// mergeAskPrefix is what the id of a merge ask made by the retired module mesh-delivery begins with: the pull request's number
|
||||
// and the head's first twelve digits (`merge-<number>-<head>-<try>`). The teller's ids name neither (see findMergeWarrant).
|
||||
func mergeAskPrefix(number int, head string) string {
|
||||
if len(head) > 12 {
|
||||
head = head[:12]
|
||||
}
|
||||
return fmt.Sprintf("merge-%d-%s-", number, head)
|
||||
}
|
||||
|
||||
// askersOfMerges are the askers a merge's warrant is read under: the teller, and mesh-delivery for a merge before release 3e
|
||||
// (ADR 0437 §6). Release 4's controller work stops accepting mesh-delivery (ADR 0450 §8); a merge asked under it earlier is
|
||||
// still found here until then.
|
||||
func askersOfMerges() []string { return []string{sdkdelivery.TellerModule, theDelivery} }
|
||||
|
||||
// caughtUpWarrant judges a chosen merge ask of the router as the warrant of a merge: the ask is the operator's, the warrant
|
||||
// answers that very ask as it was published, and it is the operator's Approve proved by the channel it came through (ADR 0437
|
||||
// §6, as movedOnApprove judges an announced one). The heads the Approve was bound to are checked by the caller, where it can
|
||||
// name them.
|
||||
func caughtUpWarrant(asker string, ask *asks.Ask, w *asks.Warrant) (string, error) {
|
||||
if err := askerAccepted(asker); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case ask == nil || w == nil:
|
||||
return "", errors.New("the router holds no closed record of the ask")
|
||||
case ask.Who != asks.Operator:
|
||||
return "", fmt.Errorf("the ask %s is for %q to answer, not the operator", ask.ID, ask.Who)
|
||||
}
|
||||
option, err := w.For(asker, *ask)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if option.ID != optionApprove || w.Option != optionApprove {
|
||||
return "", fmt.Errorf("the operator chose %q on the ask %s, not an Approve", w.Option, w.Ask)
|
||||
}
|
||||
if w.By == nil || w.By.Who != asks.Operator || w.By.Verified == "" {
|
||||
return "", fmt.Errorf("the ask %s was not answered by the operator on a channel that proves who answered", w.Ask)
|
||||
}
|
||||
return w.Says() + " (ask " + w.Ask + ")", nil
|
||||
}
|
||||
|
||||
// routerAskIDs are the ids of the asks an asker has in the router's record. A variable so a test needs no bus.
|
||||
var routerAskIDs = func(ctx context.Context, inv catalogueReader, asker string) ([]string, error) {
|
||||
serving := servingBus.Load()
|
||||
if serving == nil {
|
||||
return nil, errors.New("only the serving controller reads the router's record")
|
||||
}
|
||||
bucket, err := asksRecords(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bucket == "" {
|
||||
return nil, errors.New("no module declares the operator channel's records")
|
||||
}
|
||||
api, err := jetstream.New(serving.Conn())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return askIDsOf(ctx, kv, asker)
|
||||
}
|
||||
|
||||
// askIDsOf are the ids of the asks an asker has in the router's bucket.
|
||||
func askIDsOf(ctx context.Context, kv jetstream.KeyValue, asker string) ([]string, error) {
|
||||
lister, err := kv.ListKeysFiltered(ctx, asker+".>")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for key := range lister.Keys() {
|
||||
ids = append(ids, strings.TrimPrefix(key, asker+"."))
|
||||
}
|
||||
// The lister stops its own watch when it has sent the last key, so a stop of ours finds it stopped: nats answers that with
|
||||
// "invalid subscription", which is no failure of the read (issue 1336). Any other error stays one.
|
||||
if err := lister.Stop(); err != nil && !errors.Is(err, nats.ErrBadSubscription) {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// closedRecord is a router record of an ask that ended: it never changes, so it is read once.
|
||||
type closedRecord struct {
|
||||
ask *asks.Ask
|
||||
w *asks.Warrant
|
||||
}
|
||||
|
||||
var closedRecords sync.Map // asker + "." + id -> closedRecord
|
||||
|
||||
// closedRecordOf is the router's record of an ask, kept once it is closed. A nil ask is an ask still open, ended without a
|
||||
// choice, or not there.
|
||||
func closedRecordOf(ctx context.Context, inv catalogueReader, asker, id string) (*asks.Ask, *asks.Warrant, error) {
|
||||
if v, ok := closedRecords.Load(asker + "." + id); ok {
|
||||
r := v.(closedRecord)
|
||||
return r.ask, r.w, nil
|
||||
}
|
||||
ask, w, err := routerAskOf(ctx, inv, asker, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if ask != nil && w != nil {
|
||||
closedRecords.Store(asker+"."+id, closedRecord{ask, w})
|
||||
}
|
||||
return ask, w, nil
|
||||
}
|
||||
|
||||
// deliveryIDOf is the id the gate gives a delivery, and so the key of its head in the heads an Approve binds: the repository as
|
||||
// owner/name, lower-cased, `@` and the first twelve characters of the head.
|
||||
func deliveryIDOf(owner, repo, head string) string {
|
||||
if len(head) > 12 {
|
||||
head = head[:12]
|
||||
}
|
||||
return strings.ToLower(owner+"/"+repo) + "@" + head
|
||||
}
|
||||
|
||||
// coversHead says an ask the operator approved is for this merge: its Approve is bound to the heads of the pull request alone
|
||||
// (mergeAct over {delivery id: head}, which is how a pull request not in a group is asked), or, for a group's ask, whose other
|
||||
// members' heads the router's record does not carry in full, the ask as published names this pull request at this head in its
|
||||
// details ("<repo>#<number> ... at <head>:"), and the digest the warrant answers covers those words.
|
||||
func coversHead(ask *asks.Ask, owner, repo string, number int, head string) (how string, ok bool) {
|
||||
option, found := ask.Option(optionApprove)
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
heads := map[string]string{deliveryIDOf(owner, repo, head): head}
|
||||
if option.Performs(mergeAct(ask.ID, optionApprove, heads)) == nil {
|
||||
return "bound to the head of this pull request", true
|
||||
}
|
||||
short := head
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
line := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(repo) + `#` + strconv.Itoa(number) + ` .* at ` + regexp.QuoteMeta(short) + `:`)
|
||||
if line.MatchString(ask.Details) {
|
||||
return "a group's ask naming this pull request at this head", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// findMergeWarrant finds the warrant of a merge, with no read the controller does not make today: the router's chosen merge
|
||||
// ask for it. Under the teller (releases 3e on) the id is `merge-<group>-<heads hash>-<tail>`, which names neither the pull
|
||||
// request nor the head, so the teller's closed asks are read and the one the operator approved whose heads cover the merge's
|
||||
// second parent is the warrant (coversHead). Under mesh-delivery, which made the earlier merges, the id names the pull request
|
||||
// and the head and is found by them. Why it holds; or found false with why not, for a person to read; or an error when the
|
||||
// router could not be read, which is no answer. A variable so a test needs no bus.
|
||||
var findMergeWarrant = func(ctx context.Context, inv catalogueReader, owner, repo string, number int, head string) (why string, found bool, reason string, err error) {
|
||||
reason = "the router holds no chosen merge ask of " + strings.Join(askersOfMerges(), " or ") + " that covers the head " + head[:min(12, len(head))]
|
||||
for _, asker := range askersOfMerges() {
|
||||
ids, err := routerAskIDs(ctx, inv, asker)
|
||||
if err != nil {
|
||||
return "", false, "", err
|
||||
}
|
||||
for _, id := range ids {
|
||||
named := number > 0 && strings.HasPrefix(id, mergeAskPrefix(number, head))
|
||||
if asker == sdkdelivery.TellerModule && !strings.HasPrefix(id, sdkdelivery.AskKindMerge+"-") {
|
||||
continue
|
||||
}
|
||||
if asker == theDelivery && !named {
|
||||
continue
|
||||
}
|
||||
ask, w, err := closedRecordOf(ctx, inv, asker, id)
|
||||
if err != nil {
|
||||
return "", false, "", err
|
||||
}
|
||||
if ask == nil || w == nil {
|
||||
continue
|
||||
}
|
||||
warrant, err := caughtUpWarrant(asker, ask, w)
|
||||
if err != nil {
|
||||
if named {
|
||||
reason = fmt.Sprintf("%s's ask %s holds no operator's Approve: %v", asker, id, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
how, covers := coversHead(ask, owner, repo, number, head)
|
||||
if !covers && !named {
|
||||
continue
|
||||
}
|
||||
if !covers {
|
||||
how = "the ask's id names this pull request and head"
|
||||
}
|
||||
return warrant + ", " + how, true, "", nil
|
||||
}
|
||||
}
|
||||
return "", false, reason, nil
|
||||
}
|
||||
|
||||
// caughtTrunk is one trunk the catalogue builds from.
|
||||
type caughtTrunk struct{ Owner, Repo, Branch string }
|
||||
|
||||
func (t caughtTrunk) repository() string { return t.Owner + "/" + t.Repo }
|
||||
|
||||
// trunksOfCatalogue are the trunks of the repositories on the mesh's forge the catalogue builds a module from.
|
||||
func trunksOfCatalogue(forms *sourceForms, entries []inventory.Entry) []caughtTrunk {
|
||||
var out []caughtTrunk
|
||||
for _, e := range entries {
|
||||
if e.Provided || e.Source.Repository == "" {
|
||||
continue
|
||||
}
|
||||
owner, name, onForge := forms.onTheForge(e.Source.Repository, e.Source.Seat)
|
||||
if !onForge {
|
||||
continue
|
||||
}
|
||||
branch := followedBranch(e.Source.Ref)
|
||||
if branch == "" {
|
||||
branch = "main"
|
||||
}
|
||||
t := caughtTrunk{owner, name, branch}
|
||||
if !slices.Contains(out, t) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// catchUpBeside runs a catch-up beside the hearing of the merge that asked for it, so the bus's acknowledgement of that merge
|
||||
// waits for no forge. A variable so a test runs it where it stands.
|
||||
var catchUpBeside = func(run func()) { go run() }
|
||||
|
||||
// catchingUp makes one catch-up at a time.
|
||||
var catchingUp sync.Mutex
|
||||
|
||||
// settledMerges are the merges this process found nothing to plan for: heard, and no record kept, because nothing reads them.
|
||||
// They are not read from the forge again.
|
||||
var settledMerges sync.Map
|
||||
|
||||
// catchUp hears the merges of the catalogue's trunks (only one, when only names it) that the controller holds no record of.
|
||||
// It says whether a trunk was left for later: the forge, the router or the store did not answer.
|
||||
func (f following) catchUp(ctx context.Context, only *caughtTrunk) (later bool) {
|
||||
catchingUp.Lock()
|
||||
defer catchingUp.Unlock()
|
||||
inv := f.open.inventory
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("catch-up: the catalogue cannot be read, so no merge is caught up now: %v\n", err)
|
||||
return true
|
||||
}
|
||||
kept, first, err := inv.EarliestKept(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("catch-up: what the controller kept cannot be read, so no merge is caught up now: %v\n", err)
|
||||
return true
|
||||
}
|
||||
if kept {
|
||||
fmt.Printf("catch-up: the controller's record of merges begins %s; a merge made before it, less %s, is history on every trunk\n",
|
||||
first.UTC().Format(time.RFC3339), mergeGrace)
|
||||
} else {
|
||||
fmt.Println("catch-up: the controller holds no merge of any trunk yet, so none can have been missed")
|
||||
}
|
||||
trunks := trunksOfCatalogue(newSourceForms(ctx, inv), entries)
|
||||
if only != nil {
|
||||
trunks = slices.DeleteFunc(trunks, func(t caughtTrunk) bool {
|
||||
return !strings.EqualFold(t.repository(), only.repository()) || t.Branch != only.Branch
|
||||
})
|
||||
}
|
||||
for _, t := range trunks {
|
||||
if f.catchUpTrunk(ctx, t, kept, first) {
|
||||
later = true
|
||||
}
|
||||
}
|
||||
return later
|
||||
}
|
||||
|
||||
func (f following) catchUpTrunk(ctx context.Context, t caughtTrunk, kept bool, first time.Time) (later bool) {
|
||||
inv := f.open.inventory
|
||||
repository := t.repository()
|
||||
merges, err := readTrunkMerges(ctx, t.Owner, t.Repo, t.Branch)
|
||||
if err != nil {
|
||||
// Not nothing to catch up: left for the next start or the next heard merge (ADR 0457 clause 3).
|
||||
fmt.Printf("catch-up: the forge did not answer for the last merges of %s %s, so they are not caught up now; the next start "+
|
||||
"or the next merge heard there does it: %v\n", repository, t.Branch, err)
|
||||
return true
|
||||
}
|
||||
// **A merge made before the controller's record of merges begins is history, never a gap** (novox/hq ADR 0463 §2).
|
||||
read := len(merges)
|
||||
merges = sinceTheRecord(merges, kept, first)
|
||||
fmt.Printf("catch-up: %s %s: %d of the last %d pull request merges were made since the record began\n", repository, t.Branch,
|
||||
len(merges), read)
|
||||
unwarranted := map[string]bool{}
|
||||
for _, tm := range merges {
|
||||
id := fmt.Sprintf("%s_%.8s", repository, tm.Commit)
|
||||
if _, done := settledMerges.Load(repository + "@" + tm.Commit); done {
|
||||
continue
|
||||
}
|
||||
if _, known, err := inv.MergeOf(ctx, repository, tm.Commit); err != nil {
|
||||
fmt.Printf("catch-up: the record of %s cannot be read, so %s is not caught up now: %v\n", repository, tm.Commit[:8], err)
|
||||
return true
|
||||
} else if known {
|
||||
continue
|
||||
}
|
||||
m, err := readMergeEvent(ctx, t.Owner, t.Repo, t.Branch, tm)
|
||||
if err != nil {
|
||||
fmt.Printf("catch-up: the merge %.8s of %s cannot be read from the forge, so it is not caught up now: %v\n", tm.Commit, repository, err)
|
||||
later = true
|
||||
continue
|
||||
}
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
facts, err := factsOfMerge(ctx, inv, m, time.Now().UTC(), entries, read)
|
||||
if err != nil {
|
||||
fmt.Printf("catch-up: %s %.8s cannot be judged now: %v\n", repository, tm.Commit, err)
|
||||
later = true
|
||||
continue
|
||||
}
|
||||
// The very test hearMerge makes: a merge it would plan, or take as a first build, is a gap; any other it would say
|
||||
// nothing reads, and is no gap for a person to decide.
|
||||
if !facts.plans() && !facts.addsModuleToBuilds {
|
||||
settledMerges.Store(repository+"@"+tm.Commit, true)
|
||||
continue
|
||||
}
|
||||
why, found, reason, err := findMergeWarrant(ctx, inv, t.Owner, t.Repo, m.Number, tm.Head)
|
||||
if err != nil {
|
||||
fmt.Printf("catch-up: the router cannot be read, so %s %.8s is not caught up now: %v\n", repository, tm.Commit, err)
|
||||
later = true
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
unwarranted[id] = true
|
||||
f.raiseMergeNotHeard(ctx, repository, t.Branch, tm, m, reason)
|
||||
continue
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
said := fmt.Sprintf("caught up: merged %s, not heard, planned %s", tm.At.Format(time.RFC3339), now.Format(time.RFC3339))
|
||||
fmt.Printf("catch-up: %s merged into %s (%.8s) was not heard; %s; on the warrant %s\n", repository, t.Branch, tm.Commit, said, why)
|
||||
m.Title = strings.TrimSpace(m.Title + " (" + said + ")")
|
||||
if err := f.hearMerge(ctx, m, now); err != nil {
|
||||
fmt.Printf("catch-up: %s %.8s could not be heard now: %v\n", repository, tm.Commit, err)
|
||||
later = true
|
||||
continue
|
||||
}
|
||||
if _, known, err := inv.MergeOf(ctx, repository, tm.Commit); err == nil && !known {
|
||||
settledMerges.Store(repository+"@"+tm.Commit, true) // heard, and nothing reads it
|
||||
}
|
||||
}
|
||||
// Cleared only when every gap was judged: one left for later may still be open.
|
||||
if !later {
|
||||
f.clearMergesHeard(ctx, repository, unwarranted)
|
||||
}
|
||||
return later
|
||||
}
|
||||
|
||||
// raiseMergeNotHeard keeps open, and observes again, the condition of a merge with no warrant to plan it. One for each
|
||||
// repository and commit, however many catch-ups find it.
|
||||
func (f following) raiseMergeNotHeard(ctx context.Context, repository, branch string, tm trunkMerge, m link.SourceMoved, reason string) {
|
||||
fmt.Printf("catch-up: %s merged into %s (%.8s) was not heard, and is not planned: %s\n", repository, branch, tm.Commit, reason)
|
||||
if conditionsFrom == nil {
|
||||
return
|
||||
}
|
||||
o := conditions.Observation{Scope: conditions.ScopeMerge, ID: fmt.Sprintf("%s_%.8s", repository, tm.Commit), Kind: kindMergeNotHeard,
|
||||
Severity: conditions.Warning, Source: "catch-up", Resolver: conditions.ResolverOperator,
|
||||
Summary: fmt.Sprintf("the merge %.8s of %s into %s was not heard by the controller, and no operator's Approve of it is on "+
|
||||
"record to plan it on: a person decides", tm.Commit, repository, branch),
|
||||
Said: fmt.Sprintf("%s %.8s (pull request %d, head %.8s, merged %s): %s", repository, tm.Commit, m.Number, tm.Head,
|
||||
tm.At.Format(time.RFC3339), reason),
|
||||
Headline: fmt.Sprintf("A merge into %s was never delivered", repository),
|
||||
Explanation: fmt.Sprintf("A merge into %s was made while the controller did not hear it, and no Approve of yours for it is on "+
|
||||
"record that the controller can plan it on. It stays open while the merge is among the latest %d merges of that repository. "+
|
||||
"The details say what was looked for.", repository, catchUpWindow),
|
||||
Needs: "decide whether that merge should be delivered, and say so if it should.",
|
||||
Resolved: "the merge is planned, or is no longer among the latest merges of its repository"}
|
||||
if _, err := conditionsFrom.Observe(ctx, o); err != nil {
|
||||
fmt.Printf("catch-up: the condition of %s %.8s could not be kept: %v\n", repository, tm.Commit, err)
|
||||
}
|
||||
}
|
||||
|
||||
// clearMergesHeard clears the open conditions of a repository's merges not heard that this catch-up did not find again: the
|
||||
// gap was planned, or left the window.
|
||||
func (f following) clearMergesHeard(ctx context.Context, repository string, still map[string]bool) {
|
||||
if conditionsFrom == nil {
|
||||
return
|
||||
}
|
||||
open, err := conditionsFrom.Open(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("catch-up: the open conditions cannot be read, so none is cleared now: %v\n", err)
|
||||
return
|
||||
}
|
||||
for _, c := range open {
|
||||
if c.Kind != kindMergeNotHeard || c.Subject.Scope != conditions.ScopeMerge ||
|
||||
!strings.HasPrefix(c.Subject.ID, repository+"_") || still[c.Subject.ID] {
|
||||
continue
|
||||
}
|
||||
if _, err := conditionsFrom.Clear(ctx, c.Key, "the merge is planned, or left the last merges of its trunk"); err != nil {
|
||||
fmt.Printf("catch-up: the condition %s could not be cleared: %v\n", c.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// catchUpAtStart catches up every trunk once the controller serves, and again every minute while a trunk was left for later.
|
||||
func catchUpAtStart(ctx context.Context, open *stores) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
for try := 0; try < 20; try++ {
|
||||
if !(following{open: open}).catchUp(ctx, nil) {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Minute):
|
||||
}
|
||||
}
|
||||
fmt.Println("catch-up: a trunk is still left for later after twenty tries; the next merge heard there does it")
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The checks of ADR 0457 (issue 1336): what the controller does about merges it did not hear.
|
||||
|
||||
// aTrunk is a forge's trunk as the catch-up reads it: its merges oldest first, the module each moves, and which of them the
|
||||
// router holds an operator's Approve for.
|
||||
type aTrunk struct {
|
||||
merges []trunkMerge
|
||||
module map[string]string
|
||||
approved map[string]bool
|
||||
read []string // the trunks asked of the forge, as owner/repo
|
||||
events int // merges read in full from the forge
|
||||
silent bool // the forge does not answer
|
||||
}
|
||||
|
||||
func aTrunkOf(t *testing.T, repo string, modules ...string) *aTrunk {
|
||||
t.Helper()
|
||||
tr := &aTrunk{module: map[string]string{}, approved: map[string]bool{}}
|
||||
for i, m := range modules {
|
||||
c := fmt.Sprintf("%s-c%02d", repo, i+1)
|
||||
tr.merges = append(tr.merges, trunkMerge{Commit: c, Head: fmt.Sprintf("%shead%02d", repo, i+1),
|
||||
Subject: fmt.Sprintf("Merge pull request 'change %d' (#%d) from pr/%s into main", i+1, 100+i, c),
|
||||
At: t0.Add(time.Duration(i) * time.Minute)})
|
||||
tr.module[c] = m
|
||||
tr.approved[c] = true
|
||||
}
|
||||
was, wasEvent, wasWarrant := trunkCommitsPage, readMergeEvent, findMergeWarrant
|
||||
trunkCommitsPage = func(_ context.Context, owner, name, _ string, page int) ([]trunkCommit, error) {
|
||||
tr.read = append(tr.read, owner+"/"+name)
|
||||
if tr.silent {
|
||||
return nil, errors.New("the forge did not answer")
|
||||
}
|
||||
if page > 1 || owner+"/"+name != "novox/"+repo {
|
||||
return nil, nil
|
||||
}
|
||||
var newest []trunkCommit
|
||||
for i := len(tr.merges) - 1; i >= 0; i-- {
|
||||
m := tr.merges[i]
|
||||
newest = append(newest, trunkCommit{Commit: m.Commit, SecondParent: m.Head, Subject: m.Subject, At: m.At})
|
||||
}
|
||||
return newest, nil
|
||||
}
|
||||
readMergeEvent = func(_ context.Context, owner, name, branch string, tm trunkMerge) (link.SourceMoved, error) {
|
||||
tr.events++
|
||||
m := catalogueMerge(tm.Commit, tr.module[tm.Commit], tm.At)
|
||||
m.Owner, m.Repo, m.Base, m.HeadSHA = owner, name, branch, tm.Head
|
||||
_, m.Number, _ = pullOfSubject(tm.Subject)
|
||||
return m, nil
|
||||
}
|
||||
findMergeWarrant = func(_ context.Context, _ catalogueReader, _, _ string, number int, head string) (string, bool, string, error) {
|
||||
for _, m := range tr.merges {
|
||||
if strings.Contains(m.Subject, fmt.Sprintf("(#%d)", number)) && m.Head == head && tr.approved[m.Commit] {
|
||||
return "the operator chose Approve (ask merge-" + fmt.Sprint(number) + ")", true, "", nil
|
||||
}
|
||||
}
|
||||
return "", false, "the router holds no chosen merge ask for the pull request and head", nil
|
||||
}
|
||||
wasBeside := catchUpBeside
|
||||
catchUpBeside = func(run func()) { run() }
|
||||
t.Cleanup(func() {
|
||||
trunkCommitsPage, readMergeEvent, findMergeWarrant, catchUpBeside = was, wasEvent, wasWarrant, wasBeside
|
||||
settledMerges.Clear()
|
||||
})
|
||||
return tr
|
||||
}
|
||||
|
||||
// aKeptMergeBefore is a merge the controller heard before the merges of a test: where its record begins, so what a trunk holds from
|
||||
// then on can be a gap (novox/hq ADR 0463 §2).
|
||||
func aKeptMergeBefore(t *testing.T, open *stores) {
|
||||
t.Helper()
|
||||
hear(t, open, catalogueMerge("mesh-catalog-seed", "app", t0.Add(-time.Hour)), t0.Add(-time.Hour))
|
||||
}
|
||||
|
||||
func mergeKnown(t *testing.T, open *stores, commit string) bool {
|
||||
t.Helper()
|
||||
_, known, err := open.inventory.MergeOf(t.Context(), "novox/mesh-catalog", commit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return known
|
||||
}
|
||||
|
||||
// Clause 1: the trunk holds merges 1, 2 and 3 and the controller holds records of 1 and 3 only; at its start it plans 2, said
|
||||
// as caught up; a second start plans nothing new.
|
||||
func TestAGapBeforeANewerHeardMergeIsPlannedAtTheControllersStart(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
tr := aTrunkOf(t, "mesh-catalog", "app", "notes", "app")
|
||||
hear(t, open, catalogueMerge("mesh-catalog-c01", "app", t0), t0.Add(time.Second))
|
||||
hear(t, open, catalogueMerge("mesh-catalog-c03", "app", t0.Add(2*time.Minute)), t0.Add(2*time.Minute+time.Second))
|
||||
if mergeKnown(t, open, "mesh-catalog-c02") {
|
||||
t.Fatal("the merge to be caught up is known before the catch-up")
|
||||
}
|
||||
if (following{open: open}).catchUp(t.Context(), nil) {
|
||||
t.Fatal("the catch-up says a trunk was left for later")
|
||||
}
|
||||
if !mergeKnown(t, open, "mesh-catalog-c02") {
|
||||
t.Fatal("the merge the controller did not hear was not planned")
|
||||
}
|
||||
kept, _, _ := open.inventory.MergeOf(t.Context(), "novox/mesh-catalog", "mesh-catalog-c02")
|
||||
if said := announcedOf(kept).Title; !strings.Contains(said, "caught up: merged ") || !strings.Contains(said, "not heard, planned ") {
|
||||
t.Fatalf("the merge does not say it was caught up: %q", said)
|
||||
}
|
||||
cutAt(t, open, t0.Add(10*time.Minute))
|
||||
ws, _ := walks(t, open)
|
||||
if len(ws) == 0 {
|
||||
t.Fatal("no walk answers the caught-up merge")
|
||||
}
|
||||
if _, in := ws[0].Modules["notes"]; !in {
|
||||
t.Fatalf("the walk does not build notes, which the caught-up merge moved: %v", ws[0].Modules)
|
||||
}
|
||||
before := tr.events
|
||||
if (following{open: open}).catchUp(t.Context(), nil) || tr.events != before {
|
||||
t.Fatalf("a second start read %d merge(s) in full again", tr.events-before)
|
||||
}
|
||||
}
|
||||
|
||||
// Clause 1: a heard merge of a repository whose trunk holds earlier merges the controller has no record of plans them all, the
|
||||
// earlier first, and the forge is read for that repository's trunk only.
|
||||
func TestAHeardMergeCatchesUpTheEarlierOnesOfItsOwnTrunkOnly(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
tr := aTrunkOf(t, "mesh-catalog", "app", "notes", "app")
|
||||
var order []string
|
||||
inner := readMergeEvent
|
||||
readMergeEvent = func(ctx context.Context, owner, name, branch string, tm trunkMerge) (link.SourceMoved, error) {
|
||||
order = append(order, tm.Commit)
|
||||
return inner(ctx, owner, name, branch, tm)
|
||||
}
|
||||
t.Cleanup(func() { readMergeEvent = inner })
|
||||
tr.read = nil
|
||||
heard := catalogueMerge("mesh-catalog-c03", "app", t0.Add(2*time.Minute))
|
||||
if err := (following{open: open}).SourceMoved(t.Context(), heard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []string{"mesh-catalog-c01", "mesh-catalog-c02", "mesh-catalog-c03"} {
|
||||
if !mergeKnown(t, open, c) {
|
||||
t.Fatalf("%s is not planned: the heard merge and the earlier ones of its trunk are all wanted", c)
|
||||
}
|
||||
}
|
||||
if len(order) != 2 || order[0] != "mesh-catalog-c01" || order[1] != "mesh-catalog-c02" {
|
||||
t.Fatalf("the earlier merges were heard in the order %v, not the trunk's", order)
|
||||
}
|
||||
if len(tr.read) == 0 {
|
||||
t.Fatal("the forge was not read for the trunk of the merge heard")
|
||||
}
|
||||
for _, r := range tr.read {
|
||||
if r != "novox/mesh-catalog" {
|
||||
t.Fatalf("the forge was read for %s, not only for the trunk of the merge heard: %v", r, tr.read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clause 1: a trunk of fifty-one merges whose oldest is unheard plans nothing: the oldest is outside the window.
|
||||
func TestAMergeOlderThanTheLastFiftyIsNotRead(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
modules := make([]string, 51)
|
||||
for i := range modules {
|
||||
modules[i] = "app"
|
||||
}
|
||||
aTrunkOf(t, "mesh-catalog", modules...)
|
||||
// Fifty are known; the oldest is not.
|
||||
for i := 2; i <= 51; i++ {
|
||||
c := fmt.Sprintf("mesh-catalog-c%02d", i)
|
||||
hear(t, open, catalogueMerge(c, "app", t0.Add(time.Duration(i)*time.Minute)), t0.Add(time.Duration(i)*time.Minute+time.Second))
|
||||
}
|
||||
// Paged as the forge pages: fifty a page, and a trunk of another repository is empty (a short page, or none).
|
||||
pages := trunkCommitsPage
|
||||
trunkCommitsPage = func(ctx context.Context, owner, name, branch string, page int) ([]trunkCommit, error) {
|
||||
all, err := pages(ctx, owner, name, branch, 1)
|
||||
if err != nil || page < 1 || (page-1)*50 >= len(all) {
|
||||
return nil, err
|
||||
}
|
||||
return all[(page-1)*50 : min(page*50, len(all))], nil
|
||||
}
|
||||
t.Cleanup(func() { trunkCommitsPage = pages })
|
||||
(following{open: open}).catchUp(t.Context(), nil)
|
||||
if mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatal("the oldest of fifty-one merges was read and planned, outside the window")
|
||||
}
|
||||
}
|
||||
|
||||
// Clause 2: a gap with no chosen Approve in the router is not planned and raises one condition naming its repository and
|
||||
// commit; a second start leaves that one condition open, observed twice; planning the gap clears it.
|
||||
func TestAGapWithNoApproveRaisesOneConditionKeptOpenAndClearedWhenPlanned(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
aKeptMergeBefore(t, open)
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
tr := aTrunkOf(t, "mesh-catalog", "notes")
|
||||
tr.approved["mesh-catalog-c01"] = false
|
||||
catch := func() { (following{open: open}).catchUp(t.Context(), nil) }
|
||||
catch()
|
||||
if mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatal("a merge with no Approve on record was planned")
|
||||
}
|
||||
raised := func() []conditions.Condition {
|
||||
t.Helper()
|
||||
all, err := keeper.Open(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []conditions.Condition
|
||||
for _, c := range all {
|
||||
if c.Kind == kindMergeNotHeard {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
one := raised()
|
||||
if len(one) != 1 || !strings.Contains(one[0].Subject.ID, "novox/mesh-catalog") || !strings.Contains(one[0].Subject.ID, "mesh-cata") {
|
||||
t.Fatalf("the gap raised %+v, not one condition naming its repository and commit", one)
|
||||
}
|
||||
catch()
|
||||
if again := raised(); len(again) != 1 || again[0].Observations < 2 {
|
||||
t.Fatalf("a second catch-up left %+v: one condition, observed twice, was wanted", again)
|
||||
}
|
||||
tr.approved["mesh-catalog-c01"] = true
|
||||
catch()
|
||||
if !mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatal("the gap was not planned once its Approve was on record")
|
||||
}
|
||||
if left := raised(); len(left) != 0 {
|
||||
t.Fatalf("planning the gap left its condition open: %+v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// Clause 3: a forge that does not answer leaves the catch-up undone and says so, and the next start does it.
|
||||
func TestAForgeThatDoesNotAnswerLeavesTheCatchUpForTheNextStart(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
aKeptMergeBefore(t, open)
|
||||
tr := aTrunkOf(t, "mesh-catalog", "notes")
|
||||
tr.silent = true
|
||||
if later := (following{open: open}).catchUp(t.Context(), nil); !later {
|
||||
t.Fatal("a forge that did not answer counted as nothing to catch up")
|
||||
}
|
||||
if mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatal("a merge was planned without the forge's word")
|
||||
}
|
||||
tr.silent = false
|
||||
if later := (following{open: open}).catchUp(t.Context(), nil); later || !mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatalf("the next start did not catch up (left for later: %v)", later)
|
||||
}
|
||||
}
|
||||
|
||||
// Finding 2 of the review: a gap that adds a module is a gap, judged as hearMerge judges it. With the operator's Approve on record it
|
||||
// is heard (hearMerge asks the new module in its walk); without, it raises the condition and is not lost in silence.
|
||||
func TestAGapThatAddsAModuleIsHeardOnItsWarrantAndRaisedWithout(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
aKeptMergeBefore(t, open)
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
tr := aTrunkOf(t, "mesh-catalog", "newmodule")
|
||||
tr.approved["mesh-catalog-c01"] = false
|
||||
catch := func() { (following{open: open}).catchUp(t.Context(), nil) }
|
||||
catch()
|
||||
if mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatal("a merge adding a module was heard with no Approve on record")
|
||||
}
|
||||
all, err := keeper.Open(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raised int
|
||||
for _, c := range all {
|
||||
if c.Kind == kindMergeNotHeard {
|
||||
raised++
|
||||
}
|
||||
}
|
||||
if raised != 1 {
|
||||
t.Fatalf("a merge adding a module with no Approve raised %d condition(s), not one: it was lost in silence", raised)
|
||||
}
|
||||
tr.approved["mesh-catalog-c01"] = true
|
||||
catch()
|
||||
if !mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatal("a merge adding a module was not heard once its Approve was on record")
|
||||
}
|
||||
}
|
||||
|
||||
// A trunk with merges from before the controller began keeping merges, and merges since: only the later ones are gaps.
|
||||
func TestAMergeFromBeforeTheRecordBeganIsHistoryAndNoCondition(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
tr := aTrunkOf(t, "mesh-catalog", "notes", "notes", "notes")
|
||||
tr.approved["mesh-catalog-c01"], tr.approved["mesh-catalog-c02"], tr.approved["mesh-catalog-c03"] = false, false, false
|
||||
// The record begins at the second merge: the first is two days older.
|
||||
tr.merges[0].At = t0.Add(-48 * time.Hour)
|
||||
hear(t, open, catalogueMerge("mesh-catalog-c02", "notes", t0.Add(time.Minute)), t0.Add(time.Minute))
|
||||
delete(tr.approved, "mesh-catalog-c02")
|
||||
(following{open: open}).catchUp(t.Context(), nil)
|
||||
// The first merge (two days before the record) was not even read from the forge: only the third, which is unknown, was.
|
||||
if tr.events != 1 {
|
||||
t.Fatalf("%d merge(s) were read from the forge: the one from before the record is history and is not read", tr.events)
|
||||
}
|
||||
all, err := keeper.Open(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raised int
|
||||
for _, c := range all {
|
||||
if c.Kind == kindMergeNotHeard {
|
||||
raised++
|
||||
}
|
||||
}
|
||||
if raised != 1 {
|
||||
t.Fatalf("%d condition(s) raised: only the merge since the record began (the third) is a gap", raised)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0463 §2: the record begins at the earliest merge kept on ANY trunk. A trunk the controller kept no merge of has the
|
||||
// merges made since as gaps (mesh-delivery #1 on novox), and the merges made before as history.
|
||||
func TestATrunkWithNoKeptMergeHasItsLaterMergesAsGapsAndItsEarlierOnesAsHistory(t *testing.T) {
|
||||
open := windowed(t)
|
||||
everyHeadWaits(t)
|
||||
asksRecorded(t)
|
||||
// The record begins on another trunk, at t0 less an hour.
|
||||
hear(t, open, repoMerge("one", "c-one", t0.Add(-time.Hour)), t0.Add(-time.Hour))
|
||||
tr := aTrunkOf(t, "mesh-catalog", "notes", "notes")
|
||||
tr.merges[0].At = t0.Add(-72 * time.Hour) // before the record: history
|
||||
(following{open: open}).catchUp(t.Context(), nil)
|
||||
if mergeKnown(t, open, "mesh-catalog-c01") {
|
||||
t.Fatal("a merge from before the record began was heard")
|
||||
}
|
||||
if !mergeKnown(t, open, "mesh-catalog-c02") {
|
||||
t.Fatal("a merge since the record began, on a trunk with no merge kept, was not caught up")
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The pieces of the catch-up that need no store (novox/hq ADR 0457, issue 1336).
|
||||
|
||||
func TestAMergeCommitsSubjectNamesItsPullRequest(t *testing.T) {
|
||||
title, number, ok := pullOfSubject("Merge pull request 'Fix keycloak's sign-in (issue 1332)' (#378) from fix/1332-sign-in into main")
|
||||
if !ok || number != 378 || title != "Fix keycloak's sign-in (issue 1332)" {
|
||||
t.Fatalf("read %q %d %v", title, number, ok)
|
||||
}
|
||||
if b := headBranchOfSubject("Merge pull request 'x' (#378) from fix/1332-sign-in into main"); b != "fix/1332-sign-in" {
|
||||
t.Fatalf("the head branch read as %q", b)
|
||||
}
|
||||
if _, _, ok := pullOfSubject("Merge branch 'main' of somewhere"); ok {
|
||||
t.Fatal("a merge that names no pull request read as one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheLastFiftyMergesOfATrunkAreRead(t *testing.T) {
|
||||
pages := map[int][]trunkCommit{}
|
||||
var all []trunkCommit // newest first: 51 merges, a plain commit between each pair
|
||||
for i := 51; i >= 1; i-- {
|
||||
all = append(all, trunkCommit{Commit: fmt.Sprintf("merge-%02d", i), SecondParent: fmt.Sprintf("head-%02d", i),
|
||||
Subject: fmt.Sprintf("Merge pull request 'change %d' (#%d) from fix/x into main", i, i)},
|
||||
trunkCommit{Commit: fmt.Sprintf("plain-%02d", i)})
|
||||
}
|
||||
for i := 0; i < len(all); i += 50 {
|
||||
pages[i/50+1] = all[i:min(i+50, len(all))]
|
||||
}
|
||||
was := trunkCommitsPage
|
||||
trunkCommitsPage = func(_ context.Context, _, _, _ string, page int) ([]trunkCommit, error) { return pages[page], nil }
|
||||
t.Cleanup(func() { trunkCommitsPage = was })
|
||||
got, err := readTrunkMerges(t.Context(), "novox", "mesh-catalog", "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != catchUpWindow || got[0].Commit != "merge-02" || got[len(got)-1].Commit != "merge-51" {
|
||||
t.Fatalf("read %d merges from %s to %s: the oldest of 51 is outside the window", len(got), got[0].Commit, got[len(got)-1].Commit)
|
||||
}
|
||||
if got[0].Head != "head-02" {
|
||||
t.Fatalf("a merge's head is its second parent, not %q", got[0].Head)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAForgeThatDoesNotAnswerIsAnErrorNotAnEmptyTrunk(t *testing.T) {
|
||||
was := trunkCommitsPage
|
||||
trunkCommitsPage = func(context.Context, string, string, string, int) ([]trunkCommit, error) {
|
||||
return nil, errors.New("the forge did not answer")
|
||||
}
|
||||
t.Cleanup(func() { trunkCommitsPage = was })
|
||||
if got, err := readTrunkMerges(t.Context(), "novox", "mesh-catalog", "main"); err == nil {
|
||||
t.Fatalf("a forge that did not answer read as %d merges: nothing to catch up", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestACaughtUpMergeIsAcceptedOnTheSameWarrantAsAnAnnouncedOne(t *testing.T) {
|
||||
ok := approvedMerge(t)
|
||||
for _, asker := range []string{sdkdelivery.TellerModule, theDelivery} {
|
||||
w := ok.warrant
|
||||
w.Asker = asker
|
||||
if _, err := caughtUpWarrant(asker, &ok.ask, &w); err != nil {
|
||||
t.Errorf("the operator's Approve under %s was refused: %v", asker, err)
|
||||
}
|
||||
}
|
||||
if _, err := caughtUpWarrant("keycloak", &ok.ask, &ok.warrant); err == nil {
|
||||
t.Error("a warrant of an asker the controller takes none from was accepted")
|
||||
}
|
||||
declined := ok.warrant
|
||||
declined.Option, declined.Label = "decline", "Decline"
|
||||
if _, err := caughtUpWarrant(theDelivery, &ok.ask, &declined); err == nil {
|
||||
t.Error("a Decline was accepted as a warrant")
|
||||
}
|
||||
unproved := ok.warrant
|
||||
by := *unproved.By
|
||||
by.Verified = ""
|
||||
unproved.By = &by
|
||||
if _, err := caughtUpWarrant(theDelivery, &ok.ask, &unproved); err == nil {
|
||||
t.Error("an answer whose channel proves nobody was accepted")
|
||||
}
|
||||
if _, err := caughtUpWarrant(theDelivery, nil, nil); err == nil {
|
||||
t.Error("a router that holds nothing was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// aTellersAsk is the ask the delivery gate composes for a pull request, as the router holds it once the operator chose Approve
|
||||
// (the gate's composeMergeAsk: its id from sdkdelivery.AskingID of the group and the heads, its Approve bound to mergeAct over the
|
||||
// heads, the pull request and its head's first twelve digits in the details), with the warrant the router wrote.
|
||||
func aTellersAsk(t *testing.T, group string, members ...sdkdelivery.Head) (asks.Ask, asks.Warrant) {
|
||||
t.Helper()
|
||||
key, err := sdkdelivery.MergeAskKey(group, members)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := sdkdelivery.AskingID(key, 1)
|
||||
heads := map[string]string{}
|
||||
var details []string
|
||||
for _, m := range members {
|
||||
owner, repo, _ := strings.Cut(m.Repository, "/")
|
||||
heads[deliveryIDOf(owner, repo, m.Head)] = m.Head
|
||||
details = append(details, fmt.Sprintf("%s#%d %q at %s: issues: none named; review: approved; reaches: no delivery plan yet", repo, m.Number, "title", m.Head[:12]))
|
||||
}
|
||||
bind := func(option string) string {
|
||||
d, err := asks.ActDigest(mergeAct(id, option, heads))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
at := time.Date(2026, 10, 20, 12, 2, 1, 0, time.UTC)
|
||||
ask := asks.Ask{ID: id, Headline: "Merge into main", Explanation: "passed its checks.", Who: asks.Operator, Expires: at.Add(24 * time.Hour),
|
||||
OnExpiry: "nothing is merged; the ask is made again", Details: strings.Join(details, "\n"),
|
||||
Options: []asks.Option{
|
||||
{ID: optionApprove, Label: "Approve", Does: "merges", Level: asks.Approve, Binds: bind(optionApprove)},
|
||||
{ID: "decline", Label: "Decline", Does: "nothing is merged", Level: asks.Approve, Binds: bind("decline")}}}
|
||||
w := asks.Warrant{Ask: id, Asker: sdkdelivery.TellerModule, Outcome: asks.OutcomeChosen, Option: optionApprove, Label: "Approve",
|
||||
Level: asks.Approve, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "1", Verified: "user id"},
|
||||
At: at.Add(time.Minute), AskDigest: ask.Digest()}
|
||||
return ask, w
|
||||
}
|
||||
|
||||
// withRouter hands findMergeWarrant these asks as the router's records, by asker.
|
||||
func withRouter(t *testing.T, records map[string]map[string]struct {
|
||||
ask asks.Ask
|
||||
w asks.Warrant
|
||||
}) {
|
||||
t.Helper()
|
||||
wasIDs, wasAsk := routerAskIDs, routerAskOf
|
||||
closedRecords.Range(func(k, _ any) bool { closedRecords.Delete(k); return true })
|
||||
routerAskIDs = func(_ context.Context, _ catalogueReader, asker string) ([]string, error) {
|
||||
var ids []string
|
||||
for id := range records[asker] {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
routerAskOf = func(_ context.Context, _ catalogueReader, asker, id string) (*asks.Ask, *asks.Warrant, error) {
|
||||
r, ok := records[asker][id]
|
||||
if !ok {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return &r.ask, &r.w, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
routerAskIDs, routerAskOf = wasIDs, wasAsk
|
||||
closedRecords.Range(func(k, _ any) bool { closedRecords.Delete(k); return true })
|
||||
})
|
||||
}
|
||||
|
||||
type aRouterRecord = struct {
|
||||
ask asks.Ask
|
||||
w asks.Warrant
|
||||
}
|
||||
|
||||
// The teller's asks, as the router held them on 2026-10-20: the id is the router's own key, rebuilt here from the group and the
|
||||
// head the gate composed it for (mesh-controller#405 at 8a330afd, mesh-catalog#381 at 1c702422, mesh-host#124 at 52600ec7). It names
|
||||
// neither the pull request nor the head, so the warrant is found by the heads the Approve is bound to.
|
||||
func TestATellersWarrantIsFoundByTheHeadItsApproveIsBoundTo(t *testing.T) {
|
||||
real := []struct {
|
||||
group, key, owner, repo string
|
||||
number int
|
||||
head string
|
||||
}{
|
||||
{"feat/r4b-sdk-users", "merge-feat_r4b_sdk_users-ba59b03220f8-989afe0a3bcf", "novox", "mesh-controller", 405, "8a330afd44ba852873339cf1210e90989dca46e1"},
|
||||
{"fix/1351-remove-todays-delivery-module", "merge-fix_1351_remove_todays_delivery_module-64a353-d93b97b9697b", "novox", "mesh-catalog", 381, "1c7024224dd77c5b603299378883e0993b2e2fdd"},
|
||||
{"feat/r4b-sdk-users", "merge-feat_r4b_sdk_users-6a59ba48da14-424e053ebc9c", "novox", "mesh-host", 124, "52600ec7bd61ee271a5cc7f4136bcf0a2a8f00fc"},
|
||||
}
|
||||
teller := map[string]aRouterRecord{}
|
||||
for _, r := range real {
|
||||
ask, w := aTellersAsk(t, r.group, sdkdelivery.Head{Repository: r.owner + "/" + r.repo, Number: r.number, Head: r.head})
|
||||
if ask.ID != r.key {
|
||||
t.Fatalf("the id of %s#%d is %q, and the router held it as %q: the fixture is not the router's", r.repo, r.number, ask.ID, r.key)
|
||||
}
|
||||
teller[ask.ID] = aRouterRecord{ask, w}
|
||||
}
|
||||
withRouter(t, map[string]map[string]aRouterRecord{sdkdelivery.TellerModule: teller})
|
||||
for _, r := range real {
|
||||
why, found, reason, err := findMergeWarrant(t.Context(), nil, r.owner, r.repo, r.number, r.head)
|
||||
if err != nil || !found || !strings.Contains(why, r.key) || !strings.Contains(why, "bound to the head of this pull request") {
|
||||
t.Errorf("%s#%d: the warrant was not found: %q %v %q %v", r.repo, r.number, why, found, reason, err)
|
||||
}
|
||||
// Another head of the same pull request is another merge: the Approve was not given for it.
|
||||
if _, found, _, _ := findMergeWarrant(t.Context(), nil, r.owner, r.repo, r.number, "ffffffffffffffffffffffffffffffffffffffff"); found {
|
||||
t.Errorf("%s#%d: another head found the warrant of this one", r.repo, r.number)
|
||||
}
|
||||
}
|
||||
// One pull request's Approve is none for another repository's merge at the same head digits.
|
||||
if _, found, _, _ := findMergeWarrant(t.Context(), nil, "novox", "mesh-tools", 405, real[0].head); found {
|
||||
t.Error("a merge of another repository found mesh-controller#405's warrant")
|
||||
}
|
||||
}
|
||||
|
||||
// A group's ask binds several heads, and the router's record carries only twelve digits of the other members': the pull request is
|
||||
// found in the ask as it was published, which the warrant's digest covers.
|
||||
func TestAGroupsWarrantIsFoundByTheAsksWordsForThePullRequestAndItsHead(t *testing.T) {
|
||||
a := sdkdelivery.Head{Repository: "novox/mesh-host", Number: 124, Head: "52600ec7bd61ee271a5cc7f4136bcf0a2a8f00fc"}
|
||||
b := sdkdelivery.Head{Repository: "novox/mesh-controller", Number: 405, Head: "8a330afd44ba852873339cf1210e90989dca46e1"}
|
||||
ask, w := aTellersAsk(t, "feat/r4b-sdk-users", a, b)
|
||||
withRouter(t, map[string]map[string]aRouterRecord{sdkdelivery.TellerModule: {ask.ID: {ask, w}}})
|
||||
for _, h := range []sdkdelivery.Head{a, b} {
|
||||
owner, repo, _ := strings.Cut(h.Repository, "/")
|
||||
why, found, reason, err := findMergeWarrant(t.Context(), nil, owner, repo, h.Number, h.Head)
|
||||
if err != nil || !found || !strings.Contains(why, "a group's ask naming this pull request at this head") {
|
||||
t.Errorf("%s#%d: not found in the group's ask: %q %v %q %v", repo, h.Number, why, found, reason, err)
|
||||
}
|
||||
}
|
||||
if _, found, _, _ := findMergeWarrant(t.Context(), nil, "novox", "mesh-catalog", 381, a.Head); found {
|
||||
t.Error("a pull request the group's ask does not name found its warrant")
|
||||
}
|
||||
// A Decline is no warrant.
|
||||
declined := w
|
||||
declined.Option, declined.Label = "decline", "Decline"
|
||||
withRouter(t, map[string]map[string]aRouterRecord{sdkdelivery.TellerModule: {ask.ID: {ask, declined}}})
|
||||
if _, found, _, _ := findMergeWarrant(t.Context(), nil, "novox", "mesh-host", 124, a.Head); found {
|
||||
t.Error("a Decline found a warrant")
|
||||
}
|
||||
}
|
||||
|
||||
// Keycloak's merge was asked as mesh-delivery.merge-378-b9104454f9a5-1, before release 3e: the id names the pull request and
|
||||
// the first twelve digits of its head, and the warrant is found by them under mesh-delivery.
|
||||
func TestAWarrantAskedUnderMeshDeliveryIsFoundByThePullRequestAndItsHead(t *testing.T) {
|
||||
ok := approvedMerge(t)
|
||||
head := "b9104454f9a5e0c4d3b2a1f0e9d8c7b6a5948372"
|
||||
askID := "merge-378-b9104454f9a5-1"
|
||||
ask, w := ok.ask, ok.warrant
|
||||
ask.ID, w.Ask, w.Asker = askID, askID, theDelivery
|
||||
w.AskDigest = ask.Digest()
|
||||
withRouter(t, map[string]map[string]aRouterRecord{theDelivery: {
|
||||
"merge-377-aaaaaaaaaaaa-1": {},
|
||||
askID: {ask, w},
|
||||
}})
|
||||
why, found, reason, err := findMergeWarrant(t.Context(), nil, "novox", "mesh-catalog", 378, head)
|
||||
if err != nil || !found || !strings.Contains(why, askID) {
|
||||
t.Fatalf("the warrant was not found: %q %v %q %v", why, found, reason, err)
|
||||
}
|
||||
// Another head names another ask, and none is found.
|
||||
if _, found, _, _ := findMergeWarrant(t.Context(), nil, "novox", "mesh-catalog", 378, "ffffffffffff0000"); found {
|
||||
t.Fatal("another head found a warrant")
|
||||
}
|
||||
// A router that cannot be read is an error, never "no warrant".
|
||||
routerAskIDs = func(context.Context, catalogueReader, string) ([]string, error) { return nil, errors.New("no answer") }
|
||||
if _, found, _, err := findMergeWarrant(t.Context(), nil, "novox", "mesh-catalog", 378, head); found || err == nil {
|
||||
t.Fatalf("an unreadable router read as no warrant: %v %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A trunk shorter than a page, or with none, is read without a fault: the pages end when the forge's do.
|
||||
func TestAShortTrunkIsReadWholeAndAnEmptyOneIsNoMerges(t *testing.T) {
|
||||
was := trunkCommitsPage
|
||||
t.Cleanup(func() { trunkCommitsPage = was })
|
||||
trunkCommitsPage = func(_ context.Context, _, _, _ string, page int) ([]trunkCommit, error) {
|
||||
if page > 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return []trunkCommit{{Commit: "m2", SecondParent: "h2", Subject: "Merge pull request 'two' (#2) from fix/b into main"},
|
||||
{Commit: "p"}, {Commit: "m1", SecondParent: "h1", Subject: "Merge pull request 'one' (#1) from fix/a into main"}}, nil
|
||||
}
|
||||
got, err := readTrunkMerges(t.Context(), "novox", "x", "main")
|
||||
if err != nil || len(got) != 2 || got[0].Commit != "m1" || got[1].Commit != "m2" {
|
||||
t.Fatalf("a short trunk read as %+v %v", got, err)
|
||||
}
|
||||
trunkCommitsPage = func(context.Context, string, string, string, int) ([]trunkCommit, error) { return nil, nil }
|
||||
if got, err := readTrunkMerges(t.Context(), "novox", "x", "main"); err != nil || len(got) != 0 {
|
||||
t.Fatalf("an empty trunk read as %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0253: what the operator reads of a merge not heard is plain: no hash, no identifier, nothing to do "by hand".
|
||||
func TestTheConditionOfAMergeNotHeardIsInPlainWords(t *testing.T) {
|
||||
keeper, _ := withConditionsInMemory(t)
|
||||
tm := trunkMerge{Commit: "c0880c4d1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b", Head: "b9104454f9a5e0c4d3b2a1f0e9d8c7b6a5948372"}
|
||||
(following{}).raiseMergeNotHeard(t.Context(), "novox/mesh-catalog", "main", tm, link.SourceMoved{Number: 378},
|
||||
"the router holds no chosen merge ask of mesh-delivery-teller or mesh-delivery that covers the head b9104454f9a5")
|
||||
open, err := keeper.Open(t.Context())
|
||||
if err != nil || len(open) != 1 {
|
||||
t.Fatalf("opened %d conditions, %v", len(open), err)
|
||||
}
|
||||
c := open[0]
|
||||
if why, ok := conditions.PlainWords(conditions.Words{Headline: c.Headline, Explanation: c.Explanation, Needs: c.Needs,
|
||||
Resolved: c.Resolved}); !ok {
|
||||
t.Fatalf("the condition's words are not plain: %s\n%q\n%q\n%q", why, c.Headline, c.Explanation, c.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
// The trunk's history lists the merges made inside a pull request's branch too. On novox (2026-10-20) 15 of the 15 non-pull-request
|
||||
// "gaps" of mesh-catalog and mesh-controller were such merges; only a pull request's merge into this trunk is a merge to catch up.
|
||||
func TestAMergeMadeInsideABranchIsNoMergeOfTheTrunk(t *testing.T) {
|
||||
was := trunkCommitsPage
|
||||
t.Cleanup(func() { trunkCommitsPage = was })
|
||||
trunkCommitsPage = func(_ context.Context, _, _, _ string, page int) ([]trunkCommit, error) {
|
||||
if page > 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return []trunkCommit{
|
||||
{Commit: "c0880c4d", SecondParent: "b9104454", Subject: "Merge pull request 'Read the grant file' (#378) from fix/1332 into main"},
|
||||
{Commit: "6394a62a", SecondParent: "x1", Subject: "Merge remote-tracking branch 'origin/main' into fix/1081-withdraw"},
|
||||
{Commit: "896f1d28", SecondParent: "x2", Subject: "Merge feat/agents-write-git-ssh into feat/agents-write-git-ssh-f (issue 955 F)"},
|
||||
{Commit: "4d03755c", SecondParent: "x3", Subject: "Bring the branch up to main after #354 (issue 1047)"},
|
||||
{Commit: "other", SecondParent: "x4", Subject: "Merge pull request 'Elsewhere' (#5) from fix/y into nox-mesh"},
|
||||
}, nil
|
||||
}
|
||||
got, err := readTrunkMerges(t.Context(), "novox", "mesh-catalog", "main")
|
||||
if err != nil || len(got) != 1 || got[0].Commit != "c0880c4d" {
|
||||
t.Fatalf("read %+v %v: only keycloak's pull request merge into main is a merge to catch up", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0463 §2: the record of merges begins at the earliest merge the controller holds on any trunk. A merge before it is
|
||||
// history on every trunk; a trunk with no merge kept of its own still has the merges made since as gaps (mesh-delivery #1 on novox).
|
||||
func TestOnlyTheMergesSinceTheRecordBeganAreGapsOnEveryTrunk(t *testing.T) {
|
||||
first := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC) // the first merge it heard, on some trunk of the mesh
|
||||
trunk := func(prefix string) []trunkMerge {
|
||||
return []trunkMerge{
|
||||
{Commit: prefix + "-old-1", At: first.Add(-30 * 24 * time.Hour)},
|
||||
{Commit: prefix + "-old-2", At: first.Add(-2 * time.Hour)},
|
||||
{Commit: prefix + "-within-grace", At: first.Add(-mergeGrace / 2)},
|
||||
{Commit: prefix + "-since", At: first.Add(24 * time.Hour)},
|
||||
}
|
||||
}
|
||||
// Two trunks, one of which the controller has kept no merge of: both read by the one starting point.
|
||||
for _, prefix := range []string{"kept-trunk", "trunk-with-no-kept-merge"} {
|
||||
var commits []string
|
||||
for _, m := range sinceTheRecord(trunk(prefix), true, first) {
|
||||
commits = append(commits, m.Commit)
|
||||
}
|
||||
if want := prefix + "-within-grace " + prefix + "-since"; strings.Join(commits, " ") != want {
|
||||
t.Errorf("%s: the gaps are %v, want %q: earlier ones are history on every trunk, later ones are gaps", prefix, commits, want)
|
||||
}
|
||||
}
|
||||
if none := sinceTheRecord(trunk("x"), false, time.Time{}); len(none) != 0 {
|
||||
t.Errorf("with no merge kept anywhere there are %d gap(s), but no record to have one in", len(none))
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/chain"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/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"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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 (
|
||||
|
||||
"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
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
@@ -75,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, or the running controller).
|
||||
// Judge is who judges the gate (link.JudgeSelf, link.JudgeValidator, 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
|
||||
@@ -101,6 +101,10 @@ 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 {
|
||||
@@ -277,7 +281,7 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
|
||||
for _, e := range entries {
|
||||
shelf[e.Manifest.Module] = e.Manifest
|
||||
}
|
||||
world, err := restOfTheMesh(ctx, open, shelf, "")
|
||||
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
|
||||
if err != nil {
|
||||
return link.BuildRequest{}, err
|
||||
}
|
||||
|
||||
@@ -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 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 node-engine is judged with its validator", pull("novox", "mesh-host", "main", []string{"validate/v.go"}),
|
||||
"mesh-host", "", "module.json", true, "validator"},
|
||||
{"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",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1389: the controller's own composition is gone. No function of the package is named for it any more, and no function
|
||||
// calls one by that name: a name reappearing is a second copy of what mesh-sdk's go/compose holds (novox/hq ADR 0453).
|
||||
func TestTheControllersOwnCompositionIsGone(t *testing.T) {
|
||||
gone := map[string]bool{"planFor": true, "declarationWith": true, "composedAndValidated": true, "generators": true,
|
||||
"renderingFor": true, "theRestOfTheMesh": true, "whoResolves": true, "grantsFor": true, "namesInTheMesh": true,
|
||||
"onThePrivateNetwork": true, "passSeatsOn": true, "theMeshPassesSeatsOn": true, "assignmentsOf": true,
|
||||
"licencesFor": true, "seatsOn": true, "composeBusUsers": true, "adoptionFor": true, "localNetworksOf": 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 {
|
||||
continue
|
||||
}
|
||||
if gone[fn.Name.Name] {
|
||||
found = append(found, f+": "+fn.Name.Name+" is declared")
|
||||
}
|
||||
if 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 && gone[id.Name] {
|
||||
found = append(found, f+": "+fn.Name.Name+" calls "+id.Name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Strings(found)
|
||||
for _, one := range found {
|
||||
t.Errorf("%s: the controller's own composition is back; compose through mesh-sdk (compose_sdk.go)", one)
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
)
|
||||
|
||||
// grantsOf is the grants a provider creates for its consumers, the consumers whose identity overflows the provision's bound
|
||||
// and the credentials bound to another consumer, as mesh-sdk's rendering gives them for the machine (it replaced the
|
||||
// controller's grantsFor, issue 1389).
|
||||
func grantsOf(ctx context.Context, open *stores, node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
rendered, err := compose.Render(in.Input, node, compose.Reading)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return rendered.Rendering.Grants, rendered.Withheld, rendered.Unbound, nil
|
||||
}
|
||||
|
||||
// madeAndRendered sends to the machine (so everything a send makes is made) and answers its rendering as a question reads it
|
||||
// afterwards: what the controller's Allocating rendering was.
|
||||
func madeAndRendered(ctx context.Context, open *stores, node string) (catalogue.Rendering, error) {
|
||||
if _, err := composeSend(ctx, open, node); err != nil {
|
||||
return catalogue.Rendering{}, err
|
||||
}
|
||||
forgetInput(ctx)
|
||||
return renderingOf(ctx, open, node)
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
// A send makes the credentials of files and reports; a question makes none.
|
||||
if _, err := composeSend(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)
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
//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 := resolveNode(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 := resolveNode(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)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
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)
|
||||
}
|
||||
if performSeatPasses(ctx, seatPassingOn(inv, w), d.Passes.Passes) {
|
||||
did = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Held only when sealed to the key the machine has now, or given by a person (which is not the mesh's
|
||||
// to make again): a made row under an old key is re-made, as SecretForModule did.
|
||||
if r.Node == node && (r.SealedTo == n.SealingKey || r.Origin == inventory.OriginAccepted) {
|
||||
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)
|
||||
}, true)
|
||||
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
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
//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 := resolveNode(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1, finding 1: a machine that rejoined with a new sealing key keeps its made rows sealed to the old key. A
|
||||
// send re-makes them for the new key, as SecretForModule did on main, and the file naming the secret is not refused.
|
||||
func TestASendReMakesAnOwnSecretSealedToAKeyTheMachineNoLongerHas(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
register(t, open, aBot("bot", false))
|
||||
if _, err := assign(ctx, open, "laptop", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := composeSend(ctx, open, "laptop"); err != nil {
|
||||
t.Fatalf("the first send is refused: %v", err)
|
||||
}
|
||||
if err := inv.RecordSealingKey(ctx, nodeID(t, open, "laptop"), aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := composeSend(ctx, open, "laptop"); err != nil {
|
||||
t.Fatalf("a machine that rejoined cannot be sent to: %v", err)
|
||||
}
|
||||
in, err := readComposeInput(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, _ := nodeOf(in.Input, "laptop")
|
||||
found := false
|
||||
for _, r := range in.OwnSecrets {
|
||||
if r.Node == "laptop" && r.Module == "bot" && r.Name == "token" {
|
||||
found = true
|
||||
if r.SealedTo != n.SealingKey {
|
||||
t.Fatalf("the own secret is still sealed to a key the machine no longer has")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the own secret has no row")
|
||||
}
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// renderingOf is what a machine is rendered with as a question reads it (nothing made), with the private network: what the
|
||||
// filter's rules, the roster and the like are derived from.
|
||||
func renderingOf(ctx context.Context, open *stores, node string) (catalogue.Rendering, error) {
|
||||
in, err := inputFor(ctx, open)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, err
|
||||
}
|
||||
rendered, err := compose.Render(in.Input, node, compose.Reading)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, markUnresolvable(err)
|
||||
}
|
||||
with := rendered.Rendering
|
||||
if with.Generators, err = compose.Generators(in.Input); err != nil {
|
||||
return catalogue.Rendering{}, err
|
||||
}
|
||||
return with, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
)
|
||||
|
||||
// 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: the current build of each module it carries, the build a module left out of it was last
|
||||
// sent (when that is known), and a module kept at the build the machine runs at that one. (The controller's carriedOn recorded
|
||||
// the same; mesh-sdk's node_test TestAModuleLeftOutKeepsTheBuildItWasLastSent holds the left-out case.)
|
||||
func TestWhatASendRecordsAsCarriedIsWhatTheControllerRecorded(t *testing.T) {
|
||||
plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "postgres"}, {Module: "files"}, {Module: "cache"}}}
|
||||
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
|
||||
want map[string]string
|
||||
}{
|
||||
"nothing sent": {want: map[string]string{"postgres": "p2", "files": "f2"}},
|
||||
"sent and kept": {sent: map[string]string{"postgres": "p1", "cache": "c1", "files": "f1"}, known: true,
|
||||
kept: map[string]string{"postgres": "p1"}, want: map[string]string{"postgres": "p1", "files": "f2", "cache": "c1"}},
|
||||
"sent but not known": {sent: map[string]string{"cache": "c1"}, want: map[string]string{"postgres": "p2", "files": "f2"}},
|
||||
"kept a module not in": {known: true, kept: map[string]string{"mail": "m1"}, want: map[string]string{"postgres": "p2", "files": "f2"}},
|
||||
} {
|
||||
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, c.want) {
|
||||
t.Errorf("%s: a send records %v, want %v", name, got, c.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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/overlay"
|
||||
)
|
||||
|
||||
func aConfiguredMesh(t *testing.T) (*stores, configuredMesh, map[string]map[string]any) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -127,17 +127,21 @@ type snapshotFacts = snapshot.Facts
|
||||
func toldEveryMachine(t *testing.T, open *stores, served map[string]map[string]any, on map[string][]string) map[string]string {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
told := map[string]string{}
|
||||
for _, node := range fourMachines {
|
||||
plan, _, err := resolveNode(ctx, open, node)
|
||||
plan, settings, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", node, err)
|
||||
}
|
||||
with, err := madeAndRendered(ctx, open, node)
|
||||
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Allocating)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", node, err)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
)
|
||||
|
||||
// The core's own credentials, sealed by the vault (novox/hq ADR 0349 §16, issue 755; the lead's decision on issue 755,
|
||||
@@ -39,6 +40,15 @@ import (
|
||||
// the vault and the machine, cleared by the first send the vault answers whole. Only a machine with no sealing key, or
|
||||
// one whose key cannot be read, leaves its module out.
|
||||
|
||||
// engineOpensVaultBox says whether a machine's node-engine opens the vault's box, by its own newest statement.
|
||||
func engineOpensVaultBox(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.VaultBoxContract, nil
|
||||
}
|
||||
|
||||
// coreAnswer is the vault's `get` answer as the controller reads it: the name, the generation and the box, never
|
||||
// opened.
|
||||
type coreAnswer struct {
|
||||
@@ -111,7 +121,7 @@ func sealingKeyPEM(raw string) (string, error) {
|
||||
// own seal where nothing was kept; on any other question, the kept box, or a stand-in the next send asks for. Pure: what to keep and what to
|
||||
// say is answered, not done.
|
||||
func coreBoxesOf(node, sealingKey string, core map[string][]string, kept map[string]map[string]inventory.CoreBox,
|
||||
allocating bool, ask coreAsk) coreBoxes {
|
||||
choosing Choosing, ask coreAsk) coreBoxes {
|
||||
out := coreBoxes{sealed: map[string]map[string]json.RawMessage{}, notPlaced: map[string]string{}}
|
||||
modules := make([]string, 0, len(core))
|
||||
for m := range core {
|
||||
@@ -133,7 +143,7 @@ func coreBoxesOf(node, sealingKey string, core map[string][]string, kept map[str
|
||||
for _, name := range core[module] {
|
||||
k, has := kept[module][name]
|
||||
valid := has && k.SealedTo == sealingKey && len(k.Box) > 0
|
||||
if !allocating {
|
||||
if choosing != Allocating {
|
||||
if valid {
|
||||
boxes[name] = k.Box
|
||||
} else {
|
||||
@@ -208,10 +218,10 @@ var coreVault = func(ctx context.Context) coreAsk {
|
||||
|
||||
// coreBoxesFor is coreBoxesOf for one machine, read from and kept in the store, and said on a send.
|
||||
func coreBoxesFor(ctx context.Context, inv *inventory.Inventory, node string, modules []catalogue.Manifest,
|
||||
handed func(catalogue.Manifest) bool, allocating bool) (coreBoxes, error) {
|
||||
handed func(catalogue.Manifest) bool, choosing Choosing) (coreBoxes, error) {
|
||||
core := coreModulesOf(modules, handed)
|
||||
if len(core) == 0 {
|
||||
if allocating {
|
||||
if choosing == Allocating {
|
||||
settleVaultSilent(ctx, node)
|
||||
}
|
||||
return coreBoxes{}, nil
|
||||
@@ -225,16 +235,16 @@ func coreBoxesFor(ctx context.Context, inv *inventory.Inventory, node string, mo
|
||||
return coreBoxes{}, err
|
||||
}
|
||||
var ask coreAsk
|
||||
if allocating {
|
||||
if choosing == Allocating {
|
||||
ask = coreVault(ctx)
|
||||
}
|
||||
out := coreBoxesOf(node, key, core, kept, allocating, ask)
|
||||
out := coreBoxesOf(node, key, core, kept, choosing, ask)
|
||||
for _, b := range out.keep {
|
||||
if err := inv.KeepCoreBox(ctx, node, b); err != nil {
|
||||
return coreBoxes{}, err
|
||||
}
|
||||
}
|
||||
if allocating {
|
||||
if choosing == Allocating {
|
||||
sayVaultSilent(ctx, node, out)
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -96,7 +96,7 @@ func TestASilentVaultHoldsTheKeptBoxAndSaysSo(t *testing.T) {
|
||||
func TestACoreModuleFirstPlacedWhileTheVaultIsSilentIsSealedByTheController(t *testing.T) {
|
||||
open, vault := aCoreMesh(t, link.VaultBoxContract)
|
||||
vault.silent = errors.New("no responders")
|
||||
plan, settings, err := resolveNode(t.Context(), open, "laptop")
|
||||
plan, settings, err := planFor(t.Context(), open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -68,7 +68,7 @@ func TestReplay908(t *testing.T) {
|
||||
key := "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
core := map[string][]string{"nats": {"password"}}
|
||||
ask := func(name, publicKey string) (coreAnswer, error) { return coreAnswer{}, errors.New("no responders") }
|
||||
got := coreBoxesOf("novox", key, core, nil, true, ask)
|
||||
got := coreBoxesOf("novox", key, core, nil, Allocating, ask)
|
||||
if _, sealed := got.sealed["nats"]; sealed || len(got.notPlaced) != 0 {
|
||||
t.Fatalf("nats is vault-sealed %v or not placed %v", got.sealed, got.notPlaced)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestReplay908(t *testing.T) {
|
||||
}
|
||||
// A kept box is still carried while the vault is silent.
|
||||
kept := map[string]map[string]inventory.CoreBox{"nats": {"password": {SealedTo: key, Box: json.RawMessage(`{"v":1}`)}}}
|
||||
got = coreBoxesOf("novox", key, core, kept, true, ask)
|
||||
got = coreBoxesOf("novox", key, core, kept, Allocating, ask)
|
||||
if string(got.sealed["nats"]["password"]) != `{"v":1}` || len(got.byController) != 0 {
|
||||
t.Fatalf("the kept box was not held: %+v", got)
|
||||
}
|
||||
@@ -99,17 +99,17 @@ func TestOnlyASendAsksTheVault(t *testing.T) {
|
||||
t.Fatal("the vault was asked by a question that is not a send")
|
||||
return coreAnswer{}, nil
|
||||
}
|
||||
for _, allocating := range []bool{false} {
|
||||
if got := coreBoxesOf("anchor", key, core, kept, allocating, never); string(got.sealed["nats"]["broker"]) != `{"v":1}` {
|
||||
for _, choosing := range []Choosing{Reading, Foreseeing} {
|
||||
if got := coreBoxesOf("anchor", key, core, kept, choosing, never); string(got.sealed["nats"]["broker"]) != `{"v":1}` {
|
||||
t.Errorf("read %s", got.sealed["nats"]["broker"])
|
||||
}
|
||||
if got := coreBoxesOf("anchor", key, core, nil, allocating, never); string(got.sealed["nats"]["broker"]) !=
|
||||
if got := coreBoxesOf("anchor", key, core, nil, choosing, never); string(got.sealed["nats"]["broker"]) !=
|
||||
string(catalogue.ForeseenVaultBox) {
|
||||
t.Errorf("with none kept, read %s", got.sealed["nats"]["broker"])
|
||||
}
|
||||
}
|
||||
// A box kept for another key of the machine is never carried.
|
||||
other := coreBoxesOf("anchor", aPublicKey(t), core, kept, true,
|
||||
other := coreBoxesOf("anchor", aPublicKey(t), core, kept, Allocating,
|
||||
func(string, string) (coreAnswer, error) { return coreAnswer{}, errors.New("silent") })
|
||||
if _, vaultSealed := other.sealed["nats"]; vaultSealed || !reflect.DeepEqual(other.byController, []string{"nats"}) {
|
||||
t.Fatalf("a box sealed to the machine's old key was carried: %+v", other)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestAssigningAModuleThatSpeaksOnTheBusNamesItsCredential(t *testing.T) {
|
||||
}
|
||||
|
||||
// And the push refuses to send it, naming the same verb, rather than sealing a placeholder.
|
||||
plan, settings, err := resolveNode(ctx, open, "laptop")
|
||||
plan, settings, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
@@ -152,8 +152,6 @@ 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")
|
||||
}
|
||||
@@ -182,7 +180,7 @@ func probeData(ctx context.Context, d *doctor) ([]conditions.Observation, error)
|
||||
}
|
||||
var machines []*machine
|
||||
for _, n := range nodes {
|
||||
plan, _, err := resolveNode(ctx, open, n.Name)
|
||||
plan, _, err := planFor(ctx, open, n.Name)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
@@ -851,7 +849,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 := resolveNode(ctx, open, r.Machine); err == nil {
|
||||
if plan, _, err := planFor(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"
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/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"
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -1179,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 := restOfTheMesh(ctx, open, shelfOf(entries), "")
|
||||
world, err := theRestOfTheMesh(ctx, open.inventory, shelfOf(entries), "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/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"
|
||||
@@ -122,7 +122,7 @@ func TestAMergeWaitsForItsDeliverysWordAndThePersonsWordWorksWithoutIt(t *testin
|
||||
advanceHeld(ctx, open)
|
||||
got, _ = inv.PlanByID(ctx, p.ID)
|
||||
if got.Waiting() || !strings.HasPrefix(got.Delivery.By, "a person") || len(*asked) < 4 {
|
||||
t.Fatalf("a person's word did not start the walk: %+v, asked %v, state %q, tier %d, note %q", got.Delivery, *asked, got.State, got.Tier, got.Note)
|
||||
t.Fatalf("a person's word did not start the walk: %+v, asked %v", got.Delivery, *asked)
|
||||
}
|
||||
|
||||
// The owner's stop: failed, said as stopped by it.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -42,8 +42,12 @@ func TestAMachineIsSentTheEpochOnlyOnceItSaysItReadsOne(t *testing.T) {
|
||||
if err := inv.RecordReadsEpoch(ctx, anchor.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &bodiesDelivery{recordedDelivery: recordedDelivery{inv: inv}, bodies: map[string][]byte{}}
|
||||
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, composeForPush(open), d, "", nil); err != nil {
|
||||
if _, err := sendRound(ctx, open, []string{"anchor", "laptop"}, composeForPush(open, gens), d, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
carried := func(node string) (epoch float64, has bool) {
|
||||
@@ -85,8 +89,12 @@ func TestNothingIsComposedOrSentWithoutTheLease(t *testing.T) {
|
||||
was := epochForActs
|
||||
epochForActs = func(context.Context) (uint64, error) { return 0, errors.New("this controller lost the lease") }
|
||||
t.Cleanup(func() { epochForActs = was })
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &bodiesDelivery{recordedDelivery: recordedDelivery{inv: open.inventory}, bodies: map[string][]byte{}}
|
||||
refused, err := sendRound(ctx, open, []string{"anchor"}, composeForPush(open), d, "", nil)
|
||||
refused, err := sendRound(ctx, open, []string{"anchor"}, composeForPush(open, gens), d, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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-host/validate"
|
||||
|
||||
"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"
|
||||
)
|
||||
@@ -169,15 +170,10 @@ func factsStore(ctx context.Context, open *stores) (string, error) {
|
||||
return address, nil
|
||||
}
|
||||
|
||||
// writtenFormat is the format of the snapshot this controller writes: mesh-sdk's go/facts current format, 2, which carries
|
||||
// each machine's grants and credential bindings besides everything format 1 held (issue 1394, novox/hq ADR 0456 clause 2).
|
||||
// Every reader (the merge gate, the build seat's check) reads formats 1 and 2.
|
||||
const writtenFormat = snapshot.Format
|
||||
|
||||
// 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: writtenFormat, Taken: time.Now().UTC(), Controller: snapshot.Build{Version: version}}
|
||||
f := snapshot.Facts{Format: snapshot.Format, Taken: time.Now().UTC(), Controller: snapshot.Build{Version: version}}
|
||||
f.Versions.Bus = busVersion
|
||||
storeVersion, err := inv.ServerVersion(ctx)
|
||||
if err != nil {
|
||||
@@ -267,9 +263,7 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
||||
f.Controller.Commit = c.Commit
|
||||
}
|
||||
|
||||
// The Input every machine's declaration below is composed over is read once for the whole snapshot.
|
||||
ctx = withInputCache(ctx)
|
||||
_, gensErr := generatorsOf(ctx, open)
|
||||
gens, gensErr := generators(ctx, open)
|
||||
hostShelf := shelf[hostModule]
|
||||
meshWide := map[string]bool{}
|
||||
engines := map[string]bool{}
|
||||
@@ -367,24 +361,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())})
|
||||
}
|
||||
}
|
||||
// Who was issued a credential by this machine, by name only: never what the credential is, sealed or not.
|
||||
issued, err := inv.SecretsFrom(ctx, n.Name)
|
||||
if err != nil {
|
||||
return snapshot.Facts{}, err
|
||||
}
|
||||
for _, g := range issued {
|
||||
m.Grants = append(m.Grants, scrub.Grant(g.Name, g.Consumer, g.ConsumerModule, g.Local))
|
||||
}
|
||||
bound, err := inv.BindingsFor(ctx, n.Name)
|
||||
if err != nil {
|
||||
return snapshot.Facts{}, err
|
||||
}
|
||||
for consumer, byProvision := range bound {
|
||||
for provision, c := range byProvision {
|
||||
m.Bindings = append(m.Bindings, scrub.Binding(consumer, provision, c.Node, c.Module))
|
||||
}
|
||||
}
|
||||
m.Declaration = declarationFacts(ctx, open, n.Name, gensErr, scrub)
|
||||
m.Declaration = declarationFacts(ctx, open, n.Name, gens, gensErr, scrub)
|
||||
if ctx.Err() != nil {
|
||||
return snapshot.Facts{}, ctx.Err()
|
||||
}
|
||||
@@ -479,14 +456,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, gensErr error,
|
||||
scrub *snapshot.Scrubber) snapshot.Declaration {
|
||||
func declarationFacts(ctx context.Context, open *stores, node string, gens map[string]catalogue.Generator,
|
||||
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 := composeQuestion(ctx, open, node, compose.Foreseeing, true)
|
||||
declared, problems, err := composedAndValidated(ctx, open, node, gens, Foreseeing)
|
||||
if err != nil {
|
||||
d.Problems = []string{scrub.Text(oneLine(err.Error()))}
|
||||
return d
|
||||
@@ -519,6 +496,41 @@ func declarationFacts(ctx context.Context, open *stores, node string, gensErr er
|
||||
return d
|
||||
}
|
||||
|
||||
// composedAndValidated composes one machine's declaration — as a push would (Allocating) or as the next
|
||||
// push will without making anything (Foreseeing) — with the order it was last sent, and runs the
|
||||
// node-engine's own validator over the body. An error is that it did not compose; problems are what the
|
||||
// validator refuses.
|
||||
func composedAndValidated(ctx context.Context, open *stores, node string, gens map[string]catalogue.Generator,
|
||||
choosing Choosing) (sendable, []string, error) {
|
||||
plan, settings, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
declared, err := declarationWith(ctx, open, node, plan, settings, gens, choosing)
|
||||
if err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
record, err := open.inventory.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
if declared.Sequence, err = open.inventory.Sequence(ctx, record.ID); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
if declared.Epoch, err = open.inventory.SentEpoch(ctx, record.ID); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
// And the generation it was last sent, as the would-send is (novox/hq issue 234).
|
||||
if declared.Generation, err = open.inventory.SentGeneration(ctx, record.ID); err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
body, err := declared.Body()
|
||||
if err != nil {
|
||||
return sendable{}, nil, err
|
||||
}
|
||||
return declared, validate.Declaration(body), nil
|
||||
}
|
||||
|
||||
// resourceNames are a declaration's resources as `type:id`, sorted.
|
||||
func resourceNames(resources []map[string]any) []string {
|
||||
out := make([]string, 0, len(resources))
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
)
|
||||
|
||||
// 1394 (novox/hq ADR 0456 clause 2): the snapshot this controller writes is format 2, which carries each machine's grants and
|
||||
// bindings, and the reader it shares with the build seat (mesh-sdk's go/facts) takes formats 1 and 2 and refuses one newer.
|
||||
func TestTheWriterWritesFormatTwoAndTheReaderTakesBothFormats(t *testing.T) {
|
||||
if writtenFormat != 2 {
|
||||
t.Fatalf("the writer writes format %d, not 2", 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)
|
||||
}
|
||||
}
|
||||
// Format 2 carries a machine's grants and bindings, and they are encoded in one order whatever order they were made in.
|
||||
grants := []snapshot.Grant{{Provision: "s3-bucket", ConsumerNode: "bbbbbb", ConsumerModule: "files"},
|
||||
{Provision: "s3-bucket", ConsumerNode: "aaaaaa", ConsumerModule: "files"}}
|
||||
bindings := []snapshot.Binding{{ConsumerModule: "files", Provision: "s3-bucket", ProviderNode: "cccccc", ProviderModule: "objects"}}
|
||||
encode := func(g []snapshot.Grant) []byte {
|
||||
body, err := (snapshot.Facts{Format: writtenFormat, Taken: taken, Machines: []snapshot.Machine{{Name: "abcdef", Length: 6,
|
||||
Grants: g, Bindings: bindings}}}).Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
body := encode(grants)
|
||||
// 1394/AC3 (the bytes do not depend on the order the rows came in)
|
||||
if reversed := encode([]snapshot.Grant{grants[1], grants[0]}); string(reversed) != string(body) {
|
||||
t.Error("grants in another order encode to other bytes")
|
||||
}
|
||||
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 != 2 {
|
||||
t.Errorf("the snapshot says format %d", raw.Format)
|
||||
}
|
||||
for _, m := range raw.Machines {
|
||||
for _, key := range []string{"grants", "bindings"} {
|
||||
if _, has := m[key]; !has {
|
||||
t.Errorf("a format 2 snapshot carries no %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||
)
|
||||
|
||||
// The facts snapshot (novox/hq to-be 45 §9): composed from the store, every machine under a pseudonym
|
||||
@@ -43,8 +42,12 @@ func aMeshWithSecrets(t *testing.T) (*stores, []string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A push's worth of composition, which makes the pair credential the consumer is sent.
|
||||
gens, err := generators(ctx, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, node := range []string{"laptop", "anchor"} {
|
||||
if _, err := composeSend(ctx, open, node); err != nil {
|
||||
if _, _, err := composedAndValidated(ctx, open, node, gens, Allocating); err != nil {
|
||||
t.Fatalf("%s does not compose: %v", node, err)
|
||||
}
|
||||
}
|
||||
@@ -56,11 +59,6 @@ func aMeshWithSecrets(t *testing.T) (*stores, []string) {
|
||||
// Sealed, never kept plain (ADR 0004): the sealed blobs are what the store holds, and none may leave.
|
||||
secrets = append(secrets, s.ForConsumer, s.ForProvider)
|
||||
}
|
||||
// A binding recorded for the consumer on the laptop, to the provider on the anchor.
|
||||
if err := open.inventory.RecordBindings(ctx, "laptop", []inventory.Binding{{Machine: "laptop", Consumer: "files",
|
||||
Provision: "s3-bucket", Provider: catalogue.Chosen{Node: "anchor", Module: "objects"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return open, secrets
|
||||
}
|
||||
|
||||
@@ -70,9 +68,6 @@ func TestTheFactsCarryNoSecretNoAddressAndNoName(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Format != 2 {
|
||||
t.Fatalf("the writer wrote format %d, not 2 (issue 1394)", f.Format)
|
||||
}
|
||||
body, err := f.Encode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -95,20 +90,6 @@ func TestTheFactsCarryNoSecretNoAddressAndNoName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1394/AC1: the provider's machine lists the grant, the consumer's machine the binding, machine names scrubbed.
|
||||
anchorFacts, _ := f.Machine(snapshot.Pseudonym("machine", "anchor"))
|
||||
laptopFacts, _ := f.Machine(snapshot.Pseudonym("machine", "laptop"))
|
||||
wantGrant := snapshot.Grant{Provision: "s3-bucket", ConsumerNode: snapshot.Pseudonym("machine", "laptop"), ConsumerModule: "files"}
|
||||
if len(anchorFacts.Grants) != 1 || anchorFacts.Grants[0] != wantGrant {
|
||||
t.Errorf("the anchor's grants read as %+v, want %+v", anchorFacts.Grants, wantGrant)
|
||||
}
|
||||
wantBinding := snapshot.Binding{ConsumerModule: "files", Provision: "s3-bucket",
|
||||
ProviderNode: snapshot.Pseudonym("machine", "anchor"), ProviderModule: "objects"}
|
||||
if len(laptopFacts.Bindings) != 1 || laptopFacts.Bindings[0] != wantBinding {
|
||||
t.Errorf("the laptop's bindings read as %+v, want %+v", laptopFacts.Bindings, wantBinding)
|
||||
}
|
||||
// 1394/AC2: the text scan above ran over a snapshot that holds those rows, so no sealed value is among them.
|
||||
|
||||
// What a check needs is there: every machine, its length, its modules, its declaration composing.
|
||||
if len(f.Machines) != 2 || f.Longest() != len("laptop") {
|
||||
t.Fatalf("machines %+v, longest %d", f.Machines, f.Longest())
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"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/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
|
||||
"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"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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 (
|
||||
"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"
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
|
||||
@@ -6,10 +6,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"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"
|
||||
@@ -102,6 +103,18 @@ func senderOf(caller string) string {
|
||||
return fmt.Sprintf("%s (pid %d on %s, build %s)", caller, os.Getpid(), host, version)
|
||||
}
|
||||
|
||||
// namedModules are the modules a declaration names: its machine's set, less what was left out of it.
|
||||
func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string {
|
||||
out := make([]string, 0, len(plan.Modules))
|
||||
for _, m := range plan.Modules {
|
||||
if _, left := leftOut[m.Module]; !left {
|
||||
out = append(out, m.Module)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// heardGenerationRefusal keeps a machine's refusal of a send for its generation, so S20 names its sender
|
||||
// and says it (novox/hq issue 234): on the send it refused or — when the mesh has no record of sending that
|
||||
// sequence — as a refusal of its own, naming the sender as unknown. Either way S20 is raised: a refusal the
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func TestAConsumerPinnedBackIsNoLongerGrantedByTheProviderItLeft(t *testing.T) {
|
||||
@@ -35,21 +35,17 @@ func TestAConsumerPinnedBackIsNoLongerGrantedByTheProviderItLeft(t *testing.T) {
|
||||
if err := inv.PinProvision(ctx, "laptop", "postgres-database", "anchor", "store"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, _, err := resolveNode(ctx, open, "laptop")
|
||||
plan, _, err := planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Only a send makes a credential; a question makes nothing.
|
||||
if _, err := composeSend(ctx, open, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, plan, "board", "postgres-database"); n.From != "anchor" {
|
||||
t.Fatalf("pinned to the anchor and bound to %s", n.From)
|
||||
}
|
||||
if err := inv.RecordBindings(ctx, "laptop", boundToData(plan, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grants, _, unbound, err := grantsOf(ctx, open, "anchor")
|
||||
grants, _, unbound, err := grantsFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -61,13 +57,10 @@ func TestAConsumerPinnedBackIsNoLongerGrantedByTheProviderItLeft(t *testing.T) {
|
||||
if err := inv.PinProvision(ctx, "laptop", "postgres-database", "laptop", "store"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, _, err = resolveNode(ctx, open, "laptop")
|
||||
plan, _, err = planFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := composeSend(ctx, open, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, plan, "board", "postgres-database"); n.From != "laptop" {
|
||||
t.Fatalf("pinned back to the laptop and bound to %s", n.From)
|
||||
}
|
||||
@@ -83,7 +76,7 @@ func TestAConsumerPinnedBackIsNoLongerGrantedByTheProviderItLeft(t *testing.T) {
|
||||
}
|
||||
|
||||
// The store it left: no longer granted, and said.
|
||||
grants, _, unbound, err = grantsOf(ctx, open, "anchor")
|
||||
grants, _, unbound, err = grantsFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -94,7 +87,7 @@ func TestAConsumerPinnedBackIsNoLongerGrantedByTheProviderItLeft(t *testing.T) {
|
||||
strings.Join(unbound[0].BoundTo, ",") != "laptop" {
|
||||
t.Fatalf("the consumer that moved is not the one said: %+v", unbound)
|
||||
}
|
||||
planned, settings, err := resolveNode(ctx, open, "anchor")
|
||||
planned, settings, err := planFor(ctx, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -112,7 +105,7 @@ func TestAConsumerPinnedBackIsNoLongerGrantedByTheProviderItLeft(t *testing.T) {
|
||||
}
|
||||
|
||||
// The store it is bound to: granted.
|
||||
grants, _, unbound, err = grantsOf(ctx, open, "laptop")
|
||||
grants, _, unbound, err = grantsFor(ctx, open, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -138,7 +131,7 @@ func TestAConsumerWhoseResolutionCannotBeReadIsNotWithdrawn(t *testing.T) {
|
||||
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
||||
}
|
||||
}
|
||||
if _, err := composeSend(ctx, open, "laptop"); err != nil {
|
||||
if _, _, err := planFor(ctx, open, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -151,15 +144,14 @@ func TestAConsumerWhoseResolutionCannotBeReadIsNotWithdrawn(t *testing.T) {
|
||||
if err := inv.RecordSealingKey(ctx, laptop.ID, "not a key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := resolveNode(ctx, open, "laptop"); err == nil || unresolvable(err) {
|
||||
if _, _, err := planFor(ctx, open, "laptop"); err == nil || unresolvable(err) {
|
||||
t.Fatalf("the seam this test relies on moved: %v", err)
|
||||
}
|
||||
grants, _, unbound, err := grantsOf(ctx, open, "anchor")
|
||||
grants, _, unbound, err := grantsFor(ctx, open, "anchor")
|
||||
if err == nil {
|
||||
t.Fatalf("an unreadable consumer was answered: grants %+v, unbound %+v", grants, unbound)
|
||||
}
|
||||
// mesh-sdk names the provider's node where the controller's own words named the provision.
|
||||
if !strings.Contains(err.Error(), "what laptop asked of") || !strings.Contains(err.Error(), "cannot be read") {
|
||||
if !strings.Contains(err.Error(), "what laptop asked of postgres-database cannot be read") {
|
||||
t.Fatalf("the error does not say whose resolution could not be read: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The store test's manifests are manifests the controller takes in: checked here, where no store is needed,
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
@@ -66,11 +66,15 @@ func TestTheGrantsStepSendsTheUserListAndNothingElse(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, _, err := resolveNode(under, open, "anchor")
|
||||
plan, settings, err := planFor(under, open, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared, err := composeSend(under, open, "anchor")
|
||||
gens, err := generators(under, open)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared, err := declarationWith(under, open, "anchor", plan, settings, gens, Allocating)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Every catalogue module that runs something long-lived declares how it is ready (novox/hq ADR 0240 rule
|
||||
|
||||
@@ -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/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -106,8 +107,6 @@ func buildName(commit string) string {
|
||||
// (heldBack), and answers only the machines held. A machine whose set cannot be worked out is left
|
||||
// to the send, which says why.
|
||||
func heldMachines(ctx context.Context, open *stores, names []string) (map[string][]string, error) {
|
||||
// One reading of the mesh for every machine this looks at.
|
||||
ctx = withInputCache(ctx)
|
||||
out := map[string][]string{}
|
||||
if len(names) == 0 {
|
||||
return out, nil
|
||||
@@ -126,7 +125,7 @@ func heldMachines(ctx context.Context, open *stores, names []string) (map[string
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range names {
|
||||
plan, _, err := resolveNode(ctx, open, node)
|
||||
plan, _, err := planFor(ctx, open, node)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -264,17 +263,15 @@ func flushBehind(ctx context.Context, open *stores, nodes []inventory.Node, hand
|
||||
|
||||
// composeForPush is how a push composes one machine: its set resolved, what it cannot host and what
|
||||
// is left out of it said, and its declaration allocated.
|
||||
func composeForPush(open *stores) func(held context.Context, node string) (sendable, error) {
|
||||
func composeForPush(open *stores, gens map[string]catalogue.Generator) func(held context.Context, node string) (sendable, error) {
|
||||
return func(held context.Context, node string) (sendable, error) {
|
||||
// Read afresh for each machine: the send before it made and recorded things.
|
||||
held = freshInput(held)
|
||||
plan, _, err := resolveNode(held, open, node)
|
||||
plan, settings, err := planFor(held, open, node)
|
||||
if err != nil {
|
||||
return sendable{}, err
|
||||
}
|
||||
reportUnhostable(node, plan)
|
||||
reportKept(held, plan)
|
||||
declared, err := composeSend(held, open, node)
|
||||
declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating)
|
||||
if err == nil {
|
||||
reportLeftOut(node, declared)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user