package main import ( "context" "fmt" "sort" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // A push of recorded builds is no repair (novox/hq issue 301, ADR 0242, to-be 45 §7). // // **A recorded build moves only by a person's push** (ADR 0242): a module whose upgrade policy is // `record` — the network path, the providers whose restart costs, mail — has its new build registered // at its merge and sent by nothing but `push `. That push is the person's word the policy asks // for: the mesh working as decided. The hand-act log counted it as a repair all the same, because a push // by hand is the repair roll-out by default exists to end. On 2026-10-07 the operator approved, node by // node, new builds of the resolver, the FortiClient adapter, the network managers and the packet filter; // ten pushes later S15 wanted a healer for `split-dns`, `words` and `uplink-verbs`. // // **The push says what it was, from what it carried.** Never from a word the person gives — that is // how a repair could pass for anything (issue 292). Before it is recorded, a push that names one machine // reads what that machine was last sent against what the mesh holds. It is a **push of recorded builds** // when: // // - what the machine was last sent is known; // - its last report is no failure or refusal, so the push does not re-send to mend one; // - it moves at least one module's build; and // - every module it moves records rather than rolls out: a held new build of one the machine runs, or // the first build of one newly assigned there (whose `assign` says "run `push ` to send it"). // // Anything else — a build that rolls out, a module taken off, nothing moved at all — is a push by hand // as before, and counts. The act is written with `kind: recorded-builds` and what it carried; the table // of verbs (handActVerbs) reads a push of that kind as a person's decision, and S15 never counts it. // // What is not compared: settings and grants. A push of a recorded build carries whatever else // changed in the machine's declaration with it, as any push does. // recordedPush answers what a push to one machine carries, one "module from → to" each, when every // build it moves is a recorded one held for a person's word; otherwise nil and why it is not. // // `modules` is the machine's set; `sent` and `known` what it was last sent (Inventory.SentBuilds); // `current` what the mesh holds; `identical` whether two builds of a module put the same thing on a // machine (moveFacts.identical); `failing` the machine's last report when it was a failure or a refusal. func recordedPush(modules []string, sent map[string]string, known bool, current map[string]inventory.CurrentBuild, identical func(module, a, b string) bool, failing string) ([]string, string) { if !known { return nil, "what the machine was last sent is not known" } if failing != "" { return nil, "its last report was " + failing + ": the push sends again what failed" } in := map[string]bool{} var carried []string for _, m := range modules { in[m] = true // A module the mesh holds no build of, or holds without a source, has no build to move. now, held := current[m] if !held || now.Commit == "" { continue } was, ran := sent[m] if ran && identical(m, was, now.Commit) { continue } if now.RollOut { return nil, fmt.Sprintf("%s would move %sto %s, and it rolls out: its build is a plan's to send", m, fromBuild(was, ran), buildName(now.Commit)) } carried = append(carried, fmt.Sprintf("%s %s→ %s", m, fromBuild(was, ran), buildName(now.Commit))) } for m := range sent { if !in[m] { return nil, m + " is taken off the machine" } } if len(carried) == 0 { return nil, "it moves no build: a send again" } sort.Strings(carried) return carried, "" } // recordedPushOf reads, for a push about to name one machine, whether it is a push of recorded builds: what // it carries, // or nil and why not. An error is that it could not be read; the push is then recorded as a push by hand, // as every push was before, and says why. func recordedPushOf(ctx context.Context, node string) ([]string, string, error) { open, err := openStores(ctx) if err != nil { return nil, "", err } defer open.Close() inv := open.inventory plan, _, err := planFor(ctx, open, node) if err != nil { return nil, "", fmt.Errorf("its set cannot be worked out: %w", err) } modules := make([]string, 0, len(plan.Modules)) for _, m := range plan.Modules { modules = append(modules, m.Module) } sent, known, err := inv.SentBuilds(ctx, node) if err != nil { return nil, "", err } f, err := readMoveFacts(ctx, inv) if err != nil { return nil, "", err } doing, said, err := inv.DoingOf(ctx, node) if err != nil { return nil, "", err } failing := "" if said && (doing.Outcome == inventory.OutcomeFailed || doing.Outcome == inventory.OutcomeRefused) { failing = doing.Outcome } carried, why := recordedPush(modules, sent, known, f.current, f.identical, failing) return carried, why, nil } // recordedBefore are the pushes recorded before a push said its kind, each a push of recorded builds by // what it carried (novox/hq issue 301): the operator's word, machine by machine, for new builds of modules // whose upgrade policy records — systemd-resolved, forticlient, networkmanager, systemd-networkd, // nftables, mailu. The log did not keep what a push carried then, so the record names them; no push // recorded since needs it, because every push now says its kind. S15 reads these as it reads a push of // that kind, and the three `healer-wanted` they opened clear on the next tick. var recordedBefore = map[string]string{ "act-1791404241010309198-1": "systemd-resolved, first build on its machine (ADR 0247)", "act-1791404587689907257-1": "systemd-resolved, first build; nftables, held build (catalogue #111)", "act-1791404809925218992-1": "nftables, held build (catalogue #111)", "act-1791404844504887009-1": "mailu and nftables, held builds (catalogue #106, #111)", "act-1791408072745552019-1": "forticlient, held build (catalogue #114)", "act-1791408101286318350-1": "forticlient, held build (catalogue #114)", "act-1791409209593713522-1": "networkmanager, held build (catalogue #107)", "act-1791409280125022264-1": "networkmanager, held build (catalogue #107)", "act-1791409336586475835-1": "networkmanager, held build (catalogue #107)", "act-1791409393494198352-1": "systemd-networkd, held build (catalogue #107)", } // pushedRecorded is whether an act in the log is a push of recorded builds. func pushedRecorded(a link.HandAct) bool { if a.Verb != "push" { return false } if a.Kind == link.KindRecordedBuilds { return true } _, before := recordedBefore[a.ID] return before && a.Kind == "" }