Files
mesh-catalog/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go
T
jschoubben 6bee0b6789 Never set a provision provided without its provider's outcome, read from the entry's state and why, and pin mesh-sdk 692dbf9 (issue 1954)
A provider that writes no outcome leaves its keys asked (ADR 0570 clause 3), so a provider that fails is never hidden.
2026-10-29 22:43:16 +01:00

637 lines
20 KiB
Go

package main
// The seat's holder (novox/hq ADR 0570 clauses 1 to 5, 7; issue 1954 H1-H3): mesh-provisioner composes every placed
// module's provisioning plan and every provision from bus state, on change events and never on a timer, and keeps them
// in its states `plans` (`<node>.<module>`) and `provisions` (`<node>.<consumer>.<provision>[.<local>]`), which it alone
// writes.
//
// - **Composed per node.** A change of a node's placement or of one of its wants composes that node; a change of the
// indexer's module composes the nodes the module is placed on. Each node is composed twice: at the builds its wants
// name (its plans, what its client applies) and at the newest build of each placed module, so a build that adds a
// requirement has its key before its want (clause 2). A provision is needed while either needs it (clause 5).
// - **A key's state comes from its provider's outcome alone** (clause 3, 7): the provider's own entry for that
// consumer, `<provider>_bound` under the same key: present is provided (the vault's generation carried into the
// reference, clause 6), refused is failed with why, waits is waits with what. The holder never sets provided by
// itself: a key of a provider that writes no outcome stays asked.
// - **Retiring is asked** (clause 5): a key no longer needed is `retiring`, one approve ask under mesh-provisioner's
// name says what goes from which provider on which node; the warrant goes into the key and the provider acts on it;
// a decline is kept and not asked again while nothing changes; a key needed again returns to its outcome's state.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// The states it keeps, beside Registry, Needs, Applied and Collect.
const (
ProvisionsState = provisions.ProvisionsState
PlansState = provisions.PlansState
)
// The reads it composes from.
const (
PlacementRead = delivery.PlacementRead
WantsRead = delivery.AssignerModule + ".wants"
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
BuildsRead = delivery.BuilderModule + ".builds"
HaveRead = "node-engine.have"
ResolvedRead = "mesh-config.resolved"
)
// Providers are the providing modules that keep an entry per consumer, `<module>_bound`, read as `<module>.bound`: the
// outcome of each provision they make. A provider not named here keeps none.
var Providers = []string{"gitea", "keycloak", "postgres", "route-adapter", "mesh-vault"}
// boundRead is a provider's entries as this module reads them.
func boundRead(module string) string { return module + ".bound" }
// Composer is each module's provisioning plan on a node, composed from the bus: at the builds keptAt names
// (module → fingerprint), the newest build of every other placed module.
type Composer func(ctx context.Context, node string, keptAt map[string]string) (map[string]catalogue.Plan, error)
// Asker is the operator's channel as this module asks on it: publish an ask under its own name, and read the router's
// record of it.
type Asker interface {
Publish(subject string, body any, id string) error
Record(id string) ([]byte, bool, error)
}
// Holder is the seat's holder.
type Holder struct {
Store Store
Compose Composer
Asks Asker
Now func() time.Time
Logf func(format string, args ...any)
mu sync.Mutex
}
// Composed is what one node's composition did.
type Composed struct {
Node string `json:"node"`
Plans []string `json:"plans-written,omitempty"`
Gone []string `json:"plans-removed,omitempty"`
Changed []string `json:"provisions-changed,omitempty"`
Asked []string `json:"retiring-asked,omitempty"`
Problems []string `json:"problems,omitempty"`
}
// ComposeNode composes one node: its plans and its provisions.
func (h *Holder) ComposeNode(ctx context.Context, node string) (Composed, error) {
h.mu.Lock()
defer h.mu.Unlock()
out := Composed{Node: node}
wanted, err := h.wantedOn(node)
if err != nil {
return out, err
}
atWants, err := h.Compose(ctx, node, wanted)
if err != nil {
return out, fmt.Errorf("the plans of %s cannot be composed: %w", node, err)
}
newest, err := h.Compose(ctx, node, nil)
if err != nil {
return out, fmt.Errorf("the plans of %s at the newest builds cannot be composed: %w", node, err)
}
if err := h.writePlans(node, atWants, &out); err != nil {
out.Problems = append(out.Problems, err.Error())
}
needed := map[string]provisions.Provision{}
for _, plans := range []map[string]catalogue.Plan{atWants, newest} {
for module, plan := range plans {
for _, p := range plan.Provisions {
pv := provisions.Provision{Provision: p.Provision, Local: p.Local,
Consumer: provisions.End{Module: module, Node: node},
Provider: provisions.End{Module: p.ProviderModule, Node: p.Provider}}
key, err := pv.Key()
if err != nil {
out.Problems = append(out.Problems, err.Error())
continue
}
if _, seen := needed[key]; !seen {
needed[key] = pv
}
}
}
}
h.judgeNode(node, needed, &out)
return out, nil
}
// wantedOn is the fingerprint each want on a node names, by module.
func (h *Holder) wantedOn(node string) (map[string]string, error) {
out := map[string]string{}
keys, err := h.Store.Keys(WantsRead)
if err != nil {
return nil, fmt.Errorf("the wants cannot be read: %w", err)
}
for _, k := range keys {
n, module, err := delivery.ParseWantKey(k)
if err != nil || n != node {
continue
}
e, err := h.Store.Get(WantsRead, k)
if err != nil || e == nil {
continue
}
if w, err := delivery.DecodeWant(e.Value); err == nil && !w.Removed && w.FP != "" {
out[module] = w.FP
}
}
return out, nil
}
// writePlans writes each plan that differs and removes the node's plans of modules no longer composed there.
func (h *Holder) writePlans(node string, plans map[string]catalogue.Plan, out *Composed) error {
var problems []error
for _, module := range sortedKeys(plans) {
key, err := provisions.PlanKey(node, module)
if err != nil {
problems = append(problems, err)
continue
}
raw, err := provisions.EncodePlan(provisions.Plan{Node: node, Module: module, Plan: plans[module]})
if err != nil {
problems = append(problems, err)
continue
}
if e, err := h.Store.Get(PlansState, key); err == nil && e != nil && bytes.Equal(e.Value, raw) {
continue
}
if err := h.Store.Put(PlansState, key, json.RawMessage(raw)); err != nil {
problems = append(problems, fmt.Errorf("the plan %s: %w", key, err))
continue
}
out.Plans = append(out.Plans, key)
}
keys, err := h.Store.Keys(PlansState)
if err != nil {
return errors.Join(append(problems, err)...)
}
for _, k := range keys {
n, module, ok := strings.Cut(k, ".")
if !ok || n != node {
continue
}
if _, still := plans[module]; still {
continue
}
if err := h.Store.Delete(PlansState, k); err != nil {
problems = append(problems, err)
continue
}
out.Gone = append(out.Gone, k)
}
return errors.Join(problems...)
}
// judgeNode brings the node's provision keys to what is needed and what each provider said.
func (h *Holder) judgeNode(node string, needed map[string]provisions.Provision, out *Composed) {
held := h.held(node)
for _, key := range unionKeys(needed, held) {
want, isNeeded := needed[key]
have, isHeld := held[key]
var next provisions.Provision
switch {
case isNeeded:
next = want
if isHeld {
next.Reference = have.Reference
}
h.outcome(key, &next)
case have.State == provisions.Retiring:
next = have
if next.Ask == "" && next.Warrant == nil {
if err := h.ask(&next, key); err != nil {
out.Problems = append(out.Problems, err.Error())
} else {
out.Asked = append(out.Asked, key)
}
}
if gone, why := h.retired(key, have); gone {
if err := h.Store.Delete(ProvisionsState, key); err != nil {
out.Problems = append(out.Problems, err.Error())
} else {
out.Changed = append(out.Changed, key+" (retired: "+why+")")
}
continue
}
default:
next = have
next.State, next.Why = provisions.Retiring, fmt.Sprintf("no build wanted or placed on %s requires %s of %s on %s "+
"any more", node, have.Provision, have.Provider.Module, have.Provider.Node)
next.Warrant = nil
if err := h.ask(&next, key); err != nil {
out.Problems = append(out.Problems, err.Error())
} else {
out.Asked = append(out.Asked, key)
}
}
if isHeld && same(have, next) {
continue
}
next.At = h.Now().UTC()
raw, err := provisions.Encode(next)
if err != nil {
out.Problems = append(out.Problems, err.Error())
continue
}
if err := h.Store.Put(ProvisionsState, key, json.RawMessage(raw)); err != nil {
out.Problems = append(out.Problems, fmt.Sprintf("%s: %v", key, err))
continue
}
out.Changed = append(out.Changed, key+" "+next.State)
}
}
// held is the node's provision keys as kept.
func (h *Holder) held(node string) map[string]provisions.Provision {
out := map[string]provisions.Provision{}
keys, err := h.Store.Keys(ProvisionsState)
if err != nil {
return out
}
for _, k := range keys {
if !strings.HasPrefix(k, node+".") {
continue
}
e, err := h.Store.Get(ProvisionsState, k)
if err != nil || e == nil {
continue
}
if p, err := provisions.Decode(e.Value); err == nil {
out[k] = p
}
}
return out
}
// same says two provisions say the same, their time aside.
func same(a, b provisions.Provision) bool {
a.At, b.At, a.V, b.V = time.Time{}, time.Time{}, 0, 0
ra, _ := json.Marshal(a)
rb, _ := json.Marshal(b)
return bytes.Equal(ra, rb)
}
// entry is a provider's outcome for one consumer, as its entry says it: the provider helper's Entry, read for what
// the holder needs and nothing more.
type entry struct {
// State and Why are the provider's outcome (provider.Entry, ADR 0570 clause 3): provided, failed or waits.
State string `json:"state,omitempty"`
Why string `json:"why,omitempty"`
Refused string `json:"refused,omitempty"`
Waits string `json:"waits,omitempty"`
VaultName string `json:"vault-name,omitempty"`
Generation int `json:"generation,omitempty"`
}
// outcome sets a needed provision's state from its provider's entry: provided, failed or waits; asked while it has
// none. The holder never sets provided by itself (clause 3): a provider whose entries it does not read keeps the key
// asked, said so.
func (h *Holder) outcome(key string, p *provisions.Provision) {
p.Ask, p.Warrant = "", nil
if !keepsEntries(p.Provider.Module) {
p.State, p.Why = provisions.Asked, p.Provider.Module+" writes no outcome yet: provided once it says it in its own entry"
return
}
e, err := h.Store.Get(boundRead(p.Provider.Module), key)
if err != nil {
p.State, p.Why = provisions.Asked, "the provider's entries cannot be read: "+err.Error()
return
}
if e == nil {
p.State, p.Why = provisions.Asked, ""
return
}
var en entry
if err := json.Unmarshal(e.Value, &en); err != nil {
p.State, p.Why = provisions.Asked, "the provider's entry is not readable"
return
}
switch {
case en.State == provisions.Failed:
p.State, p.Why = provisions.Failed, orSaid(en.Why, en.Refused, "the provider said it failed")
case en.State == provisions.Waits:
p.State, p.Why = provisions.Waits, orSaid(en.Why, en.Waits, "the provider waits for a person")
case en.State == "" && en.Refused != "":
p.State, p.Why = provisions.Failed, en.Refused
case en.State == "" && en.Waits != "":
p.State, p.Why = provisions.Waits, en.Waits
case en.State != "" && en.State != provisions.Provided:
p.State, p.Why = provisions.Asked, "the provider's entry says "+en.State+", which no state of a provision is"
return
default:
p.State, p.Why = provisions.Provided, ""
if en.VaultName != "" || en.Generation > 0 {
p.Reference = &provisions.Reference{ID: en.VaultName, Generation: en.Generation}
}
}
}
func keepsEntries(module string) bool {
for _, m := range Providers {
if m == module {
return true
}
}
return false
}
// retired says a retiring key the operator approved is retired: its provider's entry is gone.
func (h *Holder) retired(key string, p provisions.Provision) (bool, string) {
if p.Warrant == nil || p.Warrant.Option != OptionApprove {
return false, ""
}
if !keepsEntries(p.Provider.Module) {
return false, ""
}
e, err := h.Store.Get(boundRead(p.Provider.Module), key)
if err != nil || e != nil {
return false, ""
}
return true, "its provider removed its credential on the operator's warrant"
}
// ---- the retiring ask ----------------------------------------------------------------------------------------------
// The options a retiring ask offers.
const (
OptionApprove = "approve"
OptionDecline = "decline"
)
// askLasts is how long a retiring ask stays open.
const askLasts = asks.ApproveLasts - 10*time.Minute
// retiringAsk is the ask of one retiring provision, composed from the key alone.
func retiringAsk(key string, p provisions.Provision, at time.Time, id string) asks.Ask {
what := fmt.Sprintf("%s's access to %s on %s, for %s on %s", p.Consumer.Module, p.Provision, p.Provider.Node,
p.Consumer.Module, p.Consumer.Node)
a := asks.Ask{ID: id, Headline: "Remove " + p.Consumer.Module + "'s " + p.Provision + " access?",
Explanation: fmt.Sprintf("Nothing wanted or placed on %s needs %s any more. On Approve, %s removes %s: what it owns "+
"is handed to the resource's owner and its credential goes; the resource and its data stay.", p.Consumer.Node,
p.Provision, p.Provider.Module, what),
Who: asks.Operator, Expires: at.Add(askLasts), OnExpiry: "nothing is removed; it is asked again when it changes",
About: "provision." + key, Details: "The provision " + key + " of mesh-provisioner's state provisions."}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Remove", "the provider removes the credential; the resource and its data stay"},
{OptionDecline, "Keep", "nothing is removed, and it is not asked again while nothing changes"},
} {
binds, _ := asks.ActDigest(retireAct(key, id, 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
}
func retireAct(key, id, option string) asks.Act {
return asks.Act{"verb": "mesh-provisioner.retire", "provision": key, "ask": id, "option": option}
}
// ask raises the retiring ask of a key, under mesh-provisioner's own name; one already raised stands.
func (h *Holder) ask(p *provisions.Provision, key string) error {
if p.Ask != "" {
return nil
}
if h.Asks == nil {
return errors.New("the operator cannot be asked from here, so " + key + " is retiring and nothing is removed")
}
now := h.Now().UTC()
id := "retire-" + strings.ReplaceAll(key, ".", "-") + "-" + fmt.Sprint(now.Unix())
a := retiringAsk(key, *p, now, id)
if err := a.Check(now); err != nil {
return err
}
if err := h.Asks.Publish(asks.AskSubject(Module), a, id); err != nil {
return fmt.Errorf("the retiring ask of %s could not be published: %w", key, err)
}
p.Ask = id
return nil
}
// Decide reads the router's record of each retiring key's ask and writes the warrant into the key (clause 5): the
// provider acts on an Approve; a Decline is kept, and not asked again while nothing changes.
func (h *Holder) Decide() []string {
h.mu.Lock()
defer h.mu.Unlock()
if h.Asks == nil {
return nil
}
keys, err := h.Store.Keys(ProvisionsState)
if err != nil {
return []string{"the provisions cannot be read: " + err.Error()}
}
var said []string
for _, k := range keys {
e, err := h.Store.Get(ProvisionsState, k)
if err != nil || e == nil {
continue
}
p, err := provisions.Decode(e.Value)
if err != nil || p.State != provisions.Retiring || p.Ask == "" || p.Warrant != nil {
continue
}
raw, found, err := h.Asks.Record(p.Ask)
if err != nil || !found {
continue
}
var rec struct {
State string `json:"state"`
Ask *asks.Ask `json:"ask"`
Warrant *asks.Warrant `json:"warrant"`
}
if json.Unmarshal(raw, &rec) != nil || rec.State == "open" || rec.Warrant == nil || rec.Ask == nil {
continue
}
w := *rec.Warrant
if w.Asker != Module || w.Ask != p.Ask {
continue
}
option := ""
if w.Outcome == asks.OutcomeChosen {
o, err := w.For(Module, *rec.Ask)
if err == nil {
err = o.Performs(retireAct(k, p.Ask, o.ID))
}
if err != nil {
said = append(said, k+": the answer is refused: "+err.Error())
continue
}
option = o.ID
} else {
// Ended with no choice: asked again at the next change.
p.Ask = ""
h.put(k, p)
said = append(said, k+": the ask "+string(w.Outcome)+"; asked again when it changes")
continue
}
by := "the operator"
if w.By != nil {
by = fmt.Sprintf("the %s, as %s identity %s", w.By.Who, w.By.Kind, w.By.Identity)
}
p.Warrant = &provisions.Warrant{Ask: p.Ask, Option: option, By: by, At: w.At.UTC()}
h.put(k, p)
said = append(said, k+": "+option+" by "+by)
}
return said
}
func (h *Holder) put(key string, p provisions.Provision) {
p.At = h.Now().UTC()
if raw, err := provisions.Encode(p); err == nil {
if err := h.Store.Put(ProvisionsState, key, json.RawMessage(raw)); err != nil {
h.Logf("[%s] %s is not written: %v", Module, key, err)
}
}
}
// ---- what a change touches --------------------------------------------------------------------------------------
// NodesOfPlacement is the node a placement key names.
func NodesOfPlacement(key string) []string {
if n, err := delivery.ParsePlacementKey(key); err == nil {
return []string{n}
}
return nil
}
// NodesOfWant is the node a want key names.
func NodesOfWant(key string) []string {
if n, _, err := delivery.ParseWantKey(key); err == nil {
return []string{n}
}
return nil
}
// NodesOfModule is every node a module is placed on, and every node whose plans name it as a provider: what a change
// of the module's manifest touches.
func (h *Holder) NodesOfModule(module string) []string {
nodes := map[string]bool{}
if keys, err := h.Store.Keys(PlacementRead); err == nil {
for _, k := range keys {
n, err := delivery.ParsePlacementKey(k)
if err != nil {
continue
}
e, err := h.Store.Get(PlacementRead, k)
if err != nil || e == nil {
continue
}
if p, err := delivery.DecodePlacement(e.Value); err == nil {
for _, m := range p.Modules {
if m.Module == module {
nodes[n] = true
}
}
}
}
}
if keys, err := h.Store.Keys(ProvisionsState); err == nil {
for _, k := range keys {
if e, err := h.Store.Get(ProvisionsState, k); err == nil && e != nil {
if p, err := provisions.Decode(e.Value); err == nil && p.Provider.Module == module {
nodes[p.Consumer.Node] = true
}
}
}
}
return sortedKeys(nodes)
}
// NodeOfEntry is the consumer's node a provider's entry key names.
func NodeOfEntry(key string) []string {
if n, _, _, _, err := provisions.ParseKey(key); err == nil {
return []string{n}
}
return nil
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
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)
}
// Rejudge sets one key's state again from its provider's outcome: what a change of a provider's entry touches. A
// retiring key whose provider removed its entry on the operator's warrant goes.
func (h *Holder) Rejudge(key string) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
e, err := h.Store.Get(ProvisionsState, key)
if err != nil || e == nil {
return "", err
}
have, err := provisions.Decode(e.Value)
if err != nil {
return "", err
}
if have.State == provisions.Retiring {
if gone, why := h.retired(key, have); gone {
return key + " retired: " + why, h.Store.Delete(ProvisionsState, key)
}
return "", nil
}
next := have
h.outcome(key, &next)
if same(have, next) {
return "", nil
}
h.put(key, next)
return key + " " + next.State, nil
}
// RetireNeeds deletes every key of the retired state `needs`, which the clients no longer write (clause 1): run once at
// the start.
func (h *Holder) RetireNeeds() (int, error) {
keys, err := h.Store.Keys(Needs)
if err != nil {
return 0, err
}
n := 0
for _, k := range keys {
if err := h.Store.Delete(Needs, k); err != nil {
return n, err
}
n++
}
return n, nil
}
// orSaid is the first of the words that says something.
func orSaid(words ...string) string {
for _, w := range words {
if strings.TrimSpace(w) != "" {
return w
}
}
return ""
}