Merge pull request 'A placement change is answered by the nodes it places: the candidate contract, composing a given placement, and replicated seats in the placement check (issue 1126, ADR 0559)' (#106) from feat/1126-l2b into main
This commit was merged in pull request #106.
This commit is contained in:
@@ -312,12 +312,15 @@ func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err
|
||||
const ResolvedBucket = "mesh-config_resolved"
|
||||
|
||||
// NodeComposeReads is what a node's agent reads to compose its own modules (novox/hq ADR 0547 §2, ADR 0549 §3, issue
|
||||
// 1126): every key of the indexer's placement, modules and manifests and of the builder's builds, and of mesh-config's
|
||||
// resolved values only its own node's, `<node>.>`. Reads only; the node is checked as every subject token is by the
|
||||
// 1126): every key of the indexer's placement, placement candidates, modules and manifests and of the builder's builds,
|
||||
// and of mesh-config's resolved values only its own node's, `<node>.>`. Every candidate key, not only `*.<node>`: a node
|
||||
// answering its candidate composes with the whole group's placement (the seed places every node at once, and a binding
|
||||
// crosses nodes), and nothing in a placement is secret. Reads only; the node is checked as every subject token is by the
|
||||
// caller's principal.
|
||||
func NodeComposeReads(node string) []string {
|
||||
var out []string
|
||||
for _, bucket := range []string{delivery.PlacementBucket, delivery.ModulesBucket, delivery.ManifestsBucket, delivery.BuilderBuildsBucket} {
|
||||
for _, bucket := range []string{delivery.PlacementBucket, delivery.PlacementCandidatesBucket, delivery.ModulesBucket,
|
||||
delivery.ManifestsBucket, delivery.BuilderBuildsBucket} {
|
||||
out = append(out, readGrants(bucket, "")...)
|
||||
}
|
||||
return append(out, readGrants(ResolvedBucket, node+".>")...)
|
||||
|
||||
+38
-27
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -18,7 +19,8 @@ import (
|
||||
// (mesh-hand-compose) gets that one. Each module's Data says whether its manifest declares data. The compositions are not
|
||||
// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest).
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
return compositions(in, node, fps, mode, false)
|
||||
out, skipped, _, err = compositions(in, node, fps, mode, false)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's
|
||||
@@ -34,10 +36,21 @@ const BusUsersStandIn = "the bus's user list"
|
||||
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
|
||||
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
|
||||
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
return compositions(in, node, fps, Reading, true)
|
||||
out, skipped, _, err := compositions(in, node, fps, Reading, true)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
|
||||
// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
|
||||
// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
|
||||
// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
|
||||
// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
|
||||
// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
|
||||
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, Foreseeing, false)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
found := false
|
||||
@@ -48,36 +61,34 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
}
|
||||
d, problems, err := ComposeIn(reading, node, mode)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
}
|
||||
if applied {
|
||||
var stood []string
|
||||
for _, x := range d.StoodIn {
|
||||
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
|
||||
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
|
||||
if x != BusUsersStandIn {
|
||||
stood = append(stood, x)
|
||||
}
|
||||
}
|
||||
stood = append(stood, d.Foreseen...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
stood = append(stood, "port "+p)
|
||||
}
|
||||
if len(stood) > 0 {
|
||||
sort.Strings(stood)
|
||||
return nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
|
||||
for _, x := range d.StoodIn {
|
||||
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
|
||||
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
|
||||
if x != BusUsersStandIn {
|
||||
stood = append(stood, x)
|
||||
}
|
||||
}
|
||||
stood = append(stood, d.Foreseen...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
stood = append(stood, "port "+p)
|
||||
}
|
||||
sort.Strings(stood)
|
||||
stood = slices.Compact(stood) // an own secret is both stood in for and foreseen
|
||||
if applied && len(stood) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
@@ -86,7 +97,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var kept []json.RawMessage
|
||||
keptPaths := map[string]bool{}
|
||||
@@ -98,7 +109,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if _, ok := fps[head.Module]; head.Module != "" && !ok {
|
||||
left[head.Module] = true
|
||||
@@ -118,7 +129,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
out, err = delivery.SplitComposed(node, kept, fps, filled,
|
||||
delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for key, c := range out {
|
||||
if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
|
||||
@@ -130,5 +141,5 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
skipped = append(skipped, m)
|
||||
}
|
||||
sort.Strings(skipped)
|
||||
return out, skipped, nil
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Answer is a node's answer to its group's placement candidates (novox/hq ADR 0559 §4): s.Node composed in shadow with
|
||||
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
|
||||
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
|
||||
// nothing; head and revision are the candidate's, said back in the answer.
|
||||
//
|
||||
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
|
||||
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
|
||||
// composition stands in for is judged: a value of a module the group adds (on this node or another: its credential, own
|
||||
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
|
||||
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, head string, revision uint64) delivery.CandidateAnswer {
|
||||
a := delivery.CandidateAnswer{Head: head, Revision: revision}
|
||||
refuse := func(err error) delivery.CandidateAnswer {
|
||||
a.Refused = err.Error()
|
||||
a.Verdict = delivery.CandidateRefused
|
||||
return a
|
||||
}
|
||||
trunk, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return refuse(fmt.Errorf("frombus: placement: %w", err))
|
||||
}
|
||||
in, err := Input(ctx, r, mesh, s)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
// What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
|
||||
// whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
|
||||
// assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
|
||||
// issued under Added (review of L2b, round 1, finding 1).
|
||||
runs := map[string]bool{}
|
||||
for _, c := range running {
|
||||
if c.Module != "" {
|
||||
runs[c.Module] = true
|
||||
}
|
||||
}
|
||||
var consumed []compose.CredentialKey
|
||||
for _, k := range asConsumer {
|
||||
if runs[k.ConsumerModule] {
|
||||
consumed = append(consumed, k)
|
||||
}
|
||||
}
|
||||
asConsumer = consumed
|
||||
if len(asConsumer)+len(asProvider) > 0 {
|
||||
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
|
||||
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
|
||||
}
|
||||
}
|
||||
composed, skipped, stood, err := compose.CompositionsShadow(in, s.Node, fps)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
|
||||
// names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
|
||||
runsOwn := false
|
||||
for _, c := range running {
|
||||
runsOwn = runsOwn || c.Module == ""
|
||||
}
|
||||
if !runsOwn {
|
||||
for key, c := range composed {
|
||||
if c.Module == "" {
|
||||
delete(composed, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
cmp := delivery.CompareComposed(composed, running)
|
||||
a.Same, a.Differs, a.Removed = cmp.Same, cmp.Differs, cmp.Removed
|
||||
|
||||
// What the group adds, by node and module: placed by a candidate and not by the trunk on that node.
|
||||
added := map[string]bool{}
|
||||
for node, p := range s.Placements {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
was := map[string]bool{}
|
||||
for _, m := range trunk[node].Modules {
|
||||
was[m.Module] = true
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if !was[m.Module] {
|
||||
added[node+"/"+m.Module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
unissued := map[string][]string{}
|
||||
refusals := map[string][]string{}
|
||||
for _, x := range stood {
|
||||
node, module := standsInFor(x, s.Node)
|
||||
switch {
|
||||
case node == s.Node && runs[module]:
|
||||
// A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
|
||||
// empty, and every module counts as the group's).
|
||||
refusals[module] = append(refusals[module], x)
|
||||
case module != "" && added[node+"/"+module]:
|
||||
if node == s.Node {
|
||||
unissued[module] = append(unissued[module], x)
|
||||
} // another node's addition: the module here that carries it differs, which the assigner judges
|
||||
case module == "" || node != s.Node:
|
||||
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
|
||||
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
|
||||
default:
|
||||
refusals[module] = append(refusals[module], x)
|
||||
}
|
||||
}
|
||||
addedHere := map[string]delivery.ModuleAdded{}
|
||||
for _, m := range cmp.Added {
|
||||
addedHere[m.Module] = m
|
||||
}
|
||||
for _, m := range skipped { // placed, and no build given to compose it at
|
||||
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
|
||||
}
|
||||
for m, xs := range unissued {
|
||||
got := addedHere[m]
|
||||
got.Module = m
|
||||
got.Unissued = append(got.Unissued, xs...)
|
||||
addedHere[m] = got
|
||||
}
|
||||
for _, m := range sortedKeys(addedHere) {
|
||||
a.Added = append(a.Added, addedHere[m])
|
||||
}
|
||||
for _, m := range sortedKeys(refusals) {
|
||||
if _, isAdded := addedHere[m]; isAdded {
|
||||
continue // a module the candidate adds here: what it lacks is said under Added
|
||||
}
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
|
||||
}
|
||||
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
|
||||
var (
|
||||
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
|
||||
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
|
||||
)
|
||||
|
||||
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
|
||||
// `port module:port`, a grant `the grant of <provision> for <module> on <node>`, a need `<module> on <node> needs ...`;
|
||||
// the module empty when the words name none (the certificate, the authority).
|
||||
func standsInFor(x, node string) (string, string) {
|
||||
if m := grantStandIn.FindStringSubmatch(x); m != nil {
|
||||
return m[2], m[1]
|
||||
}
|
||||
if m := needsStandIn.FindStringSubmatch(x); m != nil {
|
||||
return m[2], m[1]
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(x, "port "); ok {
|
||||
if module, _, ok := strings.Cut(rest, ":"); ok {
|
||||
return node, module
|
||||
}
|
||||
}
|
||||
if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") {
|
||||
return node, module
|
||||
}
|
||||
return node, ""
|
||||
}
|
||||
@@ -80,10 +80,15 @@ type Mesh struct {
|
||||
// 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.
|
||||
//
|
||||
// Placements, when set, stands in for the indexer's placement of the nodes it names: a node's candidate placement
|
||||
// (delivery.PlacementCandidate), so a node composes in shadow with a catalogue pull request's placement and compares
|
||||
// the result with what it runs (issue 1126); a nil value removes the node's placement. The rest is read as always.
|
||||
type Scope struct {
|
||||
Node string
|
||||
Every bool
|
||||
Kept map[string]string
|
||||
Node string
|
||||
Every bool
|
||||
Kept map[string]string
|
||||
Placements map[string]*delivery.Placement
|
||||
}
|
||||
|
||||
// Input is the composition input for s, read through r.
|
||||
@@ -92,6 +97,23 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: placement: %w", err)
|
||||
}
|
||||
if len(s.Placements) > 0 {
|
||||
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
|
||||
for node, p := range placements {
|
||||
given[node] = p
|
||||
}
|
||||
for node, p := range s.Placements {
|
||||
if p == nil {
|
||||
delete(given, node)
|
||||
continue
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
given[node] = *p
|
||||
}
|
||||
placements = given
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
return compose.Input{}, fmt.Errorf("frombus: node records: %w", err)
|
||||
@@ -283,16 +305,14 @@ func declaresOwnSecrets(in compose.Input, node string) bool {
|
||||
// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
|
||||
// decided of it, and where its modules run).
|
||||
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
d := p.Decided
|
||||
n := compose.Node{
|
||||
Name: rec.Node,
|
||||
Place: compose.Place{Key: rec.TunnelKey, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address},
|
||||
Assigned: p.Assigned(), PublicDomain: d.PublicDomain,
|
||||
Account: d.Account, AccountHome: d.AccountHome, AgentAccount: d.AgentAccount, AgentAccountHome: d.AgentHome,
|
||||
Adopted: d.Adopted, Taken: d.Taken, Pins: p.ChosenPins(), Bound: p.Bound(), Ports: p.HostPorts(), SealingKey: rec.SealingKey,
|
||||
Name: rec.Node,
|
||||
Place: compose.Place{Key: rec.TunnelKey},
|
||||
SealingKey: rec.SealingKey,
|
||||
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
|
||||
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
|
||||
}
|
||||
placeNode(&n, p)
|
||||
if len(rec.Capabilities) > 0 {
|
||||
n.Capabilities = make(map[string]bool, len(rec.Capabilities))
|
||||
for _, c := range rec.Capabilities {
|
||||
@@ -302,6 +322,80 @@ func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
return n
|
||||
}
|
||||
|
||||
// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes
|
||||
// (its keys, links, networks, certificate) and its settings layers are left as they are.
|
||||
func placeNode(n *compose.Node, p delivery.Placement) {
|
||||
d := p.Decided
|
||||
n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}
|
||||
n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain
|
||||
n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome
|
||||
n.Adopted, n.Taken = d.Adopted, d.Taken
|
||||
n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts()
|
||||
}
|
||||
|
||||
// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's
|
||||
// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its
|
||||
// config values those in already carries for it (the layer of the node's own name), and what the machine observes is
|
||||
// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
|
||||
// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given
|
||||
// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's
|
||||
// Placements).
|
||||
func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) {
|
||||
if len(placements) == 0 {
|
||||
return in, nil
|
||||
}
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
index := map[string]int{}
|
||||
for i, n := range out.Nodes {
|
||||
index[n.Name] = i
|
||||
}
|
||||
for _, node := range sortedKeys(placements) {
|
||||
p := placements[node]
|
||||
i, ok := index[node]
|
||||
if !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node)
|
||||
}
|
||||
if p == nil {
|
||||
p = &delivery.Placement{Node: node}
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
n := out.Nodes[i]
|
||||
placeNode(&n, *p)
|
||||
settings := map[string][]catalogue.Layer{}
|
||||
for _, m := range p.Modules {
|
||||
var values map[string]any
|
||||
for _, l := range n.Settings[m.Module] {
|
||||
if l.From == node {
|
||||
values = l.Values
|
||||
}
|
||||
}
|
||||
if layers := m.Layers(node, values); layers != nil {
|
||||
settings[m.Module] = layers
|
||||
}
|
||||
}
|
||||
n.Settings = nil
|
||||
if len(settings) > 0 {
|
||||
n.Settings = settings
|
||||
}
|
||||
out.Nodes[i] = n
|
||||
}
|
||||
out.Holdings = nil
|
||||
for _, h := range in.Holdings {
|
||||
if _, given := placements[h.Node]; !given {
|
||||
out.Holdings = append(out.Holdings, h)
|
||||
}
|
||||
}
|
||||
for _, node := range sortedKeys(placements) {
|
||||
if p := placements[node]; p != nil {
|
||||
out.Holdings = append(out.Holdings, p.Held()...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -28,6 +29,9 @@ type bus struct {
|
||||
valuesRead []string
|
||||
own []compose.OwnSecretRow
|
||||
ownErr error
|
||||
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
|
||||
// module its placement does not put on the node.
|
||||
asked []compose.CredentialKey
|
||||
}
|
||||
|
||||
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
@@ -46,7 +50,17 @@ func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error
|
||||
return b.own, b.ownErr
|
||||
}
|
||||
|
||||
func (b *bus) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
func (b *bus) Credentials(_ context.Context, node string, asConsumer, _ []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
b.asked = append(b.asked, asConsumer...)
|
||||
for _, k := range asConsumer {
|
||||
placed := false
|
||||
for _, m := range b.placements[node].Modules {
|
||||
placed = placed || m.Module == k.ConsumerModule
|
||||
}
|
||||
if !placed {
|
||||
return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
|
||||
}
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -179,6 +193,150 @@ func TestInput(t *testing.T) {
|
||||
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
|
||||
}
|
||||
|
||||
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
|
||||
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
|
||||
// here. Placed puts the same placement over the store's input unchanged.
|
||||
running := make([]delivery.Composed, 0, len(fromBus))
|
||||
for _, c := range fromBus {
|
||||
running = append(running, c)
|
||||
}
|
||||
laptop := mk().placements["laptop"]
|
||||
asIs, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
|
||||
t.Errorf("the candidate equal to the placement: %+v", c)
|
||||
}
|
||||
moved := mk().placements["anchor"]
|
||||
moved.Decided.Endpoint = "anchor.example:51821"
|
||||
changed, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
|
||||
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
|
||||
t.Errorf("nothing runs: %+v", c)
|
||||
}
|
||||
|
||||
// ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not
|
||||
// yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes.
|
||||
keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`)
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
// A provider and a consumer of a pair credential on the one node (compose's objects and files).
|
||||
objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
|
||||
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
|
||||
one := func(placed ...string) *bus {
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
return &bus{placements: map[string]delivery.Placement{"n1": p},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
|
||||
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
|
||||
delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
|
||||
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
|
||||
}
|
||||
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
|
||||
"objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)}
|
||||
runs, _, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runsNow []delivery.Composed
|
||||
for _, c := range runs {
|
||||
runsNow = append(runsNow, c)
|
||||
}
|
||||
objectsRun, _, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var consumerRuns []delivery.Composed
|
||||
for _, c := range objectsRun {
|
||||
consumerRuns = append(consumerRuns, c)
|
||||
}
|
||||
withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
|
||||
var out []delivery.Composed
|
||||
for _, c := range cs {
|
||||
if c.Module != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
placedAs := func(modules ...string) map[string]*delivery.Placement {
|
||||
p := one(modules...).placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
b *bus
|
||||
given map[string]*delivery.Placement
|
||||
running []delivery.Composed
|
||||
verdict string
|
||||
check func(delivery.CandidateAnswer) bool
|
||||
}{
|
||||
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 3 && a.Head == "h1" && a.Revision == 7 }},
|
||||
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
|
||||
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
|
||||
for _, m := range a.Added {
|
||||
if m.Module == "keeper" {
|
||||
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}},
|
||||
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
|
||||
}},
|
||||
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
|
||||
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
|
||||
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
|
||||
}},
|
||||
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
|
||||
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
|
||||
}},
|
||||
"cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
|
||||
func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
|
||||
} {
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given}, oneFps, c.running, "h1", 7)
|
||||
if a.Verdict != c.verdict || !c.check(a) {
|
||||
t.Errorf("%s: %+v", name, a)
|
||||
}
|
||||
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
|
||||
}
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Placed with the node's own placement changes the input: %v", err)
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
|
||||
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
|
||||
}
|
||||
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
|
||||
t.Error("Placed a node the input holds no node for")
|
||||
}
|
||||
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
|
||||
t.Error("a given placement naming another node was taken")
|
||||
}
|
||||
|
||||
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
|
||||
// placement the bus holds, and read back it is the store's input again.
|
||||
for node, placed := range mk().placements {
|
||||
|
||||
+14
-4
@@ -17,6 +17,8 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/sdkversion"
|
||||
)
|
||||
|
||||
@@ -64,10 +66,15 @@ type Request struct {
|
||||
}
|
||||
|
||||
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
|
||||
//
|
||||
// Placements are, by node, the placement the case composes that node with in place of what Input says of it: a change's
|
||||
// own placement files, so a check composes a catalogue pull request's placement (issue 1126, frombus.Placed); a null
|
||||
// placement leaves nothing placed on the node. A case without them composes Input's.
|
||||
type Case struct {
|
||||
Name string `json:"name"`
|
||||
Manifests map[string]Manifest `json:"manifests"`
|
||||
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Manifests map[string]Manifest `json:"manifests"`
|
||||
Kept map[string]map[string]Manifest `json:"kept,omitempty"`
|
||||
Placements map[string]*delivery.Placement `json:"placements,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest is a module's manifest as its repository holds it (Raw, the bytes), or the digest the delivery indexer keeps
|
||||
@@ -251,7 +258,10 @@ func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
|
||||
if err != nil {
|
||||
return CaseVerdict{}, err
|
||||
}
|
||||
in := r.Input
|
||||
in, err := frombus.Placed(r.Input, c.Placements)
|
||||
if err != nil {
|
||||
return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err)
|
||||
}
|
||||
in.Catalogue = shelf
|
||||
in.Nodes = slices.Clone(in.Nodes)
|
||||
in.Holdings = slices.Clone(in.Holdings)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}`
|
||||
@@ -100,6 +101,19 @@ func TestARequestItCannotRunIsRefused(t *testing.T) {
|
||||
if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil {
|
||||
t.Error("an unknown depth runs")
|
||||
}
|
||||
// A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not.
|
||||
placed := Request{V: Version, Input: compose.Input{Nodes: []compose.Node{{Name: "a"}}}, Machines: []string{"a"}, Depth: DepthResolve,
|
||||
Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}},
|
||||
{Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}},
|
||||
Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}}
|
||||
if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes ||
|
||||
!strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") {
|
||||
t.Errorf("a case composes its own placement: %+v, %v", v, err)
|
||||
}
|
||||
placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}}
|
||||
if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") {
|
||||
t.Errorf("a case placing a node the input lacks: %v", err)
|
||||
}
|
||||
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
|
||||
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
|
||||
t.Fatalf("a request with no machines: %+v, %v", v, err)
|
||||
|
||||
@@ -452,6 +452,12 @@ func ParseCompositionKey(key string) (group, headsHash string, err error) {
|
||||
return group, headsHash, nil
|
||||
}
|
||||
|
||||
// CompositionUnanswered is the Words of a composition that failed only because a node placed at the head or on the
|
||||
// trunk did not answer the group's placement candidate within its bound (novox/hq ADR 0559 §5): the checker takes a
|
||||
// later passing composition of the same heads in without a new check, since the failure was a part of the mesh not
|
||||
// heard, not the change (ADR 0548 §1).
|
||||
const CompositionUnanswered = "did not answer the placement candidate within its bound; judged again when it answers"
|
||||
|
||||
// CompositionVerdict is the value of a composition key: the group and its heads hash, the State (pass, fail or waits),
|
||||
// the Words of a refusal or of what it waits for, the mesh-sdk commit it composed with (the assigner's own, or the
|
||||
// group's mesh-sdk member's head, ADR 0482 clause 3), the machines composed, the first machine that failed, the
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Comparison is what CompareComposed finds, module by module: the same, differing, added by what was composed (not run
|
||||
// now) and removed by it (run now, not composed). Each list is sorted by module.
|
||||
type Comparison struct {
|
||||
Same []string
|
||||
Differs []ModuleDiffers
|
||||
Added []ModuleAdded
|
||||
Removed []string
|
||||
}
|
||||
|
||||
// CompareComposed compares the compositions a node composed (by key, as compose.Compositions answers them) with the ones
|
||||
// it runs, as its agent would (ADR 0559 §4: a node's answer to a placement candidate). A module is the same when its
|
||||
// composition's digest is the one it runs (ComposedDigestOf, the signature left out), so the agent would apply nothing
|
||||
// of it; it differs when the digests differ (both named); it is added when composed and not run, removed when run and
|
||||
// not composed. The node's own composition is named HaveNodeWord. The bus's user list (catalogue.BusUsersID) is left out
|
||||
// on both sides: the bus node follows it alone and never applies it from a composition it made (ADR 0547 §2, §8). A
|
||||
// difference names the first resource, by its id, whose content differs or that one side alone holds; when every
|
||||
// resource is equal it says what else differs (their order, the files filled, the data, the node's own parts, a file
|
||||
// carried by digest).
|
||||
func CompareComposed(composed map[string]Composed, running []Composed) Comparison {
|
||||
name := func(c Composed) string {
|
||||
if c.Module == "" {
|
||||
return HaveNodeWord
|
||||
}
|
||||
return c.Module
|
||||
}
|
||||
ours := map[string]Composed{}
|
||||
for _, c := range composed {
|
||||
ours[name(c)] = c
|
||||
}
|
||||
theirs := map[string]Composed{}
|
||||
for _, c := range running {
|
||||
theirs[name(c)] = c
|
||||
}
|
||||
modules := map[string]bool{}
|
||||
for m := range ours {
|
||||
modules[m] = true
|
||||
}
|
||||
for m := range theirs {
|
||||
modules[m] = true
|
||||
}
|
||||
var out Comparison
|
||||
for _, m := range sortedKeys(modules) {
|
||||
a, composedHere := ours[m]
|
||||
b, runs := theirs[m]
|
||||
switch {
|
||||
case !runs:
|
||||
dig, _ := ComposedDigestOf(withoutBusUsers(a))
|
||||
out.Added = append(out.Added, ModuleAdded{Module: m, Composed: dig})
|
||||
case !composedHere:
|
||||
out.Removed = append(out.Removed, m)
|
||||
default:
|
||||
if d, ok := compareOne(m, a, b); ok {
|
||||
out.Same = append(out.Same, m)
|
||||
} else {
|
||||
out.Differs = append(out.Differs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compareOne is one module's comparison: true when the digests agree, else what differs first.
|
||||
func compareOne(module string, composed, running Composed) (ModuleDiffers, bool) {
|
||||
composed, running = withoutBusUsers(composed), withoutBusUsers(running)
|
||||
dc, errC := ComposedDigestOf(composed)
|
||||
dr, errR := ComposedDigestOf(running)
|
||||
if errC != nil {
|
||||
return ModuleDiffers{Module: module, Running: dr, Why: "the composition has no digest: " + errC.Error()}, false
|
||||
}
|
||||
if errR == nil && dc == dr {
|
||||
return ModuleDiffers{}, true
|
||||
}
|
||||
differs := func(resource, why string) (ModuleDiffers, bool) {
|
||||
return ModuleDiffers{Module: module, Composed: dc, Running: dr, Resource: resource, Why: why}, false
|
||||
}
|
||||
// Words for the difference: resource by resource, each side with its large files by digest, so a file carried whole
|
||||
// and the same file carried by digest read as one.
|
||||
a, _, errA := composed.ByDigest()
|
||||
b, _, errB := running.ByDigest()
|
||||
if errA != nil || errB != nil {
|
||||
return differs("", "the compositions differ and cannot be read resource by resource")
|
||||
}
|
||||
ra, rb := resourcesByID(a.Resources), resourcesByID(b.Resources)
|
||||
ids := map[string]bool{}
|
||||
for id := range ra {
|
||||
ids[id] = true
|
||||
}
|
||||
for id := range rb {
|
||||
ids[id] = true
|
||||
}
|
||||
for _, id := range sortedKeys(ids) {
|
||||
x, inA := ra[id]
|
||||
y, inB := rb[id]
|
||||
switch {
|
||||
case !inB:
|
||||
return differs(id, "composed, and not in what runs")
|
||||
case !inA:
|
||||
return differs(id, "in what runs, and not composed")
|
||||
case !bytes.Equal(x, y):
|
||||
return differs(id, "the resource differs")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !jsonEqual(a.Filled, b.Filled):
|
||||
return differs("", "the files the provisioner fills differ")
|
||||
case a.Data != b.Data:
|
||||
return differs("", "whether it declares data differs")
|
||||
case !jsonEqual(a.Own, b.Own):
|
||||
return differs("", "the node's own parts differ")
|
||||
case a.FP != b.FP:
|
||||
return differs("", "the fingerprint differs")
|
||||
}
|
||||
if dA, err := ComposedDigestOf(a); err == nil {
|
||||
if dB, err := ComposedDigestOf(b); err == nil && dA == dB {
|
||||
return differs("", "the same content, a large file carried whole on one side and by digest on the other")
|
||||
}
|
||||
}
|
||||
return differs("", "the same resources in another order")
|
||||
}
|
||||
|
||||
// withoutBusUsers is c without the bus's user list resource (its id `bus-users`, or `<module>.bus-users`).
|
||||
func withoutBusUsers(c Composed) Composed {
|
||||
out := c
|
||||
out.Resources = nil
|
||||
for _, raw := range c.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &head) == nil && (head.ID == catalogue.BusUsersID() || strings.HasSuffix(head.ID, "."+catalogue.BusUsersID())) {
|
||||
continue
|
||||
}
|
||||
out.Resources = append(out.Resources, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resourcesByID is each resource canonical, by its id (a resource without one by its place).
|
||||
func resourcesByID(resources []json.RawMessage) map[string][]byte {
|
||||
out := make(map[string][]byte, len(resources))
|
||||
for i, raw := range resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &head)
|
||||
id := head.ID
|
||||
if id == "" {
|
||||
id = "#" + strconv.Itoa(i)
|
||||
}
|
||||
canon, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
canon = raw
|
||||
}
|
||||
out[id] = canon
|
||||
}
|
||||
return out
|
||||
}
|
||||
+35
-5
@@ -402,6 +402,14 @@ type HaveNode struct {
|
||||
// Record is what the machine observes of itself, typed, for every node and composing part to read (issue 1126,
|
||||
// ADR 0555 §3): the node's record lives in its own have key, so it needs no bucket, writer or grant of its own.
|
||||
Record *NodeRecord `json:"record,omitempty"`
|
||||
// Sealed is, on a node composing its own, each value its own composition carries sealed to the node's key, as
|
||||
// applied (Have.Sealed for the node's own, issue 1126): a value the vault seals again after a restart moves no
|
||||
// composition, so a restart applies nothing again.
|
||||
Sealed []string `json:"sealed,omitempty"`
|
||||
// Candidates is the node's answer to each placement candidate naming it, by group (PlacementCandidate): written by
|
||||
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
|
||||
// candidate.
|
||||
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
|
||||
@@ -497,17 +505,38 @@ func DecodeHave(data []byte) (Have, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// EncodeHaveNode is n as JSON at Version.
|
||||
func (n HaveNode) check() error {
|
||||
for group, a := range n.Candidates {
|
||||
if group == "" {
|
||||
return fmt.Errorf("delivery: a node's candidate answer names no group")
|
||||
}
|
||||
if err := a.check(); err != nil {
|
||||
return fmt.Errorf("delivery: the answer to %s's candidate: %w", group, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeHaveNode is n as JSON at Version; it refuses a candidate answer CandidateAnswer's rules refuse.
|
||||
func EncodeHaveNode(n HaveNode) ([]byte, error) {
|
||||
if err := n.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.V = Version
|
||||
return json.Marshal(n)
|
||||
}
|
||||
|
||||
// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion).
|
||||
// DecodeHaveNode reads a node's facts; it refuses an unknown version (ErrUnknownVersion) and a candidate answer that
|
||||
// is not one.
|
||||
func DecodeHaveNode(data []byte) (HaveNode, error) {
|
||||
var n HaveNode
|
||||
err := decode(data, &n)
|
||||
return n, err
|
||||
if err := decode(data, &n); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if err := n.check(); err != nil {
|
||||
return HaveNode{}, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// EncodePresence is p as JSON at Version.
|
||||
@@ -560,7 +589,8 @@ func SameHave(a, b Have) bool {
|
||||
func SameHaveNode(a, b HaveNode) bool {
|
||||
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
|
||||
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) &&
|
||||
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record))
|
||||
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) &&
|
||||
jsonEqual(a.Sealed, b.Sealed) && jsonEqual(a.Candidates, b.Candidates)
|
||||
}
|
||||
|
||||
// SamePresence says whether two presences are the same value.
|
||||
|
||||
@@ -86,6 +86,14 @@ func TestForgePullCarriesManifestsAndLocks(t *testing.T) {
|
||||
if _, err := EncodeForge(f); err == nil || !strings.Contains(err.Error(), `"c"`) {
|
||||
t.Errorf("locks outside Dirs: %v", err)
|
||||
}
|
||||
// A pull message of the catalogue carries its head's placement files whole (placement candidates, issue 1126).
|
||||
f = Forge{Kind: ForgePull, Repository: CatalogueRepository, Number: 2, Head: "h", Action: PullUpdated,
|
||||
Placements: map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`}, PlacementsRead: true}
|
||||
if data, err := EncodeForge(f); err != nil {
|
||||
t.Errorf("a catalogue pull with placement files: %v", err)
|
||||
} else if got, err := DecodeForge(data); err != nil || !SameForge(got, withV(f)) {
|
||||
t.Errorf("a catalogue pull's placement files changed on the way: %+v, %v", got, err)
|
||||
}
|
||||
for name, f := range map[string]Forge{
|
||||
"refused manifests": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}},
|
||||
"refused locks": {Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Locks: map[string]map[string]string{"a": {"go.sum": "h"}}},
|
||||
|
||||
@@ -371,7 +371,8 @@ func (p Placement) Held() []catalogue.Held {
|
||||
// CheckPlacements is the catalogue's own check of every placement file (ADR 0549 §1), over the placements by node, the
|
||||
// modules the catalogue holds and the nodes enrolled. It returns every refusal, in node order, never only the first: a
|
||||
// placement naming a node not enrolled or a module the catalogue does not hold; two modules of one node on the same host
|
||||
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice;
|
||||
// port; a pin, binding or holding naming a provider that is not placed where it says; a seat of scope mesh held twice, unless the seat is replicated (catalogue.Seat's Replicated, ADR 0223), which
|
||||
// is held once per node;
|
||||
// two nodes with one address on the private network; more than one hub.
|
||||
// A host port a manifest declares without a number and a requirement left unbound are the composition's to find (its
|
||||
// ForeseenPorts and Unbound), which the catalogue's composition check reads.
|
||||
@@ -442,6 +443,11 @@ func CheckPlacements(placements map[string]Placement, held func(module string) b
|
||||
continue
|
||||
}
|
||||
seat := h.Claim + "/" + h.Kind
|
||||
if s, known := catalogue.SeatNamed(h.Claim); known && s.Replicated {
|
||||
// A replicated mesh seat (ADR 0223) is held on several nodes at once, each answering the same thing;
|
||||
// it is still held once on each node.
|
||||
seat += "/" + node
|
||||
}
|
||||
if other, twice := meshSeats[seat]; twice {
|
||||
refusals = append(refusals, fmt.Sprintf("the seat %s, held once for the mesh, is held on %s and %s", h.Claim, other, node))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Placement candidates (issue 1126; ADR 0549 §4: a placement change passes the composition check before any node sees
|
||||
// it). A catalogue pull request that changes a placement file carries every placement file at its head on its pull
|
||||
// message (Forge.Placements). The indexer writes, for each node whose file at the head differs from the trunk's, the
|
||||
// key `<group>.<node>` of its state `placement-candidates`, and deletes it when the pull request merges or closes. It is
|
||||
// a state of its own, since the readers of `placement` refuse any key that is not `placement.<node>`.
|
||||
//
|
||||
// The node it names composes in shadow with the candidate (its own sealed values, the boxes it already runs) and
|
||||
// compares the result with what it runs; it applies nothing. Its answer rides in its own have key, `<node>.node`
|
||||
// (HaveNode.Candidates, by group), which that node alone writes; the assigner's check of the group reads every placed
|
||||
// node's answer at the head and fails on any difference, refusal or silence. Event-driven both ways: the candidate key's
|
||||
// write is the node's event, the `<node>.node` change the check's.
|
||||
const (
|
||||
// PlacementCandidatesState is the indexer's state of placement candidates, and PlacementCandidatesBucket its bucket.
|
||||
PlacementCandidatesState = "placement-candidates"
|
||||
PlacementCandidatesBucket = IndexerModule + "_" + PlacementCandidatesState
|
||||
// PlacementCandidatesHistory is the revisions the bucket keeps per key.
|
||||
PlacementCandidatesHistory = 5
|
||||
)
|
||||
|
||||
// PlacementCandidateKey is the key, in PlacementCandidatesBucket, of one node's candidate placement in one group:
|
||||
// `<group>.<node>`, the group escaped as CandidatesKey escapes it, so a node watches `*.<node>` and a group's keys are
|
||||
// `<group>.*`.
|
||||
func PlacementCandidateKey(group, node string) (string, error) {
|
||||
g, err := groupKey(group)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := checkName("node", node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return g + "." + node, nil
|
||||
}
|
||||
|
||||
// ParsePlacementCandidateKey is the inverse of PlacementCandidateKey; it refuses any other key, naming it.
|
||||
func ParsePlacementCandidateKey(key string) (group, node string, err error) {
|
||||
g, node, ok := strings.Cut(key, ".")
|
||||
if !ok || checkName("node", node) != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key, <group>.<node>", key)
|
||||
}
|
||||
if group, err = parseGroupKey(g); err != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a placement candidate key: %w", key, err)
|
||||
}
|
||||
return group, node, nil
|
||||
}
|
||||
|
||||
// PlacementCandidate is the value of a candidate key: the group, the catalogue pull request and the head its placement
|
||||
// files were read at, and the node's placement at that head; nil Placement says the head removes the node's file.
|
||||
type PlacementCandidate struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Pull Head `json:"pull"`
|
||||
Placement *Placement `json:"placement,omitempty"`
|
||||
}
|
||||
|
||||
func (c PlacementCandidate) check() error {
|
||||
if c.Group == "" {
|
||||
return fmt.Errorf("delivery: a placement candidate names no group")
|
||||
}
|
||||
if err := checkRepository(c.Pull.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Pull.Number <= 0 || c.Pull.Head == "" {
|
||||
return fmt.Errorf("delivery: a placement candidate needs its pull request's number and head")
|
||||
}
|
||||
if c.Placement != nil {
|
||||
return c.Placement.check()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodePlacementCandidate is c as JSON at Version; it refuses what DecodePlacementCandidate refuses.
|
||||
func EncodePlacementCandidate(c PlacementCandidate) ([]byte, error) {
|
||||
if err := c.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.V = Version
|
||||
if c.Placement != nil {
|
||||
p := *c.Placement
|
||||
p.V = Version
|
||||
c.Placement = &p
|
||||
}
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// DecodePlacementCandidate reads a candidate; it refuses an unknown version, no group, a pull request without number or
|
||||
// head, and a placement DecodePlacement refuses.
|
||||
func DecodePlacementCandidate(data []byte) (PlacementCandidate, error) {
|
||||
var c PlacementCandidate
|
||||
if err := decode(data, &c); err != nil {
|
||||
return PlacementCandidate{}, err
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return PlacementCandidate{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// The verdicts of a node's answer to a candidate.
|
||||
const (
|
||||
// CandidateSame: every composition the node composes with the candidate is the one it runs, and it runs no other.
|
||||
CandidateSame = "same"
|
||||
// CandidateDiffers: some composition differs (Differs), is added by the candidate (Added) or removed by it (Removed);
|
||||
// whether the change intends it is the assigner's to judge (ADR 0559 §5).
|
||||
CandidateDiffers = "differs"
|
||||
// CandidateRefused: a value is missing for a module the node runs (Refusals), or the node cannot compose with the
|
||||
// candidate at all (Refused says why: an unbound requirement, a manifest it cannot read).
|
||||
CandidateRefused = "refused"
|
||||
)
|
||||
|
||||
// CandidateAnswer is a node's answer to its group's candidate keys, in HaveNode.Candidates (ADR 0559 §4): the head
|
||||
// and the revision of the node's own key in the group it answered (0 when the group has no key of its own for it: its
|
||||
// file is unchanged and it answers as placed at the head or on the trunk, judged by Head alone), its verdict, the modules composed the same, those that differ with both digests, those
|
||||
// the candidate adds (naming the values not yet issued for each, which is no refusal: a module's credential and boxes
|
||||
// are made only once it is placed, ADR 0549 §5) or removes, and each refusal. The node's own composition is named
|
||||
// "node" (HaveNodeWord).
|
||||
type CandidateAnswer struct {
|
||||
Head string `json:"head"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Verdict string `json:"verdict"`
|
||||
Same []string `json:"same,omitempty"`
|
||||
Differs []ModuleDiffers `json:"differs,omitempty"`
|
||||
Added []ModuleAdded `json:"added,omitempty"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Refusals []ModuleRefused `json:"refusals,omitempty"`
|
||||
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
|
||||
Refused string `json:"refused,omitempty"`
|
||||
}
|
||||
|
||||
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
|
||||
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
|
||||
// when every resource is equal, and why in words.
|
||||
type ModuleDiffers struct {
|
||||
Module string `json:"module"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Running string `json:"running,omitempty"`
|
||||
Resource string `json:"resource,omitempty"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// ModuleAdded is one module the candidate places on the node and the node does not run: its composition's digest when
|
||||
// it composed, and the values not yet issued for it (its own secrets, its grants, a host port), each in the words the
|
||||
// composition stood in for it with.
|
||||
type ModuleAdded struct {
|
||||
Module string `json:"module"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Unissued []string `json:"unissued,omitempty"`
|
||||
}
|
||||
|
||||
// ModuleRefused is a value missing for a module the node runs, in the words the composition stood in for it with.
|
||||
type ModuleRefused struct {
|
||||
Module string `json:"module"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// VerdictOf is the verdict of an answer's lists: refused on any refusal, differs on any difference, addition or
|
||||
// removal, same otherwise.
|
||||
func VerdictOf(a CandidateAnswer) string {
|
||||
switch {
|
||||
case a.Refused != "" || len(a.Refusals) > 0:
|
||||
return CandidateRefused
|
||||
case len(a.Differs)+len(a.Added)+len(a.Removed) > 0:
|
||||
return CandidateDiffers
|
||||
}
|
||||
return CandidateSame
|
||||
}
|
||||
|
||||
func (a CandidateAnswer) check() error {
|
||||
if a.Head == "" {
|
||||
return fmt.Errorf("delivery: a candidate answer names no head")
|
||||
}
|
||||
switch a.Verdict {
|
||||
case CandidateSame, CandidateDiffers, CandidateRefused:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the candidate verdict %q is not same, differs or refused", a.Verdict)
|
||||
}
|
||||
if got := VerdictOf(a); got != a.Verdict {
|
||||
return fmt.Errorf("delivery: a candidate answered %s names what makes it %s", a.Verdict, got)
|
||||
}
|
||||
if a.Refused != "" && len(a.Same)+len(a.Differs)+len(a.Added)+len(a.Removed)+len(a.Refusals) > 0 {
|
||||
return fmt.Errorf("delivery: a candidate the node could not compose names modules")
|
||||
}
|
||||
for _, d := range a.Differs {
|
||||
if d.Module == "" || d.Why == "" {
|
||||
return fmt.Errorf("delivery: a candidate difference names no module or no why")
|
||||
}
|
||||
}
|
||||
for _, d := range a.Added {
|
||||
if d.Module == "" {
|
||||
return fmt.Errorf("delivery: a module the candidate adds is not named")
|
||||
}
|
||||
}
|
||||
for _, d := range a.Refusals {
|
||||
if d.Module == "" || d.Why == "" {
|
||||
return fmt.Errorf("delivery: a candidate refusal names no module or no why")
|
||||
}
|
||||
}
|
||||
for _, m := range a.Removed {
|
||||
if m == "" {
|
||||
return fmt.Errorf("delivery: a module the candidate removes is not named")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -148,6 +149,16 @@ func TestPlacement(t *testing.T) {
|
||||
if r := CheckPlacements(map[string]Placement{"g14": g14, "novox": novox}, held, enrolled); len(r) != 0 {
|
||||
t.Fatalf("a sound placement is refused: %v", r)
|
||||
}
|
||||
// A replicated mesh seat (ADR 0223) is held on two nodes; a seat that is not replicated is refused there.
|
||||
for seat, refused := range map[string]bool{"mesh-dns-resolver": false, "mesh-broker": true} {
|
||||
a := Placement{Node: "ace", Modules: []PlacedModule{{Module: "dns"}}, Holds: []Holding{{Claim: seat, Scope: "mesh", Module: "dns"}}}
|
||||
b := a
|
||||
b.Node = "novox"
|
||||
r := CheckPlacements(map[string]Placement{"ace": a, "novox": b}, held, enrolled)
|
||||
if (len(r) != 0) != refused {
|
||||
t.Errorf("%s on two nodes: refusals %v, want refused %v", seat, r, refused)
|
||||
}
|
||||
}
|
||||
clash := g14
|
||||
clash.Modules = append([]PlacedModule{{Module: "ghost", Ports: map[string]int{"80": 18080}}}, g14.Modules...)
|
||||
clash.Modules[2] = PlacedModule{Module: "app", Ports: map[string]int{"8080": 18080}, Bindings: map[string]Provider{"postgres": {Node: "ace", Module: "postgres"}}}
|
||||
@@ -169,4 +180,116 @@ func TestPlacement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Issue 1126: a catalogue pull request's placement, one candidate key per node, and each node's answer in its have.
|
||||
t.Run("candidates", func(t *testing.T) {
|
||||
key, err := PlacementCandidateKey("feat/1.2", "g14")
|
||||
if err != nil || key != "feat/1=2e2.g14" {
|
||||
t.Fatalf("PlacementCandidateKey = %q, %v", key, err)
|
||||
}
|
||||
if g, n, err := ParsePlacementCandidateKey(key); err != nil || g != "feat/1.2" || n != "g14" {
|
||||
t.Fatalf("ParsePlacementCandidateKey = %q, %q, %v", g, n, err)
|
||||
}
|
||||
for _, bad := range []string{"g14", ".g14", "feat.g.14", "feat.", "fe=t.g14"} {
|
||||
if _, _, err := ParsePlacementCandidateKey(bad); err == nil {
|
||||
t.Errorf("ParsePlacementCandidateKey(%q) took it", bad)
|
||||
}
|
||||
}
|
||||
if PlacementCandidatesBucket != "mesh-delivery-indexer_placement-candidates" {
|
||||
t.Errorf("bucket %q", PlacementCandidatesBucket)
|
||||
}
|
||||
pull := Head{Repository: CatalogueRepository, Number: 7, Head: "h1"}
|
||||
for name, c := range map[string]PlacementCandidate{
|
||||
"placed": {Group: "feat/x", Pull: pull, Placement: &g14},
|
||||
"removed": {Group: "feat/x", Pull: pull},
|
||||
} {
|
||||
raw, err := EncodePlacementCandidate(c)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
back, err := DecodePlacementCandidate(raw)
|
||||
if err != nil || back.Group != c.Group || back.Pull != pull || (back.Placement == nil) != (c.Placement == nil) ||
|
||||
(c.Placement != nil && !SamePlacement(*back.Placement, g14)) {
|
||||
t.Errorf("%s: round trip %+v, %v", name, back, err)
|
||||
}
|
||||
}
|
||||
for name, c := range map[string]PlacementCandidate{
|
||||
"no group": {Pull: pull},
|
||||
"no head": {Group: "g", Pull: Head{Repository: CatalogueRepository, Number: 7}},
|
||||
"bad place": {Group: "g", Pull: pull, Placement: &Placement{Node: "a.b"}},
|
||||
"no repo form": {Group: "g", Pull: Head{Repository: "mesh-catalog", Number: 7, Head: "h"}},
|
||||
} {
|
||||
if _, err := EncodePlacementCandidate(c); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
answers := map[string]CandidateAnswer{
|
||||
"same": {Head: "h1", Revision: 3, Verdict: CandidateSame, Same: []string{"app", "node"}},
|
||||
"differs": {Head: "h1", Revision: 3, Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Module: "app", Resource: "file:/etc/app", Why: "the resource differs"}}},
|
||||
"refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
|
||||
"added": {Head: "h1", Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
|
||||
"a value": {Head: "h1", Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
|
||||
}
|
||||
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers}
|
||||
raw, err := EncodeHaveNode(n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeHaveNode(raw)
|
||||
if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers}) {
|
||||
t.Fatalf("a node's answers changed on the way: %+v, %v", back, err)
|
||||
}
|
||||
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed}) {
|
||||
t.Error("a node's have without its answers counts as the same")
|
||||
}
|
||||
// CompareComposed: the bus's user list is left out on both sides; anything else that differs is named.
|
||||
file := func(id, content string) json.RawMessage {
|
||||
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`)
|
||||
}
|
||||
fp := "src2:" + strings.Repeat("a", 64)
|
||||
comp := func(rs ...json.RawMessage) Composed {
|
||||
return Composed{Node: "novox", Module: "nats", FP: fp, Resources: rs}
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
composed, running Composed
|
||||
resource, why string
|
||||
}{
|
||||
"user list alone": {comp(file("conf", "a"), file("nats.bus-users", "stand-in")), comp(file("conf", "a"), file("nats.bus-users", "real")), "", ""},
|
||||
"a resource": {comp(file("conf", "a")), comp(file("conf", "b")), "conf", "the resource differs"},
|
||||
"one side": {comp(file("conf", "a"), file("more", "x")), comp(file("conf", "a")), "more", "composed, and not in what runs"},
|
||||
"order": {comp(file("conf", "a"), file("more", "x")), comp(file("more", "x"), file("conf", "a")), "", "the same resources in another order"},
|
||||
} {
|
||||
c := CompareComposed(map[string]Composed{"novox.nats." + fp: tc.composed}, []Composed{tc.running})
|
||||
if tc.why == "" {
|
||||
if len(c.Differs) != 0 || len(c.Same) != 1 {
|
||||
t.Errorf("%s: %+v", name, c)
|
||||
}
|
||||
} else if len(c.Differs) != 1 || c.Differs[0].Resource != tc.resource || c.Differs[0].Why != tc.why ||
|
||||
c.Differs[0].Composed == "" || c.Differs[0].Running == "" || c.Differs[0].Composed == c.Differs[0].Running {
|
||||
t.Errorf("%s: differs %+v, want %s: %s with both digests", name, c.Differs, tc.resource, tc.why)
|
||||
}
|
||||
}
|
||||
if c := CompareComposed(nil, []Composed{{Node: "novox", Resources: []json.RawMessage{}}}); len(c.Removed) != 1 || c.Removed[0] != "node" {
|
||||
t.Errorf("the node's own composition run and not composed is removed: %+v", c)
|
||||
}
|
||||
if c := CompareComposed(map[string]Composed{"k": comp(file("conf", "a"))}, nil); len(c.Added) != 1 || c.Added[0].Module != "nats" || c.Added[0].Composed == "" {
|
||||
t.Errorf("a composition not run is added: %+v", c)
|
||||
}
|
||||
for name, a := range map[string]CandidateAnswer{
|
||||
"no head": {Verdict: CandidateSame},
|
||||
"same with a diff": {Head: "h", Verdict: CandidateSame, Differs: answers["differs"].Differs},
|
||||
"differs with none": {Head: "h", Verdict: CandidateDiffers},
|
||||
"refused no reason": {Head: "h", Verdict: CandidateRefused},
|
||||
"unknown verdict": {Head: "h", Verdict: "maybe"},
|
||||
"diff with no module": {Head: "h", Verdict: CandidateDiffers, Differs: []ModuleDiffers{{Why: "x"}}},
|
||||
"same with an added": {Head: "h", Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
|
||||
"differs refusing": {Head: "h", Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
|
||||
"refused and modules": {Head: "h", Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
|
||||
} {
|
||||
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
|
||||
t.Errorf("%s: taken", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -540,6 +540,8 @@ type Forge struct {
|
||||
// Placements are, on a merged message of the catalogue repository, every placement file of the merge commit, the
|
||||
// bytes exact, by node (PlacementFile; novox/hq ADR 0549 §2), and PlacementsRead says they were read, so a catalogue
|
||||
// whose last placement file was removed is told from a message that read none (older writers, another repository).
|
||||
// On a pull message of the catalogue (CatalogueRepository) they are every placement file at the pull request's head,
|
||||
// read whole or not at all, which the indexer writes as placement candidates (PlacementCandidate).
|
||||
Placements map[string]string `json:"placements,omitempty"`
|
||||
PlacementsRead bool `json:"placements-read,omitempty"`
|
||||
}
|
||||
@@ -554,8 +556,10 @@ func (f Forge) check() error {
|
||||
if len(f.Open) > 0 {
|
||||
return fmt.Errorf("delivery: only an open message names open pull requests")
|
||||
}
|
||||
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged {
|
||||
return fmt.Errorf("delivery: only a merged message carries placement files")
|
||||
// A pull message of the catalogue carries its head's placement files, so its change of placement is composed and
|
||||
// answered by the nodes it places before it merges (placement candidates, issue 1126; ADR 0549 §4).
|
||||
if (len(f.Placements) > 0 || f.PlacementsRead) && f.Kind != ForgeMerged && (f.Kind != ForgePull || f.Repository != CatalogueRepository) {
|
||||
return fmt.Errorf("delivery: only a merged message, or a pull message of the catalogue, carries placement files")
|
||||
}
|
||||
if len(f.Placements) > 0 && !f.PlacementsRead {
|
||||
return fmt.Errorf("delivery: a message with placement files must say it read them (placements-read)")
|
||||
|
||||
Reference in New Issue
Block a user