package main // A trusted setting proposed through a verb and set only on the operator's warrant (novox/hq ADR 0277). // // mesh-controller settings propose [--node ] [--replace] // mesh-controller settings propose --clear [--node ] // mesh-controller settings proposals [] // // Whoever the bus admits may PROPOSE a settings layer — the keys issue 339 made the terminal's (`places`, // `accesses`, what a provider serves, what a trusted file asks for) among them. A proposal changes nothing: it is // kept in the controller's own asks (broker.AskedBucket, written by the controller alone) and asked of the // operator through the operator channel as an ask whose two answers, Approve and Decline, are both at the level // approve, so only a channel that proves who answered (Telegram, today) carries either. The serving controller // acts on the warrant as on any other of its asks (asker.Decided): once, for the ask it holds, the option it // offered, and only when the act about to be performed — the module, the machine, the digest of the exact values, // the layer they replace, whether a removal is meant — is the one the option bound when the operator was shown // it. Then it sets the layer as `settings set` at the terminal does, with the same judgement, keeps who approved // it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a // person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its // own: nothing consumes one, and the installer's first user list in the node-engine's repository names every // controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a // separate act, as it is for a layer set at the terminal. // // **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed // key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the // router's content rule, outward.Check) is shown with that part replaced by ‹address›, ‹path› or ‹withheld›, the // ask says so, and the whole is read with `settings proposals ` — whose fingerprint must be the one on the // phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left // waiting for an answer that cannot come. import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "regexp" "sort" "strconv" "strings" "time" "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/asks" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/outward" ) // settingsProposal is one proposed change to a module's settings layer, as the controller's ask keeps it // (asked.Proposal). Every field the ask is composed from is here, so the serving controller composes the same ask // the proposer did, and the warrant's digest holds to it. type settingsProposal struct { Module string `json:"module"` // Node is the machine, or empty for the whole mesh. Node string `json:"node,omitempty"` // Values is the layer proposed, whole; Clear says the layer is removed instead. Values map[string]any `json:"values,omitempty"` Clear bool `json:"clear,omitempty"` // Replace says the keys the layer had and Values do not name are meant to go (novox/hq ADR 0217). Replace bool `json:"replace,omitempty"` // Before is the layer as it stood when the proposal was made, and HadLayer whether there was one: what the // operator was shown the change against, and what must still stand when the warrant is acted on. Before map[string]any `json:"before,omitempty"` HadLayer bool `json:"had-layer"` // From is who proposed it, as the bus named the caller; At is when. From string `json:"from"` At time.Time `json:"at"` // Digest is the digest of Values (layerDigest), BeforeDigest of Before. Digest string `json:"digest"` BeforeDigest string `json:"before-digest"` } // proposalVerb is the verb a proposal's answers bind: the controller's own settings, performed by itself. const proposalVerb = askerName + ".settings" // The two answers, both at the level approve. const ( answerApprove = "approve" answerDecline = "decline" ) // layerDigest is the digest a proposal binds: SHA-256 over the layer's canonical JSON (Go sorts a map's keys), an // absent layer and an empty one alike. func layerDigest(values map[string]any) string { if values == nil { values = map[string]any{} } raw, _ := json.Marshal(values) sum := sha256.Sum256(raw) return "sha256:" + hex.EncodeToString(sum[:]) } // fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four, so no // word of it is long enough for the router to take for a secret. func fingerprint(digest string) string { hexed := strings.TrimPrefix(digest, "sha256:") if len(hexed) < 24 { return hexed } var groups []string for i := 0; i < 24; i += 4 { groups = append(groups, hexed[i:i+4]) } return strings.Join(groups, " ") } // where is the layer in words: "shanks" or "the whole mesh". func (p settingsProposal) where() string { if p.Node == "" { return "the whole mesh" } return p.Node } // about is what the ask is about, a key without spaces: the module's layer on the machine, or on the mesh. func (p settingsProposal) about() string { if p.Node == "" { return "settings." + p.Module + ".mesh" } return "settings." + p.Module + "." + p.Node } // actions are the proposal's two answers as the controller keeps them (asked.Actions): each binds the exact act // through boundAct — the verb, the machine, the level and every argument, the values' digest among them. func (p settingsProposal) actions(id string) []conditions.Action { args := func(answer string) map[string]string { return map[string]string{"proposal": id, "answer": answer, "module": p.Module, "node": p.Node, "values": p.Digest, "before": p.BeforeDigest, "had-layer": strconv.FormatBool(p.HadLayer), "replace": strconv.FormatBool(p.Replace), "clear": strconv.FormatBool(p.Clear), "from": p.From, "at": p.At.UTC().Format(time.RFC3339Nano)} } return []conditions.Action{ {Label: "Approve", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerApprove)}, {Label: "Decline", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerDecline)}, } } // ask is the proposal's ask, composed from it alone, as the router is sent it: the headline, the explanation // with the change shown under the content rule, and the two options with their bound acts. func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[string]int) { verb := "Set" if p.Clear { verb = "Clear" } headline := fmt.Sprintf("%s %s on %s?", verb, p.Module, p.where()) if len([]rune(headline)) > asks.HeadlineLength { headline = fmt.Sprintf("%s settings on %s?", verb, p.where()) } if len([]rune(headline)) > asks.HeadlineLength { headline = verb + " settings?" } shown, whole := p.change(machines) var b strings.Builder if p.Clear { fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where()) } else { fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where()) } fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan")) switch { case p.Clear && p.HadLayer: b.WriteString("The layer it removes:\n\n") case p.Clear: b.WriteString("There is no layer to remove; approving changes nothing.") case !p.HadLayer: b.WriteString("There is no layer yet; this is the whole of it:\n\n") default: b.WriteString("The layer is replaced whole; what changes against it:\n\n") } b.WriteString(shown) fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest)) if !whole { b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " + "this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " + "mesh-cli settings proposals " + id + ".") } if p.Clear { b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.") } else { b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.") } q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator, Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes", About: p.about()} options := map[string]int{} for i, act := range p.actions(id) { binds, _ := asks.ActDigest(boundAct(act)) oid := optionID(act.Label) options[oid] = i does := "the settings are set; nothing is pushed yet" if p.Clear { does = "the layer is removed; nothing is pushed yet" } if act.Arguments["answer"] == answerDecline { does = "nothing changes; the proposal is discarded" } q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: does, Level: asks.Level(act.Level), Binds: binds}) } return q, options } // shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`. const shownMost = 1400 // change is what changes, line by line, each value shown under the content rule, and whether every value was // shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the // count of keys unchanged. func (p settingsProposal) change(machines []string) (string, bool) { whole := true say := func(v any) string { s, w := sayableValue(v, machines) whole = whole && w return s } var lines []string if p.Clear { was := leaves(p.Before, "") for _, k := range layerKeys(was) { lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k]))) } } else { added, changed, removed := settingsChange(p.Before, p.Values) was, now := leaves(p.Before, ""), leaves(p.Values, "") for _, k := range added { lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k]))) } for _, k := range changed { lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))) } for _, k := range removed { lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))) } unchanged := len(now) - len(added) - len(changed) switch { case len(lines) == 0 && unchanged > 0: lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged)) case unchanged > 0: lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged)) } } out := strings.Join(lines, "\n") if len(out) > shownMost { cut := shownMost for cut > 0 && out[cut] != '\n' { cut-- } out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut) whole = false } return out, whole } func layerKeys(m map[string]any) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } // The shapes a value is shown without, in place: an address with what follows it up to a separator, and a // path. Replaced in place rather than word by word, so "recalbox=smb://host/share@/mnt/recalbox" is shown as // "recalbox=‹address›@‹path›" and keeps its shape. var ( shownURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s@"',;)\]}]*`) shownIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`) shownEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) shownPath = regexp.MustCompile(`(^|[\s=@,;:"'(\[{])((?:~|\.{1,2})?/[^\s"',;:)\]}]*)`) ) // Markers for what is not shown. const ( markAddress = "‹address›" markPath = "‹path›" markWithheld = "‹withheld›" ) // sayableValue is a value as the phone is shown it, and whether it was shown whole. A string is shown bare; any // other value as JSON. func sayableValue(v any, machines []string) (string, bool) { text, ok := v.(string) if !ok { raw, err := json.Marshal(v) if err != nil { return markWithheld, false } text = string(raw) } if text == "" { return `""`, true } out := sayable(text, machines) return out, out == text } // sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make // pass is withheld whole. func sayable(text string, machines []string) string { if _, ok := outward.Check(text, machines...); ok { return text } out := shownURL.ReplaceAllString(text, markAddress) out = shownEmail.ReplaceAllString(out, markAddress) out = shownIPv4.ReplaceAllString(out, markAddress) out = shownPath.ReplaceAllString(out, "${1}"+markPath) // Then word by word, as the router reads them: a host name, a path the shapes above missed, a secret's shape. words := strings.FieldsFunc(out, func(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || strings.ContainsRune("\"'`()[]{}<>,;|", r) }) for _, w := range words { if refusal, ok := outward.Check(w, machines...); !ok { mark := markWithheld switch refusal.Class { case "address": mark = markAddress case "path": mark = markPath } out = strings.ReplaceAll(out, w, mark) } } if _, ok := outward.Check(out, machines...); ok { return out } out = strings.ReplaceAll(outward.Scrub(out, markWithheld, machines...), "(withheld)", markWithheld) if _, ok := outward.Check(out, machines...); ok { return out } return markWithheld } // ---- proposing --------------------------------------------------------------------------------------- // proposer is the propose command's reaches, given so a test needs no store and no bus. type proposer struct { // layer reads a module's layer as it stands. layer func(ctx context.Context, node, module string) (map[string]any, bool, error) // judge judges the layer as SetSettings would, keeping nothing. judge func(ctx context.Context, node, module string, values map[string]any) error // machines are the mesh's machine names: allowed in the ask's words. machines func(ctx context.Context) ([]string, error) store askedStore publish func(ctx context.Context, subject string, body []byte, id string) error // routerHere and grantHeld are the asker's own judgements of whether an ask can be carried; nil is yes. routerHere func(ctx context.Context) (bool, error) grantHeld func(ctx context.Context) (bool, string, error) // routerRecord reads the router's record of an ask: its state, or "" for none. routerRecord func(ctx context.Context, id string) (string, error) now func() time.Time caller string // waitFor and waitEvery bound how long propose waits for the router's word on the new ask. waitFor time.Duration waitEvery time.Duration } // proposeInput is what is proposed. type proposeInput struct { module string node string values map[string]any clear bool replace bool } // atTheTerminalInstead names the other way, said whenever a proposal is refused for want of a channel. func atTheTerminalInstead(in proposeInput) string { if in.clear { return "the operator may clear it at the controller's terminal instead: mesh-cli settings clear " + in.module + nodeFlag(in.node) } return "the operator may set it at the controller's terminal instead: mesh-cli settings set " + in.module + " '{…}'" + nodeFlag(in.node) } // propose keeps a proposal in the controller's asks and asks the operator; it answers the words said to the // caller. Nothing is set here. func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) { refuse := func(format string, args ...any) (string, error) { return "", fmt.Errorf(format+". Nothing was proposed", args...) } if in.module == "" { return refuse("a proposal names a module") } if !in.clear && in.values == nil { return refuse("a proposal gives the values, or says --clear") } now := pr.now() before, had, err := pr.layer(ctx, in.node, in.module) if err != nil { return "", err } p := settingsProposal{Module: in.module, Node: in.node, Values: in.values, Clear: in.clear, Replace: in.replace, Before: before, HadLayer: had, From: pr.caller, At: now, BeforeDigest: layerDigest(before)} if in.clear { // A clear binds the layer it removes: its digest is the fingerprint the operator reads. p.Values, p.Digest = nil, layerDigest(before) } else { // Judged now, as SetSettings judges, so the operator is never asked about a layer the mesh would refuse — // and judged again when the warrant is acted on. if err := pr.judge(ctx, in.node, in.module, in.values); err != nil { return refuse("%v", err) } _, _, removed := settingsChange(before, in.values) if len(removed) > 0 && !in.replace { return refuse("%s on %s: this layer would no longer set %s. A layer is replaced whole; read it with "+ "`settings show %s%s` and include what should stay, or add --replace if the removal is meant "+ "(novox/hq ADR 0217)", in.module, p.where(), strings.Join(removed, ", "), in.module, nodeFlag(in.node)) } p.Digest = layerDigest(in.values) } var machines []string if pr.machines != nil { if machines, err = pr.machines(ctx); err != nil { return "", err } } id := newProposalID() q, options := p.ask(id, machines) if err := q.Check(now); err != nil { return refuse("%v", err) } words := []string{q.Headline, q.Explanation, q.OnExpiry} for _, o := range q.Options { words = append(words, o.Label, o.Does) } if refusal, ok := outward.Check(strings.Join(words, "\n"), machines...); !ok { return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+ "shown without it; %s", refusal, atTheTerminalInstead(in)) } // Fail closed, before anything is kept: no router, no grant, no channel means no ask. if pr.routerHere != nil { here, err := pr.routerHere(ctx) if err != nil { return "", err } if !here { return refuse("no router takes the controller's asks (no module holding the operator channel is assigned), "+ "so the operator cannot be asked; %s", atTheTerminalInstead(in)) } } if pr.grantHeld != nil { held, why, err := pr.grantHeld(ctx) if err != nil { return "", err } if !held { return refuse("the operator cannot be asked yet: %s; %s", why, atTheTerminalInstead(in)) } } all, err := pr.store.All(ctx) if err != nil { return "", err } open := 0 for _, r := range all { if r.State != askOpen || !now.Before(r.Ask.Expires) { continue } if r.Proposal != nil && r.Proposal.Module == p.Module && r.Proposal.Node == p.Node && r.Proposal.Digest == p.Digest && r.Proposal.Clear == p.Clear { return refuse("the same change is already proposed as %s and waits for the operator's answer until %s; "+ "`settings proposals` lists it", r.ID, r.Ask.Expires.Local().Format("15:04")) } open++ } if open >= askMostOpen { return refuse("%d questions already wait for the operator's answer, and the operator is asked at most %d at "+ "once; `settings proposals` lists the proposals among them", open, askMostOpen) } // Kept before it is published, as the asker keeps every ask, so a warrant always finds it. if err := pr.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options, State: askOpen, Opened: now, Proposal: &p}); err != nil { return "", fmt.Errorf("the proposal could not be kept in the controller's asks, so the operator was not asked: %w", err) } body, err := json.Marshal(q) if err != nil { return "", err } if err := pr.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil { _, _ = pr.store.Change(ctx, id, func(x *asked) bool { if x.State != askOpen || x.Acted != "" { return false } x.State, x.Ended, x.Acted = askUnsent, pr.now(), "nothing: it could not be published: "+err.Error() return true }) return "", fmt.Errorf("the operator could not be asked (%v); the proposal %s is kept and will never become "+ "active. Propose it again, or %s", err, id, atTheTerminalInstead(in)) } // The router's word, before answering: it refuses at once an ask no channel can carry (the serving controller // hears that and ends the ask here), and records one it took. taken := "the router has not said yet whether it took the ask; `settings proposals` shows where it stands" for deadline := time.Now().Add(pr.waitFor); time.Now().Before(deadline); { if r, err := pr.store.Get(ctx, id); err == nil && r != nil && r.State != askOpen { why := r.Acted if r.Warrant != nil && r.Warrant.Words != "" { why = r.Warrant.Words } return "", fmt.Errorf("the router did not ask the operator: the ask %s %s (%s). Nothing changes; %s", id, r.State, why, atTheTerminalInstead(in)) } if pr.routerRecord != nil { if state, err := pr.routerRecord(ctx, id); err == nil && state != "" { taken = "the router took the ask and shows it on the channels that can carry it" break } } time.Sleep(pr.waitEvery) } var b strings.Builder fmt.Fprintf(&b, "proposal %s: %s\n", id, q.Headline) fmt.Fprintf(&b, " %s\n", taken) fmt.Fprintf(&b, " the operator is asked on a channel that proves who answers, and reads the change with fingerprint %s\n", fingerprint(p.Digest)) fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n", q.Expires.Local().Format("2006-01-02 15:04")) fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node)) return b.String(), nil } func pushWord(node string) string { if node == "" { return "--behind" } return node } func newProposalID() string { return "s" + strings.TrimPrefix(newAskID(), "c") } // How long propose waits for the router's word on a new ask, and how often it looks. const ( routerAnswersWithin = 8 * time.Second routerAnswersEvery = 250 * time.Millisecond ) // proposeCommand is `settings propose`, on this controller's stores and bus. func proposeCommand(ctx context.Context, module, node, valuesArg string, clear, replace bool) error { var values map[string]any if !clear { if valuesArg == "" { return errors.New("settings propose [--node ] [--replace], or settings propose --clear [--node ]") } var raw []byte var err error if strings.HasPrefix(strings.TrimSpace(valuesArg), "{") { raw = []byte(valuesArg) } else if raw, err = os.ReadFile(valuesArg); err != nil { return err } if err := json.Unmarshal(raw, &values); err != nil { return fmt.Errorf("%s is not a settings file: %w", valuesArg, err) } } else if valuesArg != "" { return errors.New("settings propose: --clear takes no values") } open, err := openStores(ctx) if err != nil { return err } defer open.Close() js, err := aBus() if err != nil { return err } defer js.Close() conn := js.Conn() pr := proposer{ layer: open.inventory.Layer, judge: open.inventory.JudgeSettings, machines: func(ctx context.Context) ([]string, error) { nodes, err := open.inventory.Nodes(ctx) if err != nil { return nil, err } var names []string for _, n := range nodes { names = append(names, n.Name) } return names, nil }, store: busAsked{conn: conn}, publish: func(ctx context.Context, subject string, body []byte, id string) error { _, err := js.Context().Publish(subject, body, nats.MsgId(id), nats.Context(ctx)) return err }, routerHere: routerHereIn(open.inventory), grantHeld: grantHeldIn(open), routerRecord: func(ctx context.Context, id string) (string, error) { bucket, err := asksRecords(ctx, open.inventory) if err != nil || bucket == "" { return "", err } state, _, err := readRouterRecord(ctx, conn, bucket, askerName, id) return state, err }, now: time.Now, caller: link.Caller(), waitFor: routerAnswersWithin, waitEvery: routerAnswersEvery, } words, err := pr.propose(ctx, proposeInput{module: module, node: node, values: values, clear: clear, replace: replace}) if err != nil { return err } fmt.Println(words) return nil } // ---- listing --------------------------------------------------------------------------------------- // proposalsCommand is `settings proposals []`: every proposal, newest first, and where each stands; with an // id, the proposal whole — its values, the layer it was shown against, and its digest. func proposalsCommand(ctx context.Context, id string) error { return onTheBus(func(conn *nats.Conn) error { all, err := busAsked{conn: conn}.All(ctx) if err != nil { return err } var proposals []asked for _, r := range all { if r.Proposal != nil { proposals = append(proposals, r) } } sort.Slice(proposals, func(i, j int) bool { return proposals[i].Opened.After(proposals[j].Opened) }) if id != "" { for _, r := range proposals { if r.ID == id { fmt.Print(describeProposal(r, time.Now())) return nil } } return fmt.Errorf("no proposal %s is kept", id) } if len(proposals) == 0 { fmt.Println("no settings have been proposed") return nil } for _, r := range proposals { fmt.Print(proposalLine(r, time.Now())) } return nil }) } // proposalState is where a proposal stands, in a word or two. func proposalState(r asked, now time.Time) string { switch { case r.State == askOpen && now.Before(r.Ask.Expires): return "waiting for the operator until " + r.Ask.Expires.Local().Format("2006-01-02 15:04") case r.State == askOpen: return "expired unanswered; discarded" case r.Acted != "": return r.State + ": " + r.Acted } return r.State } func proposalLine(r asked, now time.Time) string { p := *r.Proposal what := "set" if p.Clear { what = "clear" } keys := strings.Join(layerKeys(p.Values), ", ") if p.Clear { keys = "the whole layer" } return fmt.Sprintf("%s %s %s on %s (%s)\n by %s at %s; fingerprint %s\n %s\n", r.ID, what, p.Module, p.where(), keys, p.From, p.At.Local().Format("2006-01-02 15:04"), fingerprint(p.Digest), proposalState(r, now)) } func describeProposal(r asked, now time.Time) string { p := *r.Proposal var b strings.Builder b.WriteString(proposalLine(r, now)) fmt.Fprintf(&b, " digest %s; the layer it was shown against %s\n", p.Digest, p.BeforeDigest) shownValues, _ := json.MarshalIndent(p.Values, " ", " ") if p.Clear { fmt.Fprintf(&b, " clears the layer\n") } else { fmt.Fprintf(&b, " values:\n %s\n", shownValues) } if p.HadLayer { shownBefore, _ := json.MarshalIndent(p.Before, " ", " ") fmt.Fprintf(&b, " the layer as it stood:\n %s\n", shownBefore) } else { b.WriteString(" there was no layer\n") } if r.Warrant != nil && r.Warrant.By != nil { fmt.Fprintf(&b, " answered: %s, through %s, at %s\n", r.Warrant.Says(), viaWords(*r.Warrant), r.Warrant.At.Local().Format("2006-01-02 15:04")) } return b.String() } // ---- acting on the warrant -------------------------------------------------------------------------- // setOnWarrant performs an approved proposal: the layer set or cleared as `settings set` and `settings clear` at the // terminal do, with who approved it kept beside the layer. It answers the words of what changed. type setOnWarrant func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error) // decideProposal is what the asker does with a warrant for a proposal (asker.Decided): nothing on Decline, and on // Approve the act only when it is the one the option bound — the digest of the exact values kept here is the one // in the act, and so the one the ask's option bound and the warrant's ask digest covers. func (a *asker) decideProposal(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) { p := *r.Proposal if act.Arguments["answer"] != answerApprove { return "nothing: the operator declined; the layer is unchanged", nil } if a.setLayer == nil { return "", errors.New("this controller cannot set a layer on a warrant") } // The record's own proposal against the act the option bound: the act is composed again from the record — // its module, machine, values (digested again), the layer they replace, whether a removal is meant, a clear, // who proposed it and when — and must digest to what the option bound when the operator was shown it. A // record changed after the ask, in any field, is refused and nothing is set. again := p again.Digest, again.BeforeDigest = layerDigest(p.Values), layerDigest(p.Before) if p.Clear { again.Digest = layerDigest(p.Before) } recomposed := again.actions(r.ID) chosen := -1 for i, candidate := range recomposed { if candidate.Arguments["answer"] == act.Arguments["answer"] { chosen = i } } option, offered := r.Ask.Option(w.Option) if chosen < 0 || !offered { return "", fmt.Errorf("the proposal %s offers no answer %q: nothing is set", r.ID, act.Arguments["answer"]) } if err := option.Performs(boundAct(recomposed[chosen])); err != nil { return "", fmt.Errorf("the proposal kept for %s is not the one the operator was shown: %v", r.ID, err) } setBy := fmt.Sprintf("approved by %s via %s at %s (ask %s, proposed by %s)", byWords(w), viaWords(w), w.At.Local().Format("2006-01-02 15:04"), r.ID, p.From) return a.setLayer(ctx, p, r.ID, setBy) } // setLayerIn is setOnWarrant on this controller's stores: the layer must still be the one the operator was shown // the change against (to-be 46 §10, step 7), then it is set with the same judgement as at the terminal. func setLayerIn(open *stores) setOnWarrant { return func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error) { inv := open.inventory before, had, err := inv.Layer(ctx, p.Node, p.Module) if err != nil { return "", err } if layerDigest(before) != p.BeforeDigest || had != p.HadLayer { return "", fmt.Errorf("the layer of %s on %s changed since the operator was shown the change: it is not set; "+ "propose it again", p.Module, p.where()) } var changed string if p.Clear { if err := inv.ClearSettingsBy(ctx, p.Node, p.Module, setBy); err != nil { return "", err } changed = "the layer is removed" } else { added, altered, removed := settingsChange(before, p.Values) if len(removed) > 0 && !p.Replace { return "", fmt.Errorf("the layer would no longer set %s, and the removal was not meant: nothing is set", strings.Join(removed, ", ")) } if err := inv.SetSettingsBy(ctx, p.Node, p.Module, p.Values, setBy); err != nil { return "", err } var parts []string for _, k := range added { parts = append(parts, "+ "+k) } for _, k := range altered { parts = append(parts, "~ "+k) } for _, k := range removed { parts = append(parts, "- "+k) } changed = strings.Join(parts, ", ") if changed == "" { changed = "nothing changed" } } return changed + " (ask " + askID + ")", nil } }