frombus fills the composition's input from placement, node records, the indexer's manifests and mesh-config's slice, so one composer serves the node agent, the check and the grants part (issue 1126, ADR 0547 §2, ADR 0549 §3)
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check
|
||||
// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126).
|
||||
//
|
||||
// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its
|
||||
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the assigner's composition check for every
|
||||
// node (ADR 0539 §6's check, ADR 0547 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
|
||||
// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads.
|
||||
//
|
||||
// **What it reads, and from whom.**
|
||||
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the
|
||||
// indexer's state `placement`, one key per node (ADR 0549 §2);
|
||||
// - what each node knows of itself (its place on the private network, capabilities, accounts, keys, networks,
|
||||
// tunnel, and on the hub the adopted tunnel and its carried peers): the node-engine's state `records`;
|
||||
// - every module's manifest: the indexer's `modules.<module>` and its manifests by digest, or the digest the
|
||||
// composing node's want names where it runs another build (Scope.Kept);
|
||||
// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the
|
||||
// caller composes every node (ADR 0547 §2, the prepare step's L4).
|
||||
//
|
||||
// **Not yet read** (each a later subtask of issue 1126): licences, own secrets, issued credentials and core boxes, which
|
||||
// the vault answers sealed to the node (ADR 0550 §3, §5). Without them the composition stands in for each and names it
|
||||
// foreseen, as compose.Reading does for a value the Input does not hold. No node is joining before ADR 0547's third
|
||||
// milestone (ADR 0550 §6), so Joining is always empty.
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a
|
||||
// read that failed, never an absent key (an absent key is an empty answer).
|
||||
type Reader interface {
|
||||
// Placements is every node's placement, by node (delivery.PlacementBucket).
|
||||
Placements(ctx context.Context) (map[string]delivery.Placement, error)
|
||||
// Records is every node's record, by node (delivery.NodeRecordsBucket).
|
||||
Records(ctx context.Context) (map[string]delivery.NodeRecord, error)
|
||||
// Modules is every module the indexer holds, by module (delivery.ModulesBucket).
|
||||
Modules(ctx context.Context) (map[string]delivery.Module, error)
|
||||
// Manifest is the raw manifest of a digest (delivery.ManifestsBucket).
|
||||
Manifest(ctx context.Context, digest string) ([]byte, error)
|
||||
// Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`,
|
||||
// keys `<node>.<module>.<key>`), decoded; nil when it has none.
|
||||
Values(ctx context.Context, node, module string) (map[string]any, error)
|
||||
}
|
||||
|
||||
// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the
|
||||
// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take
|
||||
// compose's defaults.
|
||||
type Mesh struct {
|
||||
OverlayCIDR string
|
||||
InternalSuffix string
|
||||
Authority string
|
||||
// Predefined is the list of predefined keys mesh-config published, which a manifest's values falling back to a
|
||||
// predefined key are judged against; nil reads each manifest as the indexer does (catalogue.ParseManifestForInputs).
|
||||
Predefined catalogue.PredefinedList
|
||||
}
|
||||
|
||||
// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the
|
||||
// composition check sets it to compose every node. Kept is, by module, the manifest digest the composing node's wants
|
||||
// name where it differs from the indexer's trunk: that node composes the module as it runs it.
|
||||
type Scope struct {
|
||||
Node string
|
||||
Every bool
|
||||
Kept map[string]string
|
||||
}
|
||||
|
||||
// Input is the composition input for s, read through r.
|
||||
func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) {
|
||||
placements, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
|
||||
}
|
||||
modules, err := r.Modules(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the indexer's modules: %w", err)
|
||||
}
|
||||
if _, ok := placements[s.Node]; !ok && !s.Every {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
}
|
||||
|
||||
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
|
||||
Catalogue: map[string]catalogue.Manifest{}}
|
||||
read := func(module, digest string) (catalogue.Manifest, error) {
|
||||
raw, err := r.Manifest(ctx, digest)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
|
||||
}
|
||||
if mesh.Predefined != nil {
|
||||
return catalogue.ParseManifestWith(raw, mesh.Predefined)
|
||||
}
|
||||
return catalogue.ParseManifestForInputs(raw)
|
||||
}
|
||||
for _, name := range sortedKeys(modules) {
|
||||
m := modules[name]
|
||||
if m.Manifest == "" {
|
||||
continue // a registered module the indexer has not read: nothing to compose it from
|
||||
}
|
||||
man, err := read(name, m.Manifest)
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
in.Catalogue[name] = man
|
||||
}
|
||||
|
||||
// The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes
|
||||
// with mesh-sdk and always wins, as the controller's providedModules and the grants part have it.
|
||||
provided, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
var private catalogue.Manifest
|
||||
if err := json.Unmarshal(provided, &private); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the private network's manifest mesh-sdk provides: %w", err)
|
||||
}
|
||||
in.Catalogue[overlay.Name] = private
|
||||
|
||||
var missing []string
|
||||
for _, node := range nodesOf(placements, records) {
|
||||
rec, hasRecord := records[node]
|
||||
p := placements[node]
|
||||
if !hasRecord {
|
||||
if len(p.Modules) > 0 {
|
||||
missing = append(missing, node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
n := nodeOf(rec, p)
|
||||
withValues := s.Every || node == s.Node
|
||||
for _, m := range p.Modules {
|
||||
var values map[string]any
|
||||
if withValues {
|
||||
if values, err = r.Values(ctx, node, m.Module); err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
|
||||
}
|
||||
}
|
||||
if layers := m.Layers(node, values); layers != nil {
|
||||
if n.Settings == nil {
|
||||
n.Settings = map[string][]catalogue.Layer{}
|
||||
}
|
||||
n.Settings[m.Module] = layers
|
||||
}
|
||||
}
|
||||
if node == s.Node {
|
||||
for _, module := range sortedKeys(s.Kept) {
|
||||
man, err := read(module, s.Kept[module])
|
||||
if err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
if n.Kept == nil {
|
||||
n.Kept = map[string]catalogue.Manifest{}
|
||||
}
|
||||
n.Kept[module] = man
|
||||
}
|
||||
}
|
||||
in.Nodes = append(in.Nodes, n)
|
||||
in.Holdings = append(in.Holdings, p.Held()...)
|
||||
if rec.Tunnel != nil {
|
||||
if in.Tunnels == nil {
|
||||
in.Tunnels = map[string]compose.FoundTunnel{}
|
||||
}
|
||||
in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*rec.Tunnel), NodeAdopted: rec.Adopted}
|
||||
if rec.Place.Hub {
|
||||
in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*rec.Tunnel), Hub: node, Adopted: rec.HubAdopted}
|
||||
}
|
||||
}
|
||||
if rec.Place.Hub {
|
||||
for _, c := range rec.Carried {
|
||||
in.CarriedPeers = append(in.CarriedPeers, compose.CarriedPeer{PublicKey: c.PublicKey, Address: c.Address,
|
||||
EnrolledAs: c.EnrolledAs, Named: c.Named})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return compose.Input{}, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
if !s.Every {
|
||||
if _, ok := records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Compose is the node's compositions over the bus (ADR 0547 §2): Input for s, then compose.Compositions in the Reading
|
||||
// mode at the fingerprints fps gives each module, as the controller composes them.
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return compose.Compositions(in, s.Node, fps, compose.Reading)
|
||||
}
|
||||
|
||||
// nodeOf is the composition's node from its record and its placement.
|
||||
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
n := compose.Node{
|
||||
Name: rec.Node,
|
||||
Place: compose.Place{Key: rec.Place.Key, Endpoint: rec.Place.Endpoint, Site: rec.Place.Site, Hub: rec.Place.Hub, Address: rec.Place.Address},
|
||||
Assigned: p.Assigned(), PublicDomain: rec.PublicDomain,
|
||||
Account: rec.Account, AccountHome: rec.AccountHome, AgentAccount: rec.AgentAccount, AgentAccountHome: rec.AgentHome,
|
||||
Adopted: rec.Adopted, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey,
|
||||
Taken: rec.Taken, OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
|
||||
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
|
||||
}
|
||||
if len(rec.Capabilities) > 0 {
|
||||
n.Capabilities = make(map[string]bool, len(rec.Capabilities))
|
||||
for _, c := range rec.Capabilities {
|
||||
n.Capabilities[c] = true
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func tunnelOf(t delivery.RecordTunnel) 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
|
||||
}
|
||||
|
||||
// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in.
|
||||
func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string {
|
||||
seen := map[string]bool{}
|
||||
for n := range placements {
|
||||
seen[n] = true
|
||||
}
|
||||
for n := range records {
|
||||
seen[n] = true
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// bus is a Reader over values held in memory, counting whose config values were read.
|
||||
type bus struct {
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
manifests map[string][]byte
|
||||
values map[string]map[string]any // by node.module
|
||||
valuesRead []string
|
||||
}
|
||||
|
||||
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
return b.placements, nil
|
||||
}
|
||||
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
|
||||
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
|
||||
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.manifests[digest]
|
||||
if !ok {
|
||||
return nil, errors.New("no such manifest")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
b.valuesRead = append(b.valuesRead, node+"."+module)
|
||||
return b.values[node+"."+module], nil
|
||||
}
|
||||
|
||||
func publicKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the
|
||||
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
|
||||
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
|
||||
func TestInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := delivery.ManifestDigest(xterm)
|
||||
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
|
||||
caps := []string{"container-runtime", "systemd", "wireguard"}
|
||||
tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}}}
|
||||
mk := func() *bus {
|
||||
return &bus{
|
||||
placements: map[string]delivery.Placement{
|
||||
"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: overlay.Name}},
|
||||
Holds: []delivery.Holding{{Claim: "mesh-terminal", Scope: "mesh", Module: "xterm"}}},
|
||||
"laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: overlay.Name},
|
||||
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}}},
|
||||
},
|
||||
records: map[string]delivery.NodeRecord{
|
||||
"anchor": {Node: "anchor", Place: delivery.NodePlace{Key: keys["anchor"], Endpoint: "anchor.example:51820",
|
||||
Site: "here", Hub: true, Address: "10.77.0.1"}, Capabilities: caps, Tunnel: tunnel, Contract: 2,
|
||||
Carried: []delivery.RecordCarriedPeer{{PublicKey: "peer", Address: "10.77.0.9", EnrolledAs: "phone"}}},
|
||||
"laptop": {Node: "laptop", Place: delivery.NodePlace{Key: keys["laptop"], Endpoint: "laptop.example:51820",
|
||||
Site: "here", Address: "10.77.0.2"}, Capabilities: caps},
|
||||
},
|
||||
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "unread": {}},
|
||||
manifests: map[string][]byte{digest: xterm},
|
||||
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}},
|
||||
}
|
||||
}
|
||||
|
||||
// What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it.
|
||||
var private catalogue.Manifest
|
||||
raw, _ := json.Marshal(overlay.Manifest())
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := catalogue.ParseManifestForInputs(xterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true}
|
||||
tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}}}
|
||||
want := compose.Input{
|
||||
Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed},
|
||||
Nodes: []compose.Node{
|
||||
{Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true,
|
||||
Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true},
|
||||
{Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here",
|
||||
Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm"}, Capabilities: capsSet,
|
||||
Ports: map[string]map[int]int{"xterm": {80: 8080}},
|
||||
Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}}},
|
||||
},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-terminal", Scope: "mesh", Node: "anchor", Module: "xterm"}},
|
||||
Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}},
|
||||
Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor"},
|
||||
CarriedPeers: []compose.CarriedPeer{{PublicKey: "peer", Address: "10.77.0.9", EnrolledAs: "phone"}},
|
||||
}
|
||||
|
||||
b := mk()
|
||||
got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.MarshalIndent(got, "", " ")
|
||||
wj, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm"}) {
|
||||
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
|
||||
}
|
||||
|
||||
// The same composer over both: the compositions from the bus are the store's, byte for byte.
|
||||
fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64)}
|
||||
fromBus, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) {
|
||||
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
|
||||
}
|
||||
|
||||
b = mk()
|
||||
if _, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm"}) {
|
||||
// Every node's placed modules, and nothing placed nowhere: anchor places no xterm.
|
||||
t.Errorf("the check read %v", b.valuesRead)
|
||||
}
|
||||
|
||||
kept := mk()
|
||||
kept.manifests["old"] = xterm
|
||||
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "old"}})
|
||||
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": parsed}) || in.Nodes[0].Kept != nil {
|
||||
t.Errorf("the want's manifest is kept on the composing node alone: %v, %v", in.Nodes, err)
|
||||
}
|
||||
|
||||
for name, c := range map[string]struct {
|
||||
change func(*bus)
|
||||
scope Scope
|
||||
says string
|
||||
}{
|
||||
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
|
||||
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
|
||||
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
|
||||
"a kept one unread": {func(b *bus) {}, Scope{Node: "laptop", Kept: map[string]string{"xterm": "gone"}}, "digest gone"},
|
||||
} {
|
||||
b := mk()
|
||||
c.change(b)
|
||||
if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
func TestHaveKey(t *testing.T) {
|
||||
t.Run("presence", testPresenceKey)
|
||||
t.Run("pulse", testPulseKey)
|
||||
t.Run("pulse ttl", testPulseTTL)
|
||||
if HaveBucket != "node-engine_have" {
|
||||
t.Fatalf("HaveBucket = %q", HaveBucket)
|
||||
}
|
||||
@@ -368,7 +369,7 @@ func TestPhaseHealthRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1131/AC7: the pulse's time to live is 120 seconds minus the move's age; at the bound the move is stalled.
|
||||
func TestPulseTTL(t *testing.T) {
|
||||
func testPulseTTL(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
age time.Duration
|
||||
ttl time.Duration
|
||||
|
||||
@@ -72,10 +72,21 @@ func (p Provider) Chosen() catalogue.Chosen { return catalogue.Chosen{Node: p.No
|
||||
|
||||
// PlacedModule is one module placed on the node: the host port each port it declares is published on (by the port the
|
||||
// software listens on), and, by provision, the provider each of its requirements that keeps data is bound to.
|
||||
//
|
||||
// MeshWide and Layer are where the module's things are, as a settings layer's placement holds them (catalogue.Layer's
|
||||
// Placement: `places`, `accesses`, `networks`, `endpoints`, `expose`, `reach`, `ports`; ADR 0348 §11): the whole mesh's
|
||||
// layer and this node's, which the controller keeps in its table `placement` (ADR 0549 §6 names it) and composes as two
|
||||
// layers, the mesh's first. Each node's file repeats the mesh-wide layer of the modules placed on it, so a node reads
|
||||
// its own key alone. HasLayer says the node has a layer of its own even when it holds no placement, since a layer
|
||||
// that is there and empty still carries the module's config values (Layers).
|
||||
type PlacedModule struct {
|
||||
Module string `json:"module"`
|
||||
Ports map[string]int `json:"ports,omitempty"`
|
||||
Bindings map[string]Provider `json:"bindings,omitempty"`
|
||||
MeshWide map[string]any `json:"mesh-wide,omitempty"`
|
||||
HasMesh bool `json:"has-mesh-wide,omitempty"`
|
||||
Layer map[string]any `json:"layer,omitempty"`
|
||||
HasLayer bool `json:"has-layer,omitempty"`
|
||||
}
|
||||
|
||||
// Holding is one seat a module placed on the node holds (catalogue.Held without its node, which is the file's).
|
||||
@@ -281,6 +292,24 @@ func (p Placement) HostPorts() map[string]map[int]int {
|
||||
return out
|
||||
}
|
||||
|
||||
// Layers is one module's settings layers on the node, as the composition reads them (compose.Node's Settings): the
|
||||
// whole mesh's layer when it has one, then the node's, which carries the module's config values as mesh-config resolved
|
||||
// them for this node (values, nil when none) beside its placement; a module with no layer and no value has none. This is
|
||||
// the controller's SettingsFor for a module that takes its config values from mesh-config (issue 664).
|
||||
func (m PlacedModule) Layers(node string, values map[string]any) []catalogue.Layer {
|
||||
var out []catalogue.Layer
|
||||
if m.HasMesh || len(m.MeshWide) > 0 {
|
||||
out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: map[string]any{}, Placement: m.MeshWide})
|
||||
}
|
||||
if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 {
|
||||
if values == nil {
|
||||
values = map[string]any{}
|
||||
}
|
||||
out = append(out, catalogue.Layer{From: node, Values: values, Placement: m.Layer})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Held is the node's holdings as the composition reads them (compose.Input's Holdings rows for this node).
|
||||
func (p Placement) Held() []catalogue.Held {
|
||||
out := make([]catalogue.Held, 0, len(p.Holds))
|
||||
|
||||
@@ -99,6 +99,23 @@ func TestPlacement(t *testing.T) {
|
||||
if got := g14.Held(); !reflect.DeepEqual(got, []catalogue.Held{{Claim: "node-shell", Scope: "node", Node: "g14", Module: "zsh"}}) {
|
||||
t.Errorf("Held = %v", got)
|
||||
}
|
||||
layered := PlacedModule{Module: "app", HasMesh: true, Layer: map[string]any{"expose": map[string]any{"80": "mesh"}}}
|
||||
want := []catalogue.Layer{
|
||||
{From: catalogue.MeshWideLayer, Values: map[string]any{}},
|
||||
{From: "g14", Values: map[string]any{"mode": "x"}, Placement: map[string]any{"expose": map[string]any{"80": "mesh"}}},
|
||||
}
|
||||
if got := layered.Layers("g14", map[string]any{"mode": "x"}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Layers = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a"}).Layers("g14", map[string]any{"k": 1}); len(got) != 1 || got[0].From != "g14" {
|
||||
t.Errorf("values alone make the node's layer: %#v", got)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a", HasLayer: true}).Layers("g14", nil); len(got) != 1 || got[0].Values == nil {
|
||||
t.Errorf("an empty layer of the node's own is kept, with empty values: %#v", got)
|
||||
}
|
||||
if got := (PlacedModule{Module: "a"}).Layers("g14", nil); got != nil {
|
||||
t.Errorf("no layer and no value is no layer: %#v", got)
|
||||
}
|
||||
empty := Placement{Node: "x"}
|
||||
if empty.ChosenPins() != nil || empty.Bound() != nil || empty.HostPorts() != nil || len(empty.Held()) != 0 {
|
||||
t.Error("an empty placement reads as something")
|
||||
|
||||
Reference in New Issue
Block a user