Files
mesh-controller/cmd/mesh-controller/pull_declares.go
T
jschoubben 4357b3688f
mesh/check failed: integration: github.com/novox/mesh-controller/internal/identity.TestACertificateChainsToTheMeshsOwnAuthority: authority_test.go:45:…
mesh/repo-check head 4357b3688f58 merged into trunk 3d4ad6fefb7c — pass: its merge-check.sh passed
mesh/merge-gate head 4357b3688f58 merged into trunk 3d4ad6fefb7c — pass: group fix/1826-held-config composed: every machine composes with the change as it …
mesh/delivery delivered
mesh/delivery-group group fix/1826-held-config delivered: every member is delivered
Tell mesh-config what a head declares at every head, so a head that reverts a key drops its held value (issue 1826)
2026-10-22 11:07:34 +02:00

212 lines
7.7 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"path"
"reflect"
"slices"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// What the controller tells mesh-config of an open pull request, and asks it back (novox/hq ADR 0502 clauses 2 and 4,
// issue 1826): a config key a pull request introduces is not declared on the trunk, so a value for it can be judged
// only against the pull request's own declaration, which the core reads from the forge and mesh-config never does
// (ADR 0495 §1). The call is mesh-config's `config` verb in the controller's own name, actions `pull-declares` and
// `held`; the shapes are those of the interface the two sides agreed.
// meshConfigWait is how long the controller waits for mesh-config before it goes on without: a check is never held up
// by a module that does not answer.
const meshConfigWait = 10 * time.Second
// meshConfigAsk is one call of mesh-config's `config` verb in the controller's own name, answering its result. A
// variable so a test needs no bus.
var meshConfigAsk = func(ctx context.Context, args map[string]any) ([]byte, error) {
var result []byte
err := onTheBus(func(conn *nats.Conn) error {
answer, err := link.AskMeshSeatToolAs(ctx, conn, broker.ControllerName, "mesh-config", "config", args, meshConfigWait)
if err != nil {
return err
}
if answer.Error != "" {
return fmt.Errorf("%s", answer.Error)
}
result = answer.Result
return nil
})
return result, err
}
// pullAt is an open pull request at a head: the change to read, and its number.
type pullAt struct {
changeSource
Number int
}
// pullAtOf is a pull request announced, as the change it makes over what it merges into.
func pullAtOf(p link.PullUpdated) pullAt {
old := p.BaseSHA
if old == "" {
old = p.Base
}
return pullAt{Number: p.Number, changeSource: changeSource{Owner: p.Owner, Repo: p.Repo, New: p.Commit, Old: old,
Paths: p.Paths, Truncated: p.PathsTruncated, Removed: p.Removed}}
}
func pullKey(owner, repo string, number int) string {
return fmt.Sprintf("%s/%s#%d", owner, repo, number)
}
func (p pullAt) key() string { return pullKey(p.Owner, p.Repo, p.Number) }
// pullDeclarations are the config declarations a pull request's head adds or changes over what it merges into, by
// module, in the shape of the declared-config mirror. The bool says the head could be read: false for a pull request whose
// paths are cut (the caller cuts them to the manifests of the modules it moves) or whose base is unknown, which is then
// told nothing, never an empty set.
func pullDeclarations(ctx context.Context, p pullAt) ([]inventory.DeclaredConfigMirrored, bool, error) {
var out []inventory.DeclaredConfigMirrored
if p.Truncated || p.Old == "" {
return nil, false, nil
}
for _, file := range p.Paths {
if path.Base(file) != moduleManifestFile || slices.Contains(p.Removed, file) {
continue
}
now, exists, err := manifestCached(ctx, p.Owner, p.Repo, file, p.New)
if err != nil {
return nil, false, err
}
if !exists || now.Module == "" {
continue
}
was, _, err := manifestCached(ctx, p.Owner, p.Repo, file, p.Old)
if err != nil {
return nil, false, err
}
have, err := inventory.DeclaredOf(now.Settings)
if err != nil {
return nil, false, fmt.Errorf("%s at %.8s: %w", file, p.New, err)
}
had, err := inventory.DeclaredOf(was.Settings)
if err != nil {
return nil, false, fmt.Errorf("%s at %.8s: %w", file, p.Old, err)
}
changed := map[string]inventory.DeclaredMirrored{}
for key, d := range have {
if before, there := had[key]; !there || !reflect.DeepEqual(before, d) {
changed[key] = d
}
}
if len(changed) > 0 {
out = append(out, inventory.DeclaredConfigMirrored{Module: now.Module, Settings: changed})
}
}
return out, true, nil
}
// tellPullDeclares tells mesh-config, for each pull request at every announced head, what the head declares, an empty set
// when it declares nothing new: mesh-config re-judges the values held at every head, so a head that reverts the change
// drops them (ADR 0502 clause 6). A
// failure is said to w and the check goes on: the held values are then none (ADR 0502 clause 2).
func tellPullDeclares(ctx context.Context, w io.Writer, pulls []pullAt) {
for _, p := range pulls {
mods, readable, err := pullDeclarations(ctx, p)
if err != nil {
fmt.Fprintf(w, "%s: what its head declares cannot be read, so mesh-config is told nothing: %v\n", p.key(), err)
continue
}
if !readable {
continue
}
declares := map[string]map[string]inventory.DeclaredMirrored{}
for _, m := range mods {
declares[m.Module] = m.Settings
}
if _, err := meshConfigAsk(ctx, map[string]any{"action": "pull-declares", "pull": p.key(), "head": p.New,
"declares": declares}); err != nil {
fmt.Fprintf(w, "%s: mesh-config cannot be told what its head declares (%v); the check goes on without held values\n",
p.key(), err)
}
}
}
// heldFor asks mesh-config for the approved held values of these pull requests at their heads (ADR 0502 clause 4). Only
// those of the pull requests asked about are carried. A failure is said to w and answers none.
func heldFor(ctx context.Context, w io.Writer, pulls []pullAt) []link.HeldConfig {
if len(pulls) == 0 {
return nil
}
type pullHead struct {
Pull string `json:"pull"`
Head string `json:"head"`
}
asked := make([]pullHead, 0, len(pulls))
for _, p := range pulls {
asked = append(asked, pullHead{p.key(), p.New})
}
raw, err := meshConfigAsk(ctx, map[string]any{"action": "held", "pulls": asked})
if err != nil {
fmt.Fprintf(w, "mesh-config cannot say what it holds for these pull requests (%v); the check goes on without held values\n", err)
return nil // empty-on-error: said above; a check without held values fails for want of the value and says so, it never passes on a guess
}
var answer struct {
Held []link.HeldConfig `json:"held"`
Skipped []struct {
Pull string `json:"pull"`
Why string `json:"why"`
} `json:"skipped"`
}
if len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, &answer); err != nil {
fmt.Fprintf(w, "mesh-config's held values cannot be read (%v); the check goes on without them\n", err)
return nil // empty-on-error: said above; a check without held values fails for want of the value and says so, it never passes on a guess
}
for _, s := range answer.Skipped {
fmt.Fprintf(w, "%s: mesh-config holds a value it does not carry: %s\n", s.Pull, s.Why)
}
var out []link.HeldConfig
for _, h := range answer.Held {
for _, p := range pulls {
if h.Pull == p.key() {
out = append(out, h)
break
}
}
}
return out
}
// withHeldConfig tells mesh-config what these pull requests declare and puts the approved held values of them on the
// check request (ADR 0502 clause 4). With carry false, a member of a group whose composed check composes, only the
// declarations are told.
func withHeldConfig(ctx context.Context, w io.Writer, request *link.BuildRequest, pulls []pullAt, carry bool) {
tellPullDeclares(ctx, w, pulls)
if carry {
carryHeld(ctx, w, request, pulls)
}
}
// carryHeld puts the approved held values of these pull requests on the check request.
func carryHeld(ctx context.Context, w io.Writer, request *link.BuildRequest, pulls []pullAt) {
if request.Check != nil {
request.Check.HeldConfig = heldFor(ctx, w, pulls)
}
}
// holdsModules is whether a module of the mesh is built from this repository.
func holdsModules(entries []inventory.Entry, owner, repo string) bool {
return slices.ContainsFunc(entries, func(e inventory.Entry) bool {
return sameRepository(e.Source.Repository, link.SourceMoved{Owner: owner, Repo: repo})
})
}