diff --git a/cmd/mesh-controller/compose_once_integration_test.go b/cmd/mesh-controller/compose_once_integration_test.go new file mode 100644 index 00000000..16dd7c4c --- /dev/null +++ b/cmd/mesh-controller/compose_once_integration_test.go @@ -0,0 +1,44 @@ +//go:build integration + +package main + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/compose" +) + +// 1382/AC5: composing every node of one operation builds the Input once; without an operation's cache, once for each. +func TestComposingEveryNodeOfAnOperationBuildsTheInputOnce(t *testing.T) { + open, _ := aMeshWithSecrets(t) + built := 0 + was := composeInputBuilt + composeInputBuilt = func() { built++ } + t.Cleanup(func() { composeInputBuilt = was }) + ctx := withInputCache(t.Context()) + nodes, err := open.inventory.Nodes(ctx) + if err != nil { + t.Fatal(err) + } + for _, n := range nodes { + if _, _, err := resolveNode(ctx, open, n.Name); err != nil && !unresolvable(err) { + t.Fatal(err) + } + if _, _, err := composeQuestion(ctx, open, n.Name, compose.Reading, false); err != nil && !unresolvable(err) { + t.Fatal(err) + } + } + if _, err := generatorsOf(ctx, open); err != nil { + t.Fatal(err) + } + if built != 1 { + t.Fatalf("an operation over %d node(s) built the Input %d time(s)", len(nodes), built) + } + built = 0 + if _, _, err := resolveNode(t.Context(), open, nodes[0].Name); err != nil && !unresolvable(err) { + t.Fatal(err) + } + if built != 1 { + t.Fatalf("a composition with no cache built the Input %d time(s)", built) + } +} diff --git a/cmd/mesh-controller/compose_perform.go b/cmd/mesh-controller/compose_perform.go new file mode 100644 index 00000000..32d2f822 --- /dev/null +++ b/cmd/mesh-controller/compose_perform.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "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) { + inv := open.inventory + did := false + + // The seats passed on: recorded in the store with the controller's own hand act (ADR 0449 ยง4, issue 1317). + if len(d.Passes.Passes) > 0 { + var w io.Writer = os.Stderr + if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok { + w = lw + } + for _, line := range d.Passes.Said { + fmt.Fprintln(w, line) + } + sp := seatPassingOn(inv, w) + for _, p := range d.Passes.Passes { + moved, err := inv.PassSeat(ctx, p.Seat.Name, p.Seat.Scope, p.From, p.To) + if err != nil { + // Said, and the record stays as it was: a composition is never refused for it. + fmt.Fprintf(w, "%s could not be passed from %s on %s to %s on %s: %v\n", p.Seat.Name, + p.From.Module, p.From.Node, p.To.Module, p.To.Node, err) + continue + } + if !moved { + continue // another composition passed it first, and wrote the act + } + did = true + if inv.Throwaway() { + continue + } + if err := sp.act(ctx, p); err != nil { + fmt.Fprintf(w, "%s passed from %s on %s to %s on %s, and the hand act could NOT be recorded: %v\n", + p.Seat.Name, p.From.Module, p.From.Node, p.To.Module, p.To.Node, err) + } + } + } + + // Every pair credential the stores hold none for: made once and kept, and sealed to both ends before it was ever written + // down. The error is the one planFor met: the machine that needs it, and why none could be made. + for _, p := range in.wouldMake { + if err := makePair(ctx, inv, p); err != nil { + return did, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %w", + p.ConsumerModule, p.Consumer, p.Name, p.Provider, err) + } + did = true + } + + // The ports this node's modules were given none for. + if len(d.ForeseenPorts) > 0 { + shelf, err := inv.Catalogue(ctx) + if err != nil { + return did, err + } + for _, one := range d.ForeseenPorts { + module, port, ok := strings.Cut(one, ":") + wanted, perr := strconv.Atoi(port) + if !ok || perr != nil { + return did, fmt.Errorf("the composition named a port the controller cannot read: %q", one) + } + fixed := false + for _, l := range nodeShelf(in, shelf, node)[module].Listens { + if l.Port == wanted { + fixed = l.Fixed + } + } + if _, err := inv.PortFor(ctx, node, module, wanted, fixed); err != nil { + return did, fmt.Errorf("%s needs %d reachable on %s and it could not be assigned: %w", module, wanted, node, err) + } + did = true + } + } + + // The node's own secrets: a module's superuser password, an administrator, an account. The composition refused what the + // send refuses (a bus credential nobody issued, a given value under an old key). + for _, one := range d.StoodIn { + module, name, ok := strings.Cut(one, "/") + if !ok || strings.Contains(one, " ") { + continue // a grant's sealed copy, the certificate or the bus's list: made below or by nobody + } + if _, err := inv.SecretForModule(ctx, node, module, name); err != nil { + return did, err + } + did = true + } + + // The core's own credentials, asked of the vault on every send so a rotated one reaches the machine (core_box.go). + n, ok := nodeOf(in.Input, node) + if ok && n.Stated && n.Contract >= compose.VaultBoxContract { + resolved, err := compose.Resolve(in.Input, node) + if err != nil { + return did, err + } + hands := n.Stated && n.Contract >= compose.HandedContract + handing := catalogue.Rendering{Hands: hands, Provisioner: provisionerAccount(resolved.Plan)} + boxes, err := coreBoxesFor(ctx, inv, node, resolved.Plan.Modules, func(m catalogue.Manifest) bool { + return handing.Handing(m) + }, Allocating) + if err != nil { + return did, err + } + if len(boxes.keep) > 0 { + did = true + } + // A core credential the vault did not answer for and no box was kept for is composed by the controller's own seal + // (core_box.go, ADR 0396); the composition over an Input has no such fallback, so the send is refused, said. + if len(boxes.byController) > 0 { + return did, fmt.Errorf("the vault did not answer for the core credentials of %s (%s) and no box of theirs is kept: "+ + "nothing is sent until it does", node, strings.Join(boxes.byController, ", ")) + } + } + + // 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) +} diff --git a/cmd/mesh-controller/compose_perform_integration_test.go b/cmd/mesh-controller/compose_perform_integration_test.go new file mode 100644 index 00000000..f42c93dc --- /dev/null +++ b/cmd/mesh-controller/compose_perform_integration_test.go @@ -0,0 +1,183 @@ +//go:build integration + +package main + +import ( + "errors" + "fmt" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "github.com/novox/mesh-controller/internal/link" +) + +func aWebModule() catalogue.Manifest { + return catalogue.Manifest{Module: "web", Version: "1", + Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromEverywhere}}, + OwnSecrets: catalogue.OwnSecrets{"admin": {Path: "/etc/web/admin"}}, + Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "web", "ports": []any{"8080"}, + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}, + }} +} + +// 1385/AC1: a port the composition names foreseen is allocated with the inventory's PortFor for that module and port, a second +// composition names it no more, and the number is the one the controller's own Allocating composition gives in the same store. +func TestPerformAllocatesAForeseenPortAndTheNumberIsTheControllers(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, aWebModule()) + if _, err := assign(ctx, open, "laptop", "web"); err != nil { + t.Fatal(err) + } + in, err := readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + d, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing) + if err != nil { + t.Fatal(err) + } + if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" { + t.Fatalf("the composition names the ports %v foreseen, want web:8080", d.ForeseenPorts) + } + did, err := perform(ctx, open, in, "laptop", d) + if err != nil || !did { + t.Fatalf("perform did %v: %v", did, err) + } + held, err := open.inventory.PortsFor(ctx, "laptop") + if err != nil || len(held) != 1 || held[0].Module != "web" || held[0].Wanted != 8080 { + t.Fatalf("the port perform allocated reads %+v: %v", held, err) + } + in, err = readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + if again, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing); err != nil || len(again.ForeseenPorts) != 0 { + t.Fatalf("a second composition names %v foreseen: %v", again.ForeseenPorts, err) + } + // The number is the one the controller allocates in the same store. + plan, settings, err := planFor(ctx, open, "laptop") + if err != nil { + t.Fatal(err) + } + old, err := declarationFor(ctx, open, "laptop", plan, settings) + if err != nil { + t.Fatal(err) + } + after, _, err := compose.ComposeIn(in.Input, "laptop", compose.Foreseeing) + if err != nil { + t.Fatal(err) + } + if got, want := portsOf(after.Resources), portsOf(old.Resources); got != want { + t.Fatalf("the declaration publishes %s; the controller's allocation publishes %s", got, want) + } +} + +func portsOf(resources []map[string]any) string { + var out []string + for _, r := range resources { + if r["type"] == "container" { + out = append(out, fmt.Sprint(r["name"], r["ports"])) + } + } + return strings.Join(out, ";") +} + +// 1385/AC2: an own secret named foreseen is made with SecretForModule, and a pair credential the Input names would-make is +// made as planFor chose; both exist after the send's composition and a second composition names neither. +func TestPerformMakesTheOwnSecretsAndThePairCredentialsTheCompositionNamed(t *testing.T) { + open, _ := aMeshWithSecrets(t) + ctx := t.Context() + inv := open.inventory + register(t, open, aWebModule()) + register(t, open, aConsumerOfTheBucket("reports")) + for _, m := range []string{"web", "reports"} { + if _, err := assign(ctx, open, "laptop", m); err != nil { + t.Fatal(err) + } + } + before, err := madeIn(ctx, open) + if err != nil { + t.Fatal(err) + } + declared, err := composeSend(ctx, open, "laptop") + if err != nil { + t.Fatalf("the send is refused: %v", err) + } + if len(declared.foreseen) != 0 { + t.Fatalf("the declaration still names %v foreseen", declared.foreseen) + } + after, err := madeIn(ctx, open) + if err != nil { + t.Fatal(err) + } + madeNow := func(have, was []string) []string { + seen := map[string]bool{} + for _, s := range was { + seen[s] = true + } + var out []string + for _, s := range have { + if !seen[s] { + out = append(out, s) + } + } + return out + } + if got := madeNow(after.OwnSecrets, before.OwnSecrets); len(got) != 1 || !strings.Contains(got[0], "web/admin") { + t.Errorf("the own secrets made are %v, want laptop web/admin", got) + } + if got := madeNow(after.Pairs, before.Pairs); len(got) != 1 || !strings.Contains(got[0], "reports") { + t.Errorf("the pair credentials made are %v, want the one of reports", got) + } + // And the credential is the one a second composition reads as held. + in, err := readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + if len(in.wouldMake) != 0 { + t.Errorf("after the send the next send would still make %v", in.wouldMake) + } + if _, err := inv.SecretsFrom(ctx, "anchor"); err != nil { + t.Fatal(err) + } +} + +// 1385/AC3: with the vault unreachable and no box of the credential kept, the send fails naming the module, and composes +// nothing to send. +func TestAVaultThatIsSilentRefusesTheSendWhereNoBoxIsKept(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 || !strings.Contains(err.Error(), "mesh-provisioner-client") { + t.Fatalf("the send was not refused, naming the module: %v", err) + } + if len(declared.Resources) != 0 { + t.Fatal("a declaration came with the refusal") + } +} + +// 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) + } + } +} diff --git a/cmd/mesh-controller/compose_sdk.go b/cmd/mesh-controller/compose_sdk.go new file mode 100644 index 00000000..e8a933db --- /dev/null +++ b/cmd/mesh-controller/compose_sdk.go @@ -0,0 +1,260 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "sort" + "strings" + "sync" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" +) + +// 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{}}) +} + +// 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 + } + 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) +} + +// 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 + } + d, problems, err := compose.ComposeIn(in.Input, node, mode) + if err != nil { + return sendable{}, nil, markUnresolvable(err) + } + sayFound(ctx, d) + out := sendableOf(d) + if !withOrder { + out.Sequence, out.Epoch, out.Generation = 0, 0, 0 + } + return out, problems, 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) { + for attempt := 0; ; attempt++ { + in, err := inputFor(ctx, open) + if err != nil { + return sendable{}, err + } + d, _, err := compose.ComposeIn(in.Input, node, compose.Foreseeing) + if err != nil { + return sendable{}, markUnresolvable(err) + } + left := stillForeseen(d) + resolved, rerr := compose.Resolve(in.Input, node) + if rerr != nil { + return sendable{}, 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{}, err + } + } + return out, nil + } + if attempt > 0 { + return sendable{}, 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{}, err + } + // What was made is in the stores now: the next read is a fresh one. + forgetInput(ctx) + } +} + +// 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 +} diff --git a/cmd/mesh-controller/compose_sdk_test.go b/cmd/mesh-controller/compose_sdk_test.go new file mode 100644 index 00000000..dddfd8a7 --- /dev/null +++ b/cmd/mesh-controller/compose_sdk_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "github.com/novox/mesh-controller/internal/inventory" +) + +// A declaration as the SDK composes it is the one the controller sends, byte for byte, whatever it carries: the body is the +// envelope's only marshaller on either side (design 47 R4b, issue 1386). +func TestASendableIsTheComposedDeclarationByteForByte(t *testing.T) { + d := compose.Declared{ + Resources: []map[string]any{{"id": "files.config", "type": "file"}, {"id": "mesh.hosts", "type": "file"}}, + Sequence: 7, Epoch: 3, Generation: 9, ReadsModules: true, + ModuleOf: map[string]string{"files.config": "files"}, BuildIDs: map[string]string{"files": "b-1"}, + Adoption: &compose.Adoption{Taken: []string{"files"}, Untaken: map[string][]string{"nats": {"nats.server"}}}, + Filled: []string{"/etc/files/filled.json"}, Provisioner: "mesh-provisioner", + LeftOut: []string{"cache"}, LeftOutWhy: map[string]string{"cache": "a setting cannot compose"}, + } + want, err := d.Body() + if err != nil { + t.Fatal(err) + } + got, err := sendableOf(d).Body() + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("the controller would send\n%s\nand the composition made\n%s", got, want) + } + if empty, err := sendableOf(compose.Declared{}).Body(); err != nil || !strings.Contains(string(empty), `"owns_nothing":true`) { + t.Fatalf("an empty declaration is not deliberate: %s %v", empty, err) + } +} + +// 1386: what a send records as carried is what the controller's own carriedOn recorded. +func TestWhatASendRecordsAsCarriedIsWhatTheControllerRecorded(t *testing.T) { + plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "postgres"}, {Module: "files"}, {Module: "cache"}}} + current := map[string]inventory.CurrentBuild{"postgres": {Commit: "p2"}, "files": {Commit: "f2"}, "cache": {Commit: "c2"}} + leftOut := map[string]string{"cache": "a setting cannot compose"} + for name, c := range map[string]struct { + sent map[string]string + known bool + kept map[string]string + }{ + "nothing sent": {}, + "sent and kept": {sent: map[string]string{"postgres": "p1", "cache": "c1", "files": "f1"}, known: true, kept: map[string]string{"postgres": "p1"}}, + "sent but not known": {sent: map[string]string{"cache": "c1"}}, + "kept a module not in": {known: true, kept: map[string]string{"mail": "m1"}}, + } { + var names []string + for _, m := range plan.Modules { + names = append(names, m.Module) + } + before := c.sent + if !c.known { + before = nil + } + want := carriedBuilds(names, leftOut, current, before) + for m, was := range c.kept { + if _, carried := want[m]; carried { + want[m] = was + } + } + in := compose.Input{CurrentBuilds: map[string]string{"postgres": "p2", "files": "f2", "cache": "c2"}} + node := compose.Node{SentBuilds: c.sent, SentKnown: c.known, KeptBuilds: c.kept} + if got := buildsOf(in, node, plan, leftOut); !reflect.DeepEqual(got, want) { + t.Errorf("%s: a send records %v, the controller recorded %v", name, got, want) + } + } +} + +// 1385: a declaration that stands in for a value anywhere is still foreseen, in a list the composition names or in the body +// alone (a pair credential's stand-in is in no list); one that stands in for nothing is not. It failed first, before the body +// was scanned. +func TestADeclarationThatStandsInForAValueIsStillForeseen(t *testing.T) { + clean := compose.Declared{Resources: []map[string]any{{"id": "files.config", "type": "file", "content": "ok"}}} + if left := stillForeseen(clean); len(left) != 0 { + t.Fatalf("a declaration that stands in for nothing is foreseen: %v", left) + } + for name, d := range map[string]compose.Declared{ + "an own secret": {StoodIn: []string{"files/admin"}}, + "a port": {ForeseenPorts: []string{"files:80"}}, + "a core box": {CoreBoxesWanted: []compose.CoreBoxWanted{{Node: "anchor", Module: "mesh-vault", Name: "root"}}}, + "a pass of a seat": {Passes: compose.Passes{Passes: []catalogue.SeatPass{{Seat: catalogue.Seat{Name: "mesh-store"}}}}}, + "a pair credential": {Resources: []map[string]any{{"id": "files.needs-db", "sealed": catalogue.ForeseenSealed}}}, + "the certificate": {Resources: []map[string]any{{"id": "proxy.cert", "content": compose.ForeseenCertificate}}}, + "the bus's list": {Resources: []map[string]any{{"id": "nats.users", "content": compose.ForeseenBusUsers}}}, + "the vault's box": {Resources: []map[string]any{{"id": "x", "box": catalogue.ForeseenVaultBox}}}, + } { + if left := stillForeseen(d); len(left) == 0 { + t.Errorf("%s: a declaration that stands in for it is not foreseen", name) + } + } +} + +// 1386: an Input read for a person's push is never the one read for a send that keeps recorded builds, and the cache is +// emptied by what a send made. +func TestAnInputIsCachedPerWhatItKeepsAndForgottenAfterASend(t *testing.T) { + ctx := withInputCache(context.Background()) + plain := keptSignature(ctx) + kept := keptSignature(keepingRecorded(ctx)) + bus := keptSignature(keepingRecorded(ctx, "nats")) + every := keptSignature(keepingEveryBuild(ctx, "anchor")) + for name, sig := range map[string]string{"a push": plain, "a kept send": kept, "the bus step": bus, "the grants step": every} { + for other, o := range map[string]string{"a push": plain, "a kept send": kept, "the bus step": bus, "the grants step": every} { + if name != other && sig == o { + t.Errorf("%s and %s read the same Input", name, other) + } + } + } + cache := ctx.Value(inputCacheKey{}).(*inputCache) + cache.by[plain] = &meshInput{} + forgetInput(ctx) + if len(cache.by) != 0 { + t.Fatal("what a send made left the Input it read in the cache") + } +}