mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
One commit with exactly the tree of the reviewed head35b4509c0a, on main12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
772 lines
28 KiB
Go
772 lines
28 KiB
Go
package main
|
|
|
|
// Config's records out of the controller (novox/hq ADR 0565 §3, §4, §13, §15, and the progressive insight of
|
|
// 2026-10-29): mesh-config keeps each placed module's declarations in its own state `declared`; `compare` lists every
|
|
// difference between its own sources and the controller's mirrors — each module's declarations, the predefined list,
|
|
// each open pull request's config declarations, and every placed module's resolved value on every node both ways;
|
|
// `take-over` asks the operator's Approve under mesh-config's own name and, refused while anything differs, writes the
|
|
// key `takeover.switch` naming the indexer; `hand-back` returns to the mirrors until the lease write.
|
|
//
|
|
// **Which source holds** is the key `switch` alone, read by mesh-config alone:
|
|
// - none written (the interim of mesh-catalog #558): declarations from each placed module's own manifests, the list
|
|
// of predefined keys from the controller's mirror, a pull request's declarations from the controller's
|
|
// pull-declares;
|
|
// - `indexer` (taken over): declarations as before, the list from the indexer's `predefined`, a pull request's from
|
|
// the indexer's `config-candidates`; pull-declares refused;
|
|
// - `controller` (handed back): declarations from the mirror `declared-config`, the list from the mirror,
|
|
// pull-declares taken.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// The states and reads of the switch.
|
|
const (
|
|
// DeclaredState is each placed module's declarations at the trunk, one key per module (ADR 0565 §3).
|
|
DeclaredState = "declared"
|
|
// TakeoverState holds the key SwitchKey and each take-over ask, `ask.<id>`.
|
|
TakeoverState = "takeover"
|
|
SwitchKey = "switch"
|
|
|
|
// MirrorDeclaredRead is the controller's mirror of every registered module's declarations, one key per module:
|
|
// what `compare` holds the own source to, and the source after a hand-back.
|
|
MirrorDeclaredRead = "mesh-controller.declared-config"
|
|
// IndexerPredefinedRead and CandidatesRead are the indexer's states (ADR 0565 §2).
|
|
IndexerPredefinedRead = delivery.IndexerModule + "." + delivery.IndexerPredefinedState
|
|
CandidatesRead = delivery.IndexerModule + "." + delivery.IndexerConfigCandidatesState
|
|
// LeaseRead is the controller's lease: after its write hand-back is gone and the terminal sets nothing (§13).
|
|
LeaseRead = buscompose.ControllerLease
|
|
)
|
|
|
|
// The sources the switch names.
|
|
const (
|
|
SourceInterim = "interim"
|
|
SourceIndexer = "indexer"
|
|
SourceController = "controller"
|
|
)
|
|
|
|
// TakeOverVerb and HandBackVerb are the seat's verbs.
|
|
const (
|
|
TakeOverVerb = "take-over"
|
|
HandBackVerb = "hand-back"
|
|
CompareVerb = "compare"
|
|
)
|
|
|
|
// Switch is the key `takeover.switch`: the source that holds, who chose it, why and on which ask.
|
|
type Switch struct {
|
|
Source string `json:"source"`
|
|
At time.Time `json:"at"`
|
|
By string `json:"by"`
|
|
Why string `json:"why"`
|
|
Ask string `json:"ask,omitempty"`
|
|
}
|
|
|
|
// source is the source that holds now.
|
|
func (c *Config) source() (Switch, error) {
|
|
e, err := c.Store.Get(TakeoverState, SwitchKey)
|
|
if err != nil {
|
|
return Switch{}, fmt.Errorf("the take-over's switch cannot be read: %w", err)
|
|
}
|
|
if e == nil {
|
|
return Switch{Source: SourceInterim}, nil
|
|
}
|
|
var s Switch
|
|
if err := json.Unmarshal(e.Value, &s); err != nil || (s.Source != SourceIndexer && s.Source != SourceController) {
|
|
return Switch{}, fmt.Errorf("the take-over's switch is not one: %s", e.Value)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// leaseAway says the controller's lease is handed away (ADR 0561 §3's reading).
|
|
func (c *Config) leaseAway() bool {
|
|
e, err := c.Store.Get(LeaseRead, buscompose.ControllerLeaseKey)
|
|
var raw []byte
|
|
if e != nil {
|
|
raw = e.Value
|
|
}
|
|
away, _ := buscompose.LeaseHandedAway(raw, e != nil, err)
|
|
return away
|
|
}
|
|
|
|
// ---- declared --------------------------------------------------------------------------------------------
|
|
|
|
// DeclaredKept is one key of `declared`: a module's declarations as the manifest the indexer's `modules.<module>`
|
|
// names holds them, with that manifest's digest and commit (ADR 0565 §3).
|
|
type DeclaredKept struct {
|
|
Module string `json:"module"`
|
|
Manifest string `json:"manifest"`
|
|
Commit string `json:"commit,omitempty"`
|
|
Config map[string]Declaration `json:"settings"`
|
|
}
|
|
|
|
// trunkKept is a module's declarations at the trunk with the manifest they come from.
|
|
func (c *Config) trunkKept(module string) (DeclaredKept, error) {
|
|
key, err := delivery.ModulesKey(module)
|
|
if err != nil {
|
|
return DeclaredKept{}, err
|
|
}
|
|
e, err := c.Store.Get(ModulesRead, key)
|
|
if err != nil || e == nil {
|
|
return DeclaredKept{}, fmt.Errorf("the indexer's modules.%s cannot be read (%v)", module, err)
|
|
}
|
|
var m struct {
|
|
Manifest string `json:"manifest"`
|
|
Commit string `json:"commit"`
|
|
}
|
|
if err := json.Unmarshal(e.Value, &m); err != nil || m.Manifest == "" {
|
|
return DeclaredKept{}, fmt.Errorf("the indexer's modules.%s names no manifest", module)
|
|
}
|
|
d, err := c.manifestDeclarations(module, m.Manifest)
|
|
if err != nil {
|
|
return DeclaredKept{}, err
|
|
}
|
|
return DeclaredKept{Module: module, Manifest: m.Manifest, Commit: m.Commit, Config: d.Config}, nil
|
|
}
|
|
|
|
// writeDeclared makes `declared` hold each placed module's trunk declarations, writing a key only when it differs
|
|
// (ADR 0289 §1) and removing one of a module placed nowhere. It answers the modules written.
|
|
func (c *Config) writeDeclared(s Sources) ([]string, error) {
|
|
held := map[string][]byte{}
|
|
if err := c.each(DeclaredState, func(key string, raw []byte) error {
|
|
held[key] = raw
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
var written []string
|
|
var problems []error
|
|
placed := map[string]bool{}
|
|
for _, module := range modulesOf(s.Assignments) {
|
|
placed[module] = true
|
|
k, err := c.trunkKept(module)
|
|
if err != nil {
|
|
continue // unresolved, said by the resolution; its key stays as it was
|
|
}
|
|
raw, _ := json.Marshal(k)
|
|
if bytes.Equal(raw, held[module]) {
|
|
continue
|
|
}
|
|
if err := c.Store.Put(DeclaredState, module, k); err != nil {
|
|
problems = append(problems, fmt.Errorf("%s: %w", module, err))
|
|
continue
|
|
}
|
|
written = append(written, module)
|
|
}
|
|
for module := range held {
|
|
if !placed[module] {
|
|
if err := c.Store.Delete(DeclaredState, module); err != nil {
|
|
problems = append(problems, fmt.Errorf("%s: %w", module, err))
|
|
}
|
|
}
|
|
}
|
|
return written, errors.Join(problems...)
|
|
}
|
|
|
|
// mirrorDeclarations makes the sources' declarations the controller's mirror's, for every placed module: the source
|
|
// after a hand-back, and the other side of compare.
|
|
func (c *Config) mirrorDeclarations(s *Sources) {
|
|
s.Declared, s.OnNode, s.Unresolved = map[string]ModuleDeclarations{}, map[string]ModuleDeclarations{}, nil
|
|
for _, module := range modulesOf(s.Assignments) {
|
|
e, err := c.Store.Get(MirrorDeclaredRead, module)
|
|
var d ModuleDeclarations
|
|
if err == nil && e != nil {
|
|
d, err = ParseModuleDeclarations(e.Value)
|
|
} else if err == nil {
|
|
err = errors.New("the mirror holds no key for it")
|
|
}
|
|
if err != nil {
|
|
for _, a := range s.Assignments {
|
|
if a.Module == module {
|
|
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: module,
|
|
Why: "the controller's mirror declared-config: " + err.Error()})
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
s.Declared[module] = d
|
|
}
|
|
}
|
|
|
|
// ---- the predefined list and the pull requests from the indexer ------------------------------------------
|
|
|
|
// indexerList is the indexer's predefined list as the mirror's list holds one: the keys, and the commit.
|
|
func (c *Config) indexerList() (PredefinedList, bool, error) {
|
|
e, err := c.Store.Get(IndexerPredefinedRead, delivery.PredefinedListKey)
|
|
if err != nil || e == nil {
|
|
return PredefinedList{}, false, err
|
|
}
|
|
var kept delivery.PredefinedKept
|
|
if err := json.Unmarshal(e.Value, &kept); err != nil {
|
|
return PredefinedList{}, false, fmt.Errorf("the indexer's predefined list is not one: %w", err)
|
|
}
|
|
list := PredefinedList{Keys: map[string]PredefinedKey{}, Commit: kept.Commit}
|
|
if err := json.Unmarshal(kept.List, &list.Keys); err != nil {
|
|
return PredefinedList{}, false, fmt.Errorf("the indexer's predefined list is not predefined.json: %w", err)
|
|
}
|
|
return list, true, nil
|
|
}
|
|
|
|
// mirrorList is the controller's mirror's list.
|
|
func (c *Config) mirrorList() (PredefinedList, bool, error) {
|
|
e, err := c.Store.Get(PredefinedRead, PredefinedMirrorKey)
|
|
if err != nil || e == nil {
|
|
return PredefinedList{}, false, err
|
|
}
|
|
var list PredefinedList
|
|
if err := json.Unmarshal(e.Value, &list); err != nil || list.Keys == nil {
|
|
return PredefinedList{}, false, fmt.Errorf("the controller's list is not one: %s", e.Value)
|
|
}
|
|
return list, true, nil
|
|
}
|
|
|
|
// candidates is every open pull request's config declarations as the indexer keeps them, by pull key.
|
|
func (c *Config) candidates() (map[string]PullDecl, error) {
|
|
out := map[string]PullDecl{}
|
|
err := c.each(CandidatesRead, func(key string, raw []byte) error {
|
|
var cand delivery.ConfigCandidate
|
|
if err := json.Unmarshal(raw, &cand); err != nil || cand.Pull == "" {
|
|
return fmt.Errorf("the indexer's config candidate %s is not one", key)
|
|
}
|
|
p := PullDecl{Pull: cand.Pull, Head: cand.Head, Declares: map[string]map[string]Declaration{}, At: cand.At}
|
|
for module, keys := range cand.Declares {
|
|
raw, _ := json.Marshal(keys)
|
|
var d map[string]Declaration
|
|
if err := json.Unmarshal(raw, &d); err != nil {
|
|
return fmt.Errorf("the indexer's config candidate %s declares %s unreadably", key, module)
|
|
}
|
|
for k, decl := range d {
|
|
if !decl.HasDefault() {
|
|
decl.Default = nil
|
|
}
|
|
d[k] = decl
|
|
}
|
|
p.Declares[module] = d
|
|
}
|
|
out[pullKey(cand.Pull)] = p
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// takeCandidates is the indexer's config candidates taken in place of pull-declares, after the take-over: each one
|
|
// whose head or declarations differ from what `pulls` holds is declared again, its held values judged again (ADR 0502
|
|
// §6). The caller holds the lock.
|
|
func (c *Config) takeCandidates() []string {
|
|
cands, err := c.candidates()
|
|
if err != nil {
|
|
return []string{"the indexer's config candidates cannot be read: " + err.Error()}
|
|
}
|
|
var s Sources
|
|
if err := c.readPulls(&s); err != nil {
|
|
return []string{err.Error()}
|
|
}
|
|
var said []string
|
|
for _, k := range sortedKeys(cands) {
|
|
p := cands[k]
|
|
if have, ok := s.Pulls[k]; ok && have.Head == p.Head && declaresDigest(have.Declares) == declaresDigest(p.Declares) {
|
|
continue
|
|
}
|
|
a, err := c.declarePull(p.Pull, p.Head, p.Declares)
|
|
if err != nil {
|
|
said = append(said, fmt.Sprintf("%s from the indexer: %v", p.Pull, err))
|
|
continue
|
|
}
|
|
said = append(said, a.Said+" (from the indexer)")
|
|
}
|
|
return said
|
|
}
|
|
|
|
// declaresDigest is what a pull request's declarations hold a value to, in one encoding.
|
|
func declaresDigest(d map[string]map[string]Declaration) string {
|
|
flat := map[string]string{}
|
|
for m, keys := range d {
|
|
for k, decl := range keys {
|
|
flat[m+"."+k] = declDigest(decl)
|
|
}
|
|
}
|
|
raw, _ := json.Marshal(flat)
|
|
return string(raw)
|
|
}
|
|
|
|
// ---- compare ----------------------------------------------------------------------------------------------
|
|
|
|
// Difference is one thing the own sources and the controller's mirrors say differently, and why.
|
|
type Difference struct {
|
|
What string `json:"what"`
|
|
Module string `json:"module,omitempty"`
|
|
Node string `json:"node,omitempty"`
|
|
Key string `json:"key,omitempty"`
|
|
Pull string `json:"pull,omitempty"`
|
|
Own json.RawMessage `json:"own,omitempty"`
|
|
Mirror json.RawMessage `json:"mirror,omitempty"`
|
|
Why string `json:"why"`
|
|
}
|
|
|
|
// The kinds of difference.
|
|
const (
|
|
DiffDeclared = "declared"
|
|
DiffPredefined = "predefined"
|
|
DiffPull = "pull"
|
|
DiffValue = "value"
|
|
)
|
|
|
|
// Comparison is what compare answers.
|
|
type Comparison struct {
|
|
Source string `json:"source"`
|
|
Differences []Difference `json:"differences"`
|
|
Count int `json:"count"`
|
|
Said string `json:"said"`
|
|
}
|
|
|
|
// Compare lists every difference (ADR 0565 §4 and its insight). The caller holds no lock.
|
|
func (c *Config) Compare() (*Comparison, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.compare()
|
|
}
|
|
|
|
// compare is Compare; the caller holds the lock.
|
|
func (c *Config) compare() (*Comparison, error) {
|
|
sw, err := c.source()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
own, err := c.sourcesFrom(SourceInterim)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mesh-config's own sources cannot be read: %w", err)
|
|
}
|
|
mirror := own
|
|
mirror.Declared, mirror.OnNode, mirror.Unresolved = nil, nil, nil
|
|
c.mirrorDeclarations(&mirror)
|
|
var diffs []Difference
|
|
|
|
// Each placed module's declarations: `declared` against the mirror's key for it.
|
|
for _, module := range modulesOf(own.Assignments) {
|
|
var ownRaw []byte
|
|
if e, err := c.Store.Get(DeclaredState, module); err == nil && e != nil {
|
|
var k DeclaredKept
|
|
if json.Unmarshal(e.Value, &k) == nil {
|
|
ownRaw, _ = json.Marshal(normal(k.Config))
|
|
}
|
|
}
|
|
var mirrorRaw []byte
|
|
if d, ok := mirror.Declared[module]; ok {
|
|
mirrorRaw, _ = json.Marshal(normal(d.Config))
|
|
}
|
|
switch {
|
|
case ownRaw == nil && mirrorRaw == nil:
|
|
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Why: "neither side holds its declarations"})
|
|
case ownRaw == nil:
|
|
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Mirror: mirrorRaw,
|
|
Why: "the mirror holds its declarations and declared does not"})
|
|
case mirrorRaw == nil:
|
|
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw,
|
|
Why: "declared holds its declarations and the mirror does not"})
|
|
case !bytes.Equal(ownRaw, mirrorRaw):
|
|
diffs = append(diffs, Difference{What: DiffDeclared, Module: module, Own: ownRaw, Mirror: mirrorRaw,
|
|
Why: "the trunk's manifest declares it otherwise than the mirror"})
|
|
}
|
|
}
|
|
|
|
// The predefined list: the indexer's against the mirror's.
|
|
il, iok, ierr := c.indexerList()
|
|
ml, mok, merr := c.mirrorList()
|
|
switch {
|
|
case ierr != nil || merr != nil:
|
|
diffs = append(diffs, Difference{What: DiffPredefined, Why: "a list cannot be read: " + errors.Join(ierr, merr).Error()})
|
|
case !iok || !mok:
|
|
if iok != mok {
|
|
diffs = append(diffs, Difference{What: DiffPredefined, Why: fmt.Sprintf("the indexer holds a list: %v; the "+
|
|
"mirror holds one: %v", iok, mok)})
|
|
}
|
|
default:
|
|
for _, k := range unionKeys(il.Keys, ml.Keys) {
|
|
a, aok := il.Keys[k]
|
|
b, bok := ml.Keys[k]
|
|
ar, _ := json.Marshal(a)
|
|
br, _ := json.Marshal(b)
|
|
switch {
|
|
case !aok:
|
|
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Mirror: br, Why: "only the mirror's list holds it"})
|
|
case !bok:
|
|
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Why: "only the indexer's list holds it"})
|
|
case !bytes.Equal(ar, br):
|
|
diffs = append(diffs, Difference{What: DiffPredefined, Key: k, Own: ar, Mirror: br,
|
|
Why: "the two lists declare it otherwise"})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Each open pull request: the indexer's candidate against what the controller told by pull-declares.
|
|
cands, cerr := c.candidates()
|
|
if cerr != nil {
|
|
diffs = append(diffs, Difference{What: DiffPull, Why: "the indexer's config candidates cannot be read: " + cerr.Error()})
|
|
}
|
|
for _, k := range unionKeys(cands, own.Pulls) {
|
|
a, aok := cands[k]
|
|
b, bok := own.Pulls[k]
|
|
switch {
|
|
case !aok:
|
|
diffs = append(diffs, Difference{What: DiffPull, Pull: b.Pull, Why: "the controller told its declarations and the indexer holds no candidate"})
|
|
case !bok:
|
|
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the indexer holds a candidate and the controller told nothing"})
|
|
case a.Head != b.Head:
|
|
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: fmt.Sprintf("the indexer's candidate is at %s, "+
|
|
"the controller told %s", short(a.Head), short(b.Head))})
|
|
case declaresDigest(a.Declares) != declaresDigest(b.Declares):
|
|
diffs = append(diffs, Difference{What: DiffPull, Pull: a.Pull, Why: "the two declare its config otherwise"})
|
|
}
|
|
}
|
|
|
|
// Every placed module's value on every node, resolved both ways.
|
|
ownValues, mirrorValues := Resolve(own), Resolve(mirror)
|
|
for _, k := range unionKeys(ownValues, mirrorValues) {
|
|
a, aok := ownValues[k]
|
|
b, bok := mirrorValues[k]
|
|
ar, _ := json.Marshal(a)
|
|
br, _ := json.Marshal(b)
|
|
node, module, key := a.Node, a.Module, a.Key
|
|
if !aok {
|
|
node, module, key = b.Node, b.Module, b.Key
|
|
}
|
|
d := Difference{What: DiffValue, Node: node, Module: module, Key: key}
|
|
switch {
|
|
case !aok:
|
|
d.Mirror, d.Why = br, "only the mirror's declarations resolve it on this node"
|
|
case !bok:
|
|
d.Own, d.Why = ar, "only mesh-config's own declarations (with this node's build) resolve it"
|
|
case !bytes.Equal(ar, br):
|
|
d.Own, d.Mirror, d.Why = ar, br, "it resolves to another value or source"
|
|
default:
|
|
continue
|
|
}
|
|
diffs = append(diffs, d)
|
|
}
|
|
for _, u := range mirror.Unresolved {
|
|
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from the mirror: " + u.Why})
|
|
}
|
|
for _, u := range own.Unresolved {
|
|
diffs = append(diffs, Difference{What: DiffValue, Node: u.Node, Module: u.Module, Why: "not resolved from its own source: " + u.Why})
|
|
}
|
|
sort.SliceStable(diffs, func(i, j int) bool {
|
|
a, b := diffs[i], diffs[j]
|
|
return a.What+a.Module+a.Node+a.Key+a.Pull < b.What+b.Module+b.Node+b.Key+b.Pull
|
|
})
|
|
if diffs == nil {
|
|
diffs = []Difference{}
|
|
}
|
|
cmp := &Comparison{Source: sw.Source, Differences: diffs, Count: len(diffs)}
|
|
if len(diffs) == 0 {
|
|
cmp.Said = "nothing differs: the take-over may be asked"
|
|
} else {
|
|
cmp.Said = fmt.Sprintf("%d difference(s): the take-over is refused while any stands", len(diffs))
|
|
}
|
|
return cmp, nil
|
|
}
|
|
|
|
// normal is declarations in one encoding: a null default as none.
|
|
func normal(d map[string]Declaration) map[string]Declaration {
|
|
out := map[string]Declaration{}
|
|
for k, decl := range d {
|
|
if !decl.HasDefault() {
|
|
decl.Default = nil
|
|
} else {
|
|
decl.Default = compact(decl.Default)
|
|
}
|
|
out[k] = decl
|
|
}
|
|
return out
|
|
}
|
|
|
|
func unionKeys[A, B any](a map[string]A, b map[string]B) []string {
|
|
seen := map[string]bool{}
|
|
for k := range a {
|
|
seen[k] = true
|
|
}
|
|
for k := range b {
|
|
seen[k] = true
|
|
}
|
|
return sortedKeys(seen)
|
|
}
|
|
|
|
// ---- take-over and hand-back ---------------------------------------------------------------------------------
|
|
|
|
// TakeoverAsk is one take-over asked of the operator, `takeover.ask.<id>`: what the ask is composed from again, and
|
|
// where it stands.
|
|
type TakeoverAsk struct {
|
|
ID string `json:"id"`
|
|
From string `json:"from"`
|
|
Why string `json:"why"`
|
|
At time.Time `json:"at"`
|
|
State string `json:"state"`
|
|
Outcome string `json:"outcome,omitempty"`
|
|
Decided time.Time `json:"decided,omitzero"`
|
|
Answered string `json:"answered,omitempty"`
|
|
Recorded bool `json:"recorded,omitempty"`
|
|
}
|
|
|
|
func askKey(id string) string { return "ask." + id }
|
|
|
|
// act is what an answer to the take-over binds.
|
|
func (t TakeoverAsk) act(option string) asks.Act {
|
|
return asks.Act{"verb": Seat + "." + TakeOverVerb, "option": option, "ask": t.ID, "from": t.From,
|
|
"at": t.At.UTC().Format(time.RFC3339Nano)}
|
|
}
|
|
|
|
// ask is the take-over's ask, composed from it alone.
|
|
func (t TakeoverAsk) ask() asks.Ask {
|
|
why, _ := sayable("Why: " + t.Why)
|
|
a := asks.Ask{ID: t.ID, Headline: "Switch config to its own sources",
|
|
Explanation: strings.Join([]string{
|
|
"mesh-config would read the list of predefined keys and each pull request's config from the delivery's " +
|
|
"indexer, no longer from the controller. Nothing any node resolves changes: compare showed no difference.",
|
|
point(why)}, "\n"),
|
|
Who: asks.Operator, Expires: t.At.Add(ProposalLasts),
|
|
OnExpiry: "nothing changes; config keeps reading the controller's records", About: "config.takeover",
|
|
Details: "Read what was compared with mesh-config's compare verb, through the mesh MCP server. hand-back returns " +
|
|
"to the controller's records until the controller's lease is handed away."}
|
|
for _, o := range []struct{ id, label, does string }{
|
|
{OptionApprove, "Approve", "config switches to its own sources at once, if nothing differs then"},
|
|
{OptionDecline, "Decline", "nothing changes"},
|
|
} {
|
|
binds, _ := asks.ActDigest(t.act(o.id))
|
|
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
|
}
|
|
return a
|
|
}
|
|
|
|
// TakeoverAnswer is what take-over and hand-back answer.
|
|
type TakeoverAnswer struct {
|
|
Said string `json:"said"`
|
|
Ask string `json:"ask,omitempty"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
// TakeOver asks the operator's Approve to switch to the own sources, refused while compare shows a difference.
|
|
func (c *Config) TakeOver(call Call) (*TakeoverAnswer, error) {
|
|
if strings.TrimSpace(call.Why) == "" {
|
|
return nil, errors.New("say why the take-over is asked now: it is shown to the operator")
|
|
}
|
|
if call.Caller == "" || call.Caller == Unproven {
|
|
return nil, errors.New("the call names no proven caller")
|
|
}
|
|
if c.Asks == nil {
|
|
return nil, errors.New("the operator cannot be asked from here, so nothing is switched")
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
sw, err := c.source()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sw.Source == SourceIndexer {
|
|
return &TakeoverAnswer{Source: sw.Source, Said: "config reads its own sources already, since " +
|
|
sw.At.UTC().Format(time.RFC3339) + " (" + sw.By + ")"}, nil
|
|
}
|
|
cmp, err := c.compare()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cmp.Count > 0 {
|
|
first := cmp.Differences[0]
|
|
return nil, fmt.Errorf("the take-over is refused: compare shows %d difference(s), the first %s %s%s%s%s: %s. "+
|
|
"Nothing was asked", cmp.Count, first.What, first.Module, first.Node, first.Key, first.Pull, first.Why)
|
|
}
|
|
now := c.now()
|
|
var open []string
|
|
_ = c.each(TakeoverState, func(key string, raw []byte) error {
|
|
var t TakeoverAsk
|
|
if strings.HasPrefix(key, "ask.") && json.Unmarshal(raw, &t) == nil && t.State == ProposalWaiting &&
|
|
now.Before(t.At.Add(ProposalLasts)) {
|
|
open = append(open, t.ID)
|
|
}
|
|
return nil
|
|
})
|
|
if len(open) > 0 {
|
|
return nil, fmt.Errorf("the take-over is asked already, as %s, and waits for the operator's answer", open[0])
|
|
}
|
|
t := TakeoverAsk{ID: "takeover-" + newProposalID(), From: call.Caller, Why: strings.TrimSpace(call.Why), At: now.UTC(),
|
|
State: ProposalWaiting}
|
|
ask := t.ask()
|
|
if err := ask.Check(now); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.Store.Put(TakeoverState, askKey(t.ID), t); err != nil {
|
|
return nil, fmt.Errorf("the take-over could not be kept, so the operator was not asked: %w", err)
|
|
}
|
|
if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
|
t.State, t.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error()
|
|
_ = c.Store.Put(TakeoverState, askKey(t.ID), t)
|
|
return nil, fmt.Errorf("the operator could not be asked: %w", err)
|
|
}
|
|
return &TakeoverAnswer{Ask: t.ID, Source: sw.Source, Said: fmt.Sprintf("the operator is asked %q under "+
|
|
"mesh-config's own name; nothing changes until they approve, and on Approve config switches if nothing differs "+
|
|
"then", ask.Headline)}, nil
|
|
}
|
|
|
|
// decideTakeovers acts on each take-over ask the router has closed. The caller holds the lock.
|
|
func (c *Config) decideTakeovers() []string {
|
|
var said []string
|
|
var waiting []TakeoverAsk
|
|
_ = c.each(TakeoverState, func(key string, raw []byte) error {
|
|
var t TakeoverAsk
|
|
if strings.HasPrefix(key, "ask.") && json.Unmarshal(raw, &t) == nil && t.State == ProposalWaiting {
|
|
waiting = append(waiting, t)
|
|
}
|
|
return nil
|
|
})
|
|
now := c.now()
|
|
for _, t := range waiting {
|
|
keep := func(state, outcome string) string {
|
|
t.State, t.Outcome, t.Decided = state, outcome, now
|
|
_ = c.Store.Put(TakeoverState, askKey(t.ID), t)
|
|
return "take-over " + t.ID + ": " + outcome
|
|
}
|
|
e, err := c.Asks.Record(t.ID)
|
|
if err != nil || e == nil {
|
|
if err == nil && now.After(t.At.Add(ProposalLasts+time.Hour)) {
|
|
said = append(said, keep(ProposalEnded, "the router keeps no record of its ask; nothing was switched"))
|
|
}
|
|
continue
|
|
}
|
|
var rec routerRecord
|
|
if json.Unmarshal(e.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
|
continue
|
|
}
|
|
w := *rec.Warrant
|
|
if w.Asker != Module || w.Ask != t.ID {
|
|
said = append(said, keep(ProposalRefused, "the router's record is for another ask; nothing was switched"))
|
|
continue
|
|
}
|
|
if w.Outcome != asks.OutcomeChosen {
|
|
said = append(said, keep(ProposalEnded, "the ask "+string(w.Outcome)+"; nothing was switched"))
|
|
continue
|
|
}
|
|
option, err := w.For(Module, t.ask())
|
|
if err == nil {
|
|
err = option.Performs(t.act(option.ID))
|
|
}
|
|
if err != nil {
|
|
said = append(said, keep(ProposalRefused, "the answer is refused: "+err.Error()))
|
|
continue
|
|
}
|
|
t.Answered = w.Says()
|
|
if option.ID != OptionApprove {
|
|
said = append(said, keep(ProposalDeclined, "declined by the operator; nothing was switched"))
|
|
continue
|
|
}
|
|
cmp, err := c.compare()
|
|
switch {
|
|
case err != nil:
|
|
said = append(said, keep(ProposalRefused, "approved, and compare could not be read, so nothing was switched: "+err.Error()))
|
|
continue
|
|
case cmp.Count > 0:
|
|
said = append(said, keep(ProposalRefused, fmt.Sprintf("approved, and refused: compare shows %d difference(s) "+
|
|
"now; nothing was switched, ask again once it is empty", cmp.Count)))
|
|
continue
|
|
}
|
|
sw := Switch{Source: SourceIndexer, At: now.UTC(), By: approvedTakeover(w), Why: t.Why, Ask: t.ID}
|
|
if err := c.Store.Put(TakeoverState, SwitchKey, sw); err != nil {
|
|
said = append(said, fmt.Sprintf("take-over %s: approved, and the switch could not be written (%v); read again "+
|
|
"on the next look", t.ID, err))
|
|
continue
|
|
}
|
|
if err := c.Asks.Warranted(t.ID); err == nil {
|
|
t.Recorded = true
|
|
}
|
|
said = append(said, keep(ProposalApproved, "approved by the operator: config reads its own sources from now"))
|
|
}
|
|
return said
|
|
}
|
|
|
|
func approvedTakeover(w asks.Warrant) string {
|
|
if w.By == nil {
|
|
return "the operator"
|
|
}
|
|
return fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
|
|
}
|
|
|
|
// HandBack returns to the controller's records until the controller's lease is handed away.
|
|
func (c *Config) HandBack(call Call) (*TakeoverAnswer, error) {
|
|
if strings.TrimSpace(call.Why) == "" {
|
|
return nil, errors.New("say why config hands back: it is kept with the switch")
|
|
}
|
|
if call.Caller == "" || call.Caller == Unproven {
|
|
return nil, errors.New("the call names no proven caller")
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.leaseAway() {
|
|
return nil, errors.New("hand-back is gone: the controller handed its lease away, and its records end with it " +
|
|
"(novox/hq ADR 0565 §4)")
|
|
}
|
|
sw, err := c.source()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sw.Source == SourceController {
|
|
return &TakeoverAnswer{Source: sw.Source, Said: "config reads the controller's records already"}, nil
|
|
}
|
|
now := c.now()
|
|
back := Switch{Source: SourceController, At: now.UTC(), By: call.Caller, Why: strings.TrimSpace(call.Why)}
|
|
if err := c.Store.Put(TakeoverState, SwitchKey, back); err != nil {
|
|
return nil, fmt.Errorf("the switch could not be written: %w", err)
|
|
}
|
|
answer := &TakeoverAnswer{Source: back.Source, Said: "config reads the controller's records again: its mirrors " +
|
|
"declared-config and predefined, and pull-declares"}
|
|
if err := c.Store.HandAct("handed config back to the controller's records", back.Why, "config-hand-back"); err != nil {
|
|
answer.Said += "; the hand-act log does not hold it: " + err.Error()
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// Declared is the read of `declared`: every module's key, or one whole.
|
|
func (c *Config) Declared(module string) (any, error) {
|
|
if module != "" {
|
|
e, err := c.Store.Get(DeclaredState, module)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e == nil {
|
|
return nil, fmt.Errorf("declared holds no %s: it is placed nowhere, or its manifest cannot be read", module)
|
|
}
|
|
return json.RawMessage(e.Value), nil
|
|
}
|
|
rows := map[string]any{}
|
|
err := c.each(DeclaredState, func(key string, raw []byte) error {
|
|
var k DeclaredKept
|
|
if json.Unmarshal(raw, &k) == nil {
|
|
rows[key] = map[string]any{"manifest": k.Manifest, "commit": k.Commit, "keys": len(k.Config)}
|
|
}
|
|
return nil
|
|
})
|
|
sw, _ := c.source()
|
|
return map[string]any{"declared": rows, "count": len(rows), "source": sw}, err
|
|
}
|
|
|
|
// Resync reads every source again and writes what differs: the published list, the resolved values, `declared`, and
|
|
// after the take-over the indexer's config candidates (ADR 0565 §15).
|
|
func (c *Config) Resync() (any, error) {
|
|
r, err := c.Reconcile()
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := map[string]any{"resolution": r}
|
|
if sw, err := c.source(); err == nil && sw.Source == SourceIndexer {
|
|
out["candidates"] = c.takeCandidates()
|
|
}
|
|
return out, nil
|
|
}
|