novox/hq 04-ISSUES/107. The controller already held a per-node lock while it composed and recorded each send; the order existed and was thrown away at the wire. Each send now takes the next number for its node, one higher than the last, under that hold and before the body exists — so the number is inside what the mesh signs, and a replayed older declaration cannot borrow a newer one's. Zero is not sent. A host reads absence as "no order claimed", which is the shape of every declaration before this, so nothing that worked before changes for a machine sent nothing since numbering existed. One subtlety, and it is the one that would have read every machine as behind for ever: the mesh decides a machine is behind by comparing the digest of what it WOULD send against what it DID send, and a number changes the bytes. The read-only comparison composes with the number the machine was LAST sent, not a fresh one, so it is byte for byte what was sent when nothing else changed. Hosts went first and every machine runs one that understands the field.
106 lines
4.1 KiB
Go
106 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// sendable is a declaration as a machine is sent it: its resources and, for an adopted node, its
|
|
// mode and which modules were taken on it (novox/hq ADR 0100).
|
|
//
|
|
// **Body is the only place the envelope is marshalled.** It was written by hand at every send site,
|
|
// in the digest the mesh compares, and in `plan --json`; a key added at one and not another would
|
|
// make a machine look out of date for ever, or send something `plan` never showed.
|
|
type sendable struct {
|
|
Resources []map[string]any
|
|
// Sequence orders this send against every other to the same node: one higher each time, taken
|
|
// under the node's hold just before the body is made (novox/hq 04-ISSUES/107). Zero is not sent
|
|
// at all, which a host reads as "no order claimed" — the shape of every declaration before this.
|
|
Sequence int64
|
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
|
Adoption *adoptionEnvelope
|
|
}
|
|
|
|
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
|
// it that it runs; Untaken is, for every module it runs that is not taken, the ids of every one of
|
|
// that module's resources — what the host keeps as found until the module is taken (ADR 0103). Ids
|
|
// rather than a rule to split them by, because a module's name may contain a dot.
|
|
type adoptionEnvelope struct {
|
|
Taken []string `json:"taken"`
|
|
Untaken map[string][]string `json:"untaken,omitempty"`
|
|
}
|
|
|
|
// Body is the declaration's bytes, as sent and as digested.
|
|
func (s sendable) Body() ([]byte, error) {
|
|
envelope := map[string]any{"declaration": 1, "resources": s.Resources}
|
|
if s.Adoption != nil {
|
|
envelope["adoption"] = s.Adoption
|
|
}
|
|
if s.Sequence > 0 {
|
|
envelope["sequence"] = s.Sequence
|
|
}
|
|
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
|
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
|
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
|
if len(s.Resources) == 0 {
|
|
envelope["owns_nothing"] = true
|
|
}
|
|
return json.Marshal(envelope)
|
|
}
|
|
|
|
// adoptionOf is the envelope for a node, nil when it is converged.
|
|
func adoptionOf(ctx context.Context, inv *inventory.Inventory, record inventory.Node,
|
|
plan catalogue.Resolution, composed catalogue.Composed) (*adoptionEnvelope, error) {
|
|
if !record.Adopted {
|
|
return nil, nil
|
|
}
|
|
taken, err := inv.Taken(ctx, record.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return adoptionFor(plan, taken, composed), nil
|
|
}
|
|
|
|
// adoptionFor is the envelope computed from what was taken and who owns each resource.
|
|
//
|
|
// Every module the node runs that is not taken is untaken — including one pulled in by another
|
|
// rather than assigned: what is found is kept until its module is taken, whoever put it there.
|
|
func adoptionFor(plan catalogue.Resolution, taken []string,
|
|
composed catalogue.Composed) *adoptionEnvelope {
|
|
isTaken := map[string]bool{}
|
|
for _, m := range taken {
|
|
isTaken[m] = true
|
|
}
|
|
out := &adoptionEnvelope{Taken: []string{}}
|
|
runs := map[string]bool{}
|
|
for _, m := range plan.Modules {
|
|
runs[m.Module] = true
|
|
if isTaken[m.Module] {
|
|
out.Taken = append(out.Taken, m.Module)
|
|
}
|
|
}
|
|
sort.Strings(out.Taken)
|
|
for _, r := range composed.Resources {
|
|
id, _ := r["id"].(string)
|
|
module, owned := composed.Owner[id]
|
|
// Every kind, not only files and containers (novox/hq ADR 0103): a directory, a service, a
|
|
// container mounting what was found and an action run in a held container all reach what
|
|
// the machine already has. What the mesh declares of its own is never held.
|
|
if !owned || !runs[module] || isTaken[module] ||
|
|
strings.HasPrefix(id, catalogue.AdoptionPrefix) {
|
|
continue
|
|
}
|
|
if out.Untaken == nil {
|
|
out.Untaken = map[string][]string{}
|
|
}
|
|
out.Untaken[module] = append(out.Untaken[module], id)
|
|
}
|
|
return out
|
|
}
|