package main import ( "context" "crypto/ecdh" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "os" "path/filepath" "slices" "sort" "strings" "time" "github.com/jackc/pgx/v5" "github.com/novox/mesh-controller/internal/artifacts" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/catalogue" snapshot "github.com/novox/mesh-controller/internal/facts" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/store" ) // The merge gate (novox/hq to-be 45 §9, ADR 0227 rule 9): **every machine of the mesh composed with the // change, and the node-engine's own validator run over each, before the change merges.** // // A change is judged against the mesh as the facts snapshot says it runs, twice: once as it is (the // base) and once with the change applied, each in a throwaway store raised from the snapshot through // the controller's own code — the same registration, assignment, seats, pins and settings a running // controller keeps, and the same composition a push makes. What the change breaks is what composes or // validates in the base and not with the change, named by the machine's role and the module; what was // already broken is said and does not fail the change. // // What it catches, by the incidents it was written from: // - a manifest the node-engine refuses whole, found only by assigning it to a live machine (236); // - an identity a real machine's name makes too long for a provision, which refused the provider's // whole machine (263) — or, since ADR 0225, leaves the consumer out of its grants: either fails; // - a manifest the controller the mesh runs cannot read (version skew): a catalogue change is judged // by that controller, so a field only a newer one knows fails here, not at registration; // - a module removed from its source while machines still run it (ADR 0236), and two definitions of // one module name (239); // - a setting the mesh holds that the changed definition cannot compose with (ADR 0163); // - and, said rather than failed, how wide the rebuild of the merge would be (278). // // It runs where a build runs: the build seat, asked to check a pull request (checks.go), so no CI // outside the mesh is needed; and by hand, with a snapshot and a throwaway store, by anyone. // mergeCheckInput is what one judging needs. type mergeCheckInput struct { facts snapshot.Facts // repository is the change's owner/repository; tree its checkout, empty when the change is to code // alone and every module stays as the mesh holds it. repository, tree string // changed are the paths the change touches, for the width of its rebuild. changed []string // group are the other heads of a delivery group composed with this one (novox/hq ADR 0239), each laid // over the mesh after the one before: the group judged as one future state. group []groupChange // admin is a PostgreSQL the gate may create and drop databases in. admin string // say is where progress goes; the verdict is returned. say io.Writer } // groupChange is one other head of a delivery group: its repository, its checkout and what it changes. type groupChange struct { Repository string `json:"repository"` Tree string `json:"tree"` Changed []string `json:"changed,omitempty"` } // readGroup reads the other heads of a group's composed check from the file the build seat wrote. func readGroup(path string) ([]groupChange, error) { if path == "" { return nil, nil } raw, err := os.ReadFile(path) if err != nil { return nil, err } var g []groupChange if err := json.Unmarshal(raw, &g); err != nil { return nil, fmt.Errorf("the group's heads are not readable: %w", err) } for _, c := range g { if c.Repository == "" || c.Tree == "" { return nil, errors.New("a head of the group names no repository or no checkout") } } return g, nil } // mergeVerdict is what the gate found. type mergeVerdict struct { Verdict string `json:"verdict"` // pass, warning or fail Summary string `json:"summary"` // Judge is the controller build that judged it, and Facts when the snapshot it judged against was taken. Judge string `json:"judge"` Facts time.Time `json:"facts"` Failures []string `json:"failures,omitempty"` // Errors are what kept the gate from judging: a machine the mesh composes that the gate could not // raise as it is. Any one makes the verdict an error — never a pass (novox/hq issue 282). Errors []string `json:"errors,omitempty"` Warnings []string `json:"warnings,omitempty"` Notes []string `json:"notes,omitempty"` Machines []mergeMachine `json:"machines"` Modules map[string]string `json:"modules,omitempty"` // changed module → what changed: added, changed, removed Width *mergeWidthOf `json:"width,omitempty"` } // mergeMachine is one machine, composed as it is and with the change. type mergeMachine struct { Name string `json:"name"` Described string `json:"described"` Live bool `json:"live-composes"` Base mergeComposed `json:"base"` Change mergeComposed `json:"change"` Added []string `json:"added,omitempty"` Removed []string `json:"removed,omitempty"` Modules map[string]int `json:"-"` } // mergeComposed is one machine's composition in one store. type mergeComposed struct { Composes bool `json:"composes"` Problems []string `json:"problems,omitempty"` Withheld []string `json:"withheld,omitempty"` Unbound []string `json:"unbound,omitempty"` LeftOut map[string]string `json:"left-out,omitempty"` Resources []string `json:"resources,omitempty"` } // mergeWidthOf is how much a merge of the change would rebuild. type mergeWidthOf struct { Modules []string `json:"modules"` Tiers int `json:"tiers"` // Unread is the changed paths no module's build reads — in no module's directory — which rebuild // nothing (issue 280): said, so a reader sees why a change to them moves no module. Unread []string `json:"unread,omitempty"` } // wideRebuild is how many modules a rebuild may take before the gate says so as a warning. var wideRebuild = 12 // mergeGateCommand is `gate`: // // merge-gate --facts --store [--repository owner/repo --tree ] [--changed a,b] [--json] // // --facts may be a file or `store`, the snapshot the artifact store holds (MESH_REGISTRY names it). // --store is a PostgreSQL the gate may create and drop databases in: a throwaway, never the mesh's. func mergeGateCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("merge-gate", flag.ContinueOnError) factsFrom := set.String("facts", "", "the facts snapshot: a file, or `store` for the one the artifact store holds") admin := set.String("store", os.Getenv("MESH_GATE_POSTGRES"), "a throwaway PostgreSQL the gate may create databases in") repository := set.String("repository", "", "owner/repository of the change") tree := set.String("tree", "", "the change's checkout: every module.json in it replaces the mesh's") changed := set.String("changed", "", "the paths the change touches, comma-separated, for its rebuild's width") group := set.String("group", "", "a delivery group's other heads, as JSON [{repository, tree, changed}], composed "+ "with this one as one future state (novox/hq ADR 0239)") asJSON := set.Bool("json", false, "print the verdict as JSON") if _, err := parseAround(set, args); err != nil { return err } if *factsFrom == "" || *admin == "" { return errors.New("merge-gate --facts --store [--repository owner/repo --tree ] " + "[--changed paths] [--json]") } if (*tree == "") != (*repository == "") && *tree != "" { return errors.New("--tree names the change's checkout; --repository says which repository it is") } f, err := readFacts(ctx, *factsFrom) if err != nil { return err } others, err := readGroup(*group) if err != nil { return err } out := io.Writer(os.Stdout) stdout := os.Stdout if *asJSON { out = os.Stderr // **The verdict is the only thing on standard output** when it is asked for as JSON: composition // says what it finds as it goes (a bus user without a credential, a module left out) and prints it, // and a line of that before the verdict makes the verdict unreadable — the build seat would read no // verdict at all. What is said goes beside the report. os.Stdout = os.Stderr } v, err := judgeChange(ctx, mergeCheckInput{facts: f, repository: *repository, tree: *tree, changed: splitList(*changed), group: others, admin: *admin, say: out}) os.Stdout = stdout if err != nil { return err } if *asJSON { body, err := json.MarshalIndent(v, "", " ") if err != nil { return err } fmt.Println(string(body)) // And the report a person reads, beside it: what the pull request is told comes from here. fmt.Fprint(os.Stderr, v.Report()) } else { fmt.Print(v.Report()) } switch v.Verdict { case "fail": return errMergeGateFailed case "error": return errMergeGateCouldNotJudge } return nil } // errMergeGateCouldNotJudge is the gate's own error: the mesh as it is could not be raised, so nothing // the change does to it can be seen. Never a pass (novox/hq issue 282). var errMergeGateCouldNotJudge = errors.New("the merge gate could not judge the change") // errMergeGateFailed is the gate's own failure: the verdict says why, so the error says nothing more. var errMergeGateFailed = errors.New("the change fails the merge gate") // readFacts reads a snapshot from a file, or from the artifact store. func readFacts(ctx context.Context, from string) (snapshot.Facts, error) { var body []byte var err error if from == "store" { address := strings.TrimSpace(os.Getenv("MESH_REGISTRY")) if address == "" { return snapshot.Facts{}, errors.New("--facts store reads the artifact store MESH_REGISTRY names, and it names none") } body, _, err = (artifacts.Store{Address: address}).GetTagged(ctx, snapshot.Repository, snapshot.Tag) } else { body, err = os.ReadFile(from) } if err != nil { return snapshot.Facts{}, err } return snapshot.Decode(body) } // judgeChange is the gate: both compositions, and what the change did to each machine. func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error) { v := mergeVerdict{Judge: version, Facts: in.facts.Taken, Modules: map[string]string{}} say := func(format string, args ...any) { if in.say != nil { fmt.Fprintf(in.say, format+"\n", args...) } } base, err := shelfOfFacts(in.facts) if err != nil { return v, err } change := base sources := sourcesOfFacts(in.facts) // **What the change moves is the planner's answer** (reachOfMerge, novox/hq ADR 0238), asked of the // snapshot as if the change were merged: the definitions composed with the change are those of the // modules a merge of it would rebuild, and of the modules it adds — not every definition in the tree, // which may differ from what the mesh holds for reasons that are not this change's (a module pinned // at an older commit, a main the mesh has not built yet). var reach *mergeReach // The change, then each other head of its delivery group laid over it in turn (novox/hq ADR 0239): one // future state of the mesh, judged as the group would leave it. heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...) for i, h := range heads { var r *mergeReach if h.Repository != "" && len(h.Changed) > 0 { got, err := reachOfChange(in.facts, h.Repository, h.Changed, h.Tree) if err != nil { v.Notes = append(v.Notes, "what a merge of "+h.Repository+" would rebuild could not be worked out, so "+ "every definition in its tree is judged: "+err.Error()) } else { r = &got } } if i == 0 { reach = r } if h.Tree == "" { continue } var failures []string change, failures, err = shelfWithChange(change, sources, in.facts, h.Repository, h.Tree, v.Modules, scopeOfReach(r, h.Repository)) if err != nil { return v, err } v.Failures = append(v.Failures, failures...) } if len(in.group) > 0 { say("a delivery group: %d head(s) composed together", len(heads)) } say("judging %d module(s) changed against %d machine(s), as the snapshot of %s says they run", len(v.Modules), len(in.facts.Machines), in.facts.Taken.Format(time.RFC3339)) // What no single machine shows: the rules between manifests, identities on the mesh's own longest // name, and the data each module keeps. Only what the change adds is the change's. for _, rule := range []func(catalogue.Shelf) []string{ catalogue.CatalogueProblems, func(s catalogue.Shelf) []string { return catalogue.IdentityProblems(s, in.facts.Longest()) }, catalogue.DataProblems, } { was := rule(catalogue.Shelf(base)) for _, p := range rule(catalogue.Shelf(change)) { if !slices.Contains(was, p) { v.Failures = append(v.Failures, p) } } } // Both compositions, each in a store of its own. say("composing every machine as the mesh is") before, _, notes, err := composeEveryMachine(ctx, in, base, sources, nil) if err != nil { return v, fmt.Errorf("the mesh as the snapshot says it is could not be raised: %w", err) } v.Notes = append(v.Notes, notes...) say("composing every machine with the change") var added []string for module, how := range v.Modules { if how == "added" { added = append(added, module) } } sort.Strings(added) after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added) if err != nil { return v, fmt.Errorf("the mesh with the change could not be raised: %w", err) } for _, n := range notes { if !slices.Contains(v.Notes, n) { v.Failures = append(v.Failures, n) } } for _, m := range in.facts.Machines { gm := mergeMachine{Name: m.Name, Described: m.Described(), Live: m.Declaration.Composes, Base: before[m.Name], Change: after[m.Name]} gm.Added, gm.Removed = difference(gm.Base.Resources, gm.Change.Resources) v.Machines = append(v.Machines, gm) switch { case gm.Base.Composes && !gm.Change.Composes: v.Failures = append(v.Failures, fmt.Sprintf("%s: nothing could be sent to it with this change — %s", gm.Described, firstOr(newOnly(gm.Change.Problems, gm.Base.Problems), gm.Change.Problems))) case !gm.Base.Composes && m.Declaration.Composes: // **The mesh composes it and the gate could not raise it as it is** (novox/hq issue 282): a fact // the snapshot does not carry, or one the gate does not raise. Then the change is judged against a // machine that is not the mesh's — broken against broken, which passes whatever the change does — // so the gate cannot judge, and says so: an error, never a pass. v.Errors = append(v.Errors, fmt.Sprintf("%s composes on the mesh and not as the gate raised it from the "+ "snapshot: %s", gm.Described, firstOr(gm.Base.Problems, nil))) if added := newOnly(gm.Change.Problems, gm.Base.Problems); len(added) > 0 { v.Failures = append(v.Failures, fmt.Sprintf("%s: the change adds — %s", gm.Described, added[0])) } case !gm.Base.Composes: v.Notes = append(v.Notes, fmt.Sprintf("%s does not compose on the mesh today either: %s", gm.Described, firstOr(m.Declaration.Problems, gm.Base.Problems))) } for _, w := range newOnly(gm.Change.Withheld, gm.Base.Withheld) { v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a consumer out of its grants — %s", gm.Described, w)) } for _, u := range newOnly(gm.Change.Unbound, gm.Base.Unbound) { v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves a credential bound elsewhere — %s", gm.Described, u)) } for module, why := range gm.Change.LeftOut { if _, was := gm.Base.LeftOut[module]; !was { v.Failures = append(v.Failures, fmt.Sprintf("%s: the change leaves %s out of its declaration — %s", gm.Described, module, why)) } } } // A module nobody runs yet, tried on a machine that could run it. for _, module := range added { t := tried[module] for _, r := range t.Refused { v.Failures = append(v.Failures, fmt.Sprintf("%s, assigned to %s, would be refused by its node-engine — %s", module, t.Machine, r)) } if t.Said != "" { v.Notes = append(v.Notes, fmt.Sprintf("%s could not be tried on a machine: %s", module, t.Said)) } } if reach != nil { w := widthOf(*reach) v.Width = &w if len(w.Unread) > 0 { v.Notes = append(v.Notes, fmt.Sprintf("%s read by no module's build, and rebuild nothing (issue 280)", readableList(w.Unread))) } if len(w.Modules) > wideRebuild { v.Warnings = append(v.Warnings, fmt.Sprintf("a merge rebuilds %d module(s) in %d tier(s)", len(w.Modules), w.Tiers)) } } sort.Strings(v.Failures) v.Failures = slices.Compact(v.Failures) sort.Strings(v.Errors) switch { case len(v.Failures) > 0: v.Verdict = "fail" v.Summary = fmt.Sprintf("%d problem(s) the change brings; the first: %s", len(v.Failures), v.Failures[0]) case len(v.Errors) > 0: v.Verdict = "error" v.Summary = fmt.Sprintf("the mesh as it is could not be raised, so the change cannot be judged against it "+ "(%d of %d machines compose on the mesh and not in the gate); the first: %s", len(v.Errors), len(v.Machines), v.Errors[0]) case len(v.Warnings) > 0: v.Verdict = "warning" v.Summary = v.Warnings[0] default: v.Verdict = "pass" composing := 0 for _, m := range v.Machines { if m.Change.Composes { composing++ } } v.Summary = fmt.Sprintf("every machine composes with the change as it did without (%d of %d compose)", composing, len(v.Machines)) } return v, nil } // Report is the verdict as a person reads it, and as a pull request is told it. func (v mergeVerdict) Report() string { var b strings.Builder fmt.Fprintf(&b, "merge gate: %s — %s\n", strings.ToUpper(v.Verdict), v.Summary) fmt.Fprintf(&b, "judged by controller %s against the facts of %s\n", v.Judge, v.Facts.Format(time.RFC3339)) list := func(title string, items []string) { if len(items) == 0 { return } fmt.Fprintf(&b, "\n%s:\n", title) for _, i := range items { fmt.Fprintf(&b, " - %s\n", i) } } list("fails", v.Failures) list("could not judge", v.Errors) list("warns", v.Warnings) if len(v.Modules) > 0 { var changed []string for m, how := range v.Modules { changed = append(changed, m+" ("+how+")") } sort.Strings(changed) list("modules the change moves", changed) } var machines []string for _, m := range v.Machines { line := m.Described + ": " switch { case m.Change.Composes: line += "composes" default: line += "does not compose" } if len(m.Added) > 0 || len(m.Removed) > 0 { line += fmt.Sprintf("; the change adds %d resource(s) and removes %d", len(m.Added), len(m.Removed)) if len(m.Added)+len(m.Removed) <= 6 { line += " (" + strings.Join(append(prefixed("+", m.Added), prefixed("-", m.Removed)...), ", ") + ")" } } machines = append(machines, line) } list("machines", machines) if v.Width != nil { fmt.Fprintf(&b, "\na merge would rebuild %d module(s) in %d tier(s)", len(v.Width.Modules), v.Width.Tiers) if len(v.Width.Modules) > 0 && len(v.Width.Modules) <= wideRebuild { fmt.Fprintf(&b, ": %s", strings.Join(v.Width.Modules, ", ")) } fmt.Fprintln(&b) } list("notes", v.Notes) return b.String() } func prefixed(p string, items []string) []string { out := make([]string, len(items)) for i, s := range items { out[i] = p + s } return out } // shelfOfFacts is every module of the snapshot, as the mesh holds it — but the ones that came with the // controller: those are this controller's own, provided when its store is raised. func shelfOfFacts(f snapshot.Facts) (map[string]catalogue.Manifest, error) { out := map[string]catalogue.Manifest{} for _, m := range f.Modules { if m.Provided { continue } var manifest catalogue.Manifest if err := json.Unmarshal(m.Manifest, &manifest); err != nil { return nil, fmt.Errorf("the snapshot's %s is not a manifest: %w", m.Name, err) } out[m.Name] = manifest } return out, nil } // sourcesOfFacts is where each module of the snapshot is built from. func sourcesOfFacts(f snapshot.Facts) map[string]inventory.Source { out := map[string]inventory.Source{} for _, m := range f.Modules { if !m.Provided { out[m.Name] = inventory.Source{Repository: m.Repository, Path: m.Path, BuiltFrom: m.Commit} } } return out } // ignoredInTree are directories a module.json in is not a module of the repository. var ignoredInTree = map[string]bool{".git": true, "vendor": true, "node_modules": true, "testdata": true, "examples": true, "fixtures": true} // shelfWithChange is the base shelf with every module of the change's tree in place of the mesh's: each // read by this controller's strict parser — the one the mesh runs, for a change to a catalogue — resolved // with stand-in builds, and read again as registration reads a built one. A module the repository held // and the tree no longer has is removed. Answers the shelf and what the tree itself fails. // // `scope` is the directories of the modules a merge of the change would rebuild or add, as the planner says // (scopeOfReach); a definition elsewhere in the tree is left as the mesh holds it. Nil judges every one. func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inventory.Source, f snapshot.Facts, repository, tree string, moved map[string]string, scope map[string]bool) (map[string]catalogue.Manifest, []string, error) { inScope := func(dir string) bool { return scope == nil || scope[dir] } out := make(map[string]catalogue.Manifest, len(base)) for k, m := range base { out[k] = m } var failures []string found := map[string]string{} // module → its directory in the tree err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { if path != tree && (ignoredInTree[d.Name()] || strings.HasPrefix(d.Name(), ".")) { return filepath.SkipDir } return nil } if d.Name() != "module.json" { return nil } dir, err := filepath.Rel(tree, filepath.Dir(path)) if err != nil { return err } if dir == "." { dir = "" } raw, err := os.ReadFile(path) if err != nil { return err } m, err := catalogue.ParseManifest(raw) if err != nil { if inScope(dir) { failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s", orRoot(dir), version, oneLine(err.Error()))) } return nil } if was, twice := found[m.Module]; twice { if inScope(dir) || inScope(was) { failures = append(failures, fmt.Sprintf("%s and %s both define %s: two definitions of one module", orRoot(was), orRoot(dir), m.Module)) } return nil } found[m.Module] = dir if !inScope(dir) { return nil } if s, held := sources[m.Module]; held && s.Repository != "" && !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { failures = append(failures, fmt.Sprintf("%s defines %s, which the mesh builds from %s: two definitions "+ "of one module name (issue 239)", orRoot(dir), m.Module, s.Repository)) return nil } resolved, err := standInBuild(m) if err == nil { // As registration reads a built manifest: strictly, again (takeIn). var raw []byte if raw, err = json.Marshal(resolved); err == nil { resolved, err = catalogue.ParseManifest(raw) } } if err == nil { err = namesNoInstallation(resolved) } if err != nil { failures = append(failures, fmt.Sprintf("%s: %s is not registrable — %s", orRoot(dir), m.Module, oneLine(err.Error()))) return nil } if was, held := base[m.Module]; !held { moved[m.Module] = "added" } else if builtAlike(was) == builtAlike(resolved) { // Unchanged but for what a build pins: the mesh's own build stands, so the machines that // run it compose exactly as they do. return nil } else { moved[m.Module] = "changed" } out[m.Module] = resolved return nil }) if err != nil { return nil, nil, err } // What the repository held and the tree no longer defines is removed — refused while a machine runs it. running := map[string][]string{} for _, mc := range f.Machines { for _, a := range mc.Assigned { running[a] = append(running[a], mc.Described()) } } for name, s := range sources { if s.Repository == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) { continue } if _, still := found[name]; still { continue } if !inScope(strings.Trim(s.Path, "/")) { continue } delete(out, name) moved[name] = "removed" if on := running[name]; len(on) > 0 { failures = append(failures, fmt.Sprintf("%s is removed from %s, and %s run(s) it: unassign it first (ADR 0236)", name, repository, readableList(on))) } } return out, failures, nil } func orRoot(dir string) string { if dir == "" { return "the repository's root" } return dir } func ownerOf(repository string) string { owner, _, _ := strings.Cut(repository, "/") return owner } func repoOf(repository string) string { _, repo, _ := strings.Cut(repository, "/") return repo } // builtAlike is a resolved manifest with what a build pins taken out — the digests and the references // they are reached by, and the version a path is named for — so a definition the change leaves alone reads // the same as the build of it the mesh holds. func builtAlike(m catalogue.Manifest) string { raw, err := json.Marshal(m) if err != nil { return "" } var doc any if err := json.Unmarshal(raw, &doc); err != nil { return "" } var walk func(any) any walk = func(v any) any { switch t := v.(type) { case map[string]any: for k, e := range t { switch strings.ToLower(k) { case "image", "source", "digest", "launchers": t[k] = "pinned" default: t[k] = walk(e) } } return t case []any: for i, e := range t { t[i] = walk(e) } return t case string: if before, _, found := strings.Cut(t, "/versions/"); found { return before + "/versions/pinned" } return t } return v } out, err := json.Marshal(walk(doc)) if err != nil { return "" } return string(out) } // standInBuild resolves a manifest as a build would, with artifacts nobody built: a digest made from the // module's and the artifact's names, in the store's own vocabulary. Composition and validation read the // shape of a reference, never its bytes. func standInBuild(m catalogue.Manifest) (catalogue.Manifest, error) { if m.Build == nil { return m.Resolve(nil) } var built []catalogue.Built for _, a := range m.Build.Artifacts { sum := sha256.Sum256([]byte("gate\x00" + m.Module + "\x00" + a.Name)) digest := fmt.Sprintf("sha256:%x", sum) b := catalogue.Built{Name: a.Name, Kind: a.Kind, Digest: digest} switch a.Kind { case catalogue.ArtifactImage, catalogue.ArtifactUpstream: b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "@" + digest default: b.Reference = catalogue.ArtifactStoreScheme + m.Module + "/" + a.Name + "/blobs/" + digest } built = append(built, b) } return m.Resolve(built) } // composeEveryMachine raises a throwaway store from the snapshot with this shelf, composes every machine // twice — the first time as a push does, making each credential, so the second has every consumer's // grant to compose — and answers each machine's composition. Notes are what of the snapshot could not // be raised: a refusal to register a module, keep a setting or a pin. func composeEveryMachine(ctx context.Context, in mergeCheckInput, shelf map[string]catalogue.Manifest, sources map[string]inventory.Source, trials []string) (map[string]mergeComposed, map[string]mergeTrial, []string, error) { open, drop, err := throwawayStores(ctx, in.admin) if err != nil { return nil, nil, nil, err } defer drop() notes, err := raiseFromFacts(ctx, open, in.facts, shelf, sources) if err != nil { return nil, nil, notes, err } gens, gensErr := generators(ctx, open) out := map[string]mergeComposed{} for pass := 0; pass < 2; pass++ { for _, m := range in.facts.Machines { if gensErr != nil { out[m.Name] = mergeComposed{Problems: []string{"the private network cannot be computed: " + oneLine(gensErr.Error())}} continue } declared, problems, err := composedAndValidated(ctx, open, m.Name, gens, Allocating) if pass == 0 { continue } c := mergeComposed{} if err != nil { c.Problems = []string{oneLine(err.Error())} out[m.Name] = c continue } c.Composes = len(problems) == 0 c.Problems = problems c.Resources = resourceNames(declared.Resources) c.LeftOut = declared.leftOutWhy for _, o := range declared.withheld { c.Withheld = append(c.Withheld, o.String()) } for _, u := range declared.unbound { c.Unbound = append(c.Unbound, u.String()) } sort.Strings(c.Withheld) sort.Strings(c.Unbound) out[m.Name] = c } if ctx.Err() != nil { return nil, nil, notes, ctx.Err() } } tried := map[string]mergeTrial{} if gensErr == nil { for _, module := range trials { tried[module] = tryAssigning(ctx, open, in.facts, shelf[module], gens) } } return out, tried, notes, nil } // mergeTrial is a module no machine runs yet, assigned for a moment to one that could run it. type mergeTrial struct { Machine string `json:"machine,omitempty"` Refused []string `json:"refused,omitempty"` Composes bool `json:"composes"` Said string `json:"said,omitempty"` } // tryAssigning composes a module nobody runs yet on the first machine that has what it needs, and says // what the node-engine would refuse of it there (issue 236: the refusal came only from the first live // machine it was assigned to). The assignment is taken back; nothing else in the store moves. func tryAssigning(ctx context.Context, open *stores, f snapshot.Facts, m catalogue.Manifest, gens map[string]catalogue.Generator) mergeTrial { for _, machine := range f.Machines { has := map[string]bool{} for _, c := range machine.Capabilities { has[c.Name] = c.Present } fits := true for _, c := range m.Capabilities { fits = fits && has[c] } if !fits { continue } if _, err := open.inventory.Assign(ctx, machine.Name, m.Module); err != nil { return mergeTrial{Machine: machine.Described(), Said: oneLine(err.Error())} } _, problems, err := composedAndValidated(ctx, open, machine.Name, gens, Allocating) _ = open.inventory.Unassign(ctx, machine.Name, m.Module) t := mergeTrial{Machine: machine.Described()} if err != nil { t.Said = oneLine(err.Error()) return t } for _, p := range problems { if strings.Contains(p, `"`+m.Module+".") || strings.Contains(p, m.Module+":") { t.Refused = append(t.Refused, p) } } t.Composes = len(problems) == 0 return t } return mergeTrial{Said: "no machine of the mesh has what it needs: " + strings.Join(m.Capabilities, ", ")} } // throwawayStores creates fresh databases for every context the controller keeps in the PostgreSQL at // admin, brings each to this controller's schema, and answers them opened, with what drops them again. func throwawayStores(ctx context.Context, admin string) (*stores, func(), error) { suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000_000) conn, err := pgx.Connect(ctx, admin) if err != nil { return nil, nil, fmt.Errorf("cannot reach the throwaway PostgreSQL: %w", err) } defer conn.Close(ctx) cut := strings.LastIndex(admin, "/") if cut < 0 { return nil, nil, fmt.Errorf("%q is not a PostgreSQL URL", admin) } query := "" if q := strings.Index(admin[cut:], "?"); q >= 0 { query = admin[cut+q:] } var names []string // The stores are found by the environment, so what it said before is what it says after: a gate run // inside a process with stores of its own — a test, a controller — leaves them as they were. restore := map[string]*string{} for _, c := range held { if was, set := os.LookupEnv(store.Variable(c.name)); set { restore[c.name] = &was } else { restore[c.name] = nil } } drop := func() { for name, was := range restore { if was == nil { _ = os.Unsetenv(store.Variable(name)) } else { _ = os.Setenv(store.Variable(name), *was) } } c, err := pgx.Connect(context.Background(), admin) if err != nil { return } defer c.Close(context.Background()) for _, n := range names { _, _ = c.Exec(context.Background(), "drop database if exists "+n+" with (force)") } } for _, c := range held { name := "gate_" + c.name + "_" + suffix if _, err := conn.Exec(ctx, "create database "+name); err != nil { drop() return nil, nil, fmt.Errorf("cannot create %s: %w", name, err) } names = append(names, name) url := admin[:cut] + "/" + name + query if err := os.Setenv(store.Variable(c.name), url); err != nil { drop() return nil, nil, err } migrations, err := c.migrations() if err != nil { drop() return nil, nil, err } s, err := store.Open(ctx, c.name) if err != nil { drop() return nil, nil, err } _, err = s.Migrate(ctx, migrations) s.Close() if err != nil { drop() return nil, nil, fmt.Errorf("%s does not migrate: %w", c.name, err) } } open, err := openStores(ctx) if err != nil { drop() return nil, nil, err } for _, m := range provided { if err := open.inventory.Provide(ctx, m); err != nil { open.Close() drop() return nil, nil, err } } if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil { open.Close() drop() return nil, nil, err } return open, func() { open.Close(); drop() }, nil } // raiseFromFacts puts the snapshot's mesh into a fresh store through the controller's own records: // every module of the shelf registered, every machine added where it is with what it reported, a key // for each standing in for the one it holds, its assignments, the seats it holds, its pins and its // settings, and a stand-in for every secret a person gave. func raiseFromFacts(ctx context.Context, open *stores, f snapshot.Facts, shelf map[string]catalogue.Manifest, sources map[string]inventory.Source) ([]string, error) { inv := open.inventory var notes []string names := make([]string, 0, len(shelf)) for name := range shelf { names = append(names, name) } sort.Strings(names) for _, name := range names { s := sources[name] if err := inv.RegisterModule(ctx, shelf[name], inventory.Source{Repository: s.Repository, Path: s.Path, BuiltFrom: s.BuiltFrom}); err != nil { notes = append(notes, fmt.Sprintf("%s is not registered: %s", name, oneLine(err.Error()))) } } for i, m := range f.Machines { node, err := inv.AddNodeAs(ctx, m.Name, m.Adopted) if err != nil { return notes, fmt.Errorf("%s: %w", m.Name, err) } if m.OnNetwork { endpoint := "" if m.Public { endpoint = fmt.Sprintf("192.0.2.%d:51820", i+1) } if err := inv.SetPlace(ctx, m.Name, endpoint, m.Site, m.Hub, fmt.Sprintf("10.99.%d.%d", i/250, i%250+1)); err != nil { return notes, fmt.Errorf("%s: %w", m.Name, err) } } var caps []map[string]any for _, c := range m.Capabilities { caps = append(caps, map[string]any{"name": c.Name, "present": c.Present, "detail": c.Detail}) } if err := inv.RecordProfile(ctx, node.ID, map[string]any{"architecture": m.Architecture, "kernel": m.Kernel, "capabilities": caps}); err != nil { return notes, err } for _, record := range []func(context.Context, string, string) error{inv.RecordSealingKey, inv.RecordOverlayKey} { key, err := standInKey() if err != nil { return notes, err } if err := record(ctx, node.ID, key); err != nil { return notes, err } } // The links it faces outside by, which a filter is written around (ADR 0140). A snapshot from a // controller that did not carry them stands in one for a machine the mesh composes: composing, it // had reported them. outward := m.OutwardLinks if len(outward) == 0 && m.Declaration.Composes { outward = []string{"outside0"} } if len(outward) > 0 { if err := inv.RecordOutwardLinks(ctx, node.ID, outward); err != nil { return notes, err } } if m.Account != "" { if err := inv.SetAccount(ctx, m.Name, m.Account, m.AccountHome); err != nil { return notes, err } } if m.PublicDomain != "" { if err := inv.SetPublicDomain(ctx, m.Name, m.PublicDomain); err != nil { return notes, err } } } for _, m := range f.Machines { for _, module := range m.Assigned { if _, err := inv.Assign(ctx, m.Name, module); err != nil { notes = append(notes, fmt.Sprintf("%s cannot be assigned to %s: %s", module, m.Described(), oneLine(err.Error()))) } } } for _, s := range f.Seats { for _, h := range s.Holders { var err error if s.Scope == catalogue.ScopeMesh && len(s.Holders) > 1 { err = inv.AddSeatHolder(ctx, s.Name, s.Scope, h.Machine, h.Module) } else { err = inv.HoldSeat(ctx, s.Name, s.Scope, h.Machine, h.Module) } if err != nil { notes = append(notes, fmt.Sprintf("%s's hold of %s is not kept: %s", h.Machine, s.Name, oneLine(err.Error()))) } } } for _, s := range f.Settings { if err := inv.KeepSettings(ctx, "", s.Module, standInPaths(s.Values)); err != nil { notes = append(notes, fmt.Sprintf("the mesh's settings of %s are not kept: %s", s.Module, oneLine(err.Error()))) } } for _, m := range f.Machines { for _, p := range m.Pins { if err := inv.PinProvision(ctx, m.Name, p.Provision, p.Machine, p.Module); err != nil { notes = append(notes, fmt.Sprintf("%s's pin of %s is not kept: %s", m.Described(), p.Provision, oneLine(err.Error()))) } } for _, s := range m.Settings { if err := inv.KeepSettings(ctx, m.Name, s.Module, standInPaths(s.Values)); err != nil { notes = append(notes, fmt.Sprintf("%s's settings of %s are not kept: %s", m.Described(), s.Module, oneLine(err.Error()))) } } for _, a := range m.Accepted { standIn := fmt.Sprintf("gate-stand-in-%x", sha256.Sum256([]byte(m.Name+a.Module+a.Name+a.Provider+a.Local)))[:40] if a.Provider == "" && a.Name == "broker" { // **The bus credential is an account, not a given value** (novox/hq issue 203): composition asks // whether one was issued for the module, so the raised store mints the account the mesh issued // as well as holding the sealed value — otherwise every machine running a module on the bus // fails to compose, with the change and without, and the gate compares broken to broken. // A credential the mesh still holds for a module that no longer reads one composes nothing, // so it is not raised. if !readsBusCredential(shelf, a.Module) { continue } if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Kind: busKindOf(a.Module), Node: m.Name, Module: a.Module, Username: broker.Principal{Kind: broker.KindModule, Node: m.Name, Module: a.Module}.Username()}); err != nil { notes = append(notes, fmt.Sprintf("%s's bus account for %s is not kept: %s", m.Described(), a.Module, oneLine(err.Error()))) continue } } var err error if a.Provider == "" { err = inv.AcceptSecretForModule(ctx, m.Name, a.Module, a.Name, standIn) } else { err = inv.AcceptSecretForPair(ctx, a.Name, m.Name, a.Module, a.Provider, a.Local, standIn) } if err != nil { notes = append(notes, fmt.Sprintf("%s's given %s for %s is not kept: %s", m.Described(), a.Name, a.Module, oneLine(err.Error()))) } } } return notes, nil } // standInPaths is a settings layer with every value the snapshot withheld where a path stands — an // access's or a place's — given a path of its own: a path that reads as a key is withheld by the scrub, // and a bare "withheld" is no absolute path, which composition refuses (a machine the mesh composes // would not compose as the gate raised it). Each stand-in is distinct, so two withheld places stay two. func standInPaths(values map[string]any) map[string]any { out := make(map[string]any, len(values)) for k, v := range values { out[k] = v } if accesses, ok := values["accesses"].(map[string]any); ok { kept := make(map[string]any, len(accesses)) for name, at := range accesses { if withheldPath(at) { at = "/" + snapshot.Withheld + "/access/" + name } kept[name] = at } out["accesses"] = kept } if places, ok := values["places"].(map[string]any); ok { kept := make(map[string]any, len(places)) for name, p := range places { if place, ok := p.(map[string]any); ok && withheldPath(place["path"]) { copied := make(map[string]any, len(place)) for k, v := range place { copied[k] = v } copied["path"] = "/" + snapshot.Withheld + "/place/" + name p = copied } kept[name] = p } out["places"] = kept } return out } // withheldPath is a value the scrub withheld where a path stood: bare, or keeping a path's shape. func withheldPath(v any) bool { return v == snapshot.Withheld || v == "/"+snapshot.Withheld } // readsBusCredential is whether a module of the shelf, or one that came with the controller, declares the // own secret its bus account is delivered as. func readsBusCredential(shelf map[string]catalogue.Manifest, module string) bool { if m, held := shelf[module]; held { _, reads := m.OwnSecrets["broker"] return reads } for _, m := range provided { if m.Module == module { _, reads := m.OwnSecrets["broker"] return reads } } return false } // standInKey is a key a machine could have reported; its private half is never kept. func standInKey() (string, error) { k, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil } // reachOfChange is what a merge of the change would move and build — **the planner's own answer** // (reachOfMerge), asked of the modules, sources, reads and edges the snapshot carries, never a mapping of // the gate's own: what a merge touches and what follows it are decided in one place, so the gate and the // plan a merge makes cannot disagree (novox/hq ADR 0238). func reachOfChange(f snapshot.Facts, repository string, paths []string, tree string) (mergeReach, error) { owner, repo, found := strings.Cut(repository, "/") if !found { return mergeReach{}, fmt.Errorf("%q is not owner/repository", repository) } m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "gate", Paths: paths} // Which changed directories hold a module, read from the change's tree as the forge's announcer reads // them at the head (issue 278): what the planner is told of a module the mesh does not hold yet. if tree != "" { m.ModuleDirs, m.ModuleDirsSaid = moduleDirsIn(tree, paths), true for _, p := range paths { if _, err := os.Stat(filepath.Join(tree, p)); os.IsNotExist(err) { m.Removed = append(m.Removed, p) } } } entries, read, edges, err := graphOfFacts(f) if err != nil { return mergeReach{}, err } return reachOfMerge(m, entries, read, edges), nil } // graphOfFacts is the module graph the snapshot carries, as the planner reads it from the store. func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.ReadRepository, []inventory.Edge, error) { var entries []inventory.Entry read := map[string][]inventory.ReadRepository{} for _, mod := range f.Modules { var manifest catalogue.Manifest if err := json.Unmarshal(mod.Manifest, &manifest); err != nil { return nil, nil, nil, err } entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided, Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}}) for _, r := range mod.Reads { read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Repository: r}) } } var edges []inventory.Edge for _, e := range f.Edges { edges = append(edges, inventory.Edge{From: e.From, To: e.To, Kind: e.Kind}) } return entries, read, edges, nil } // widthOf is how wide the rebuild of a reach is. func widthOf(r mergeReach) mergeWidthOf { w := mergeWidthOf{Tiers: len(r.Plan.Tiers), Unread: r.Unread} for name := range r.Plan.Modules { w.Modules = append(w.Modules, name) } sort.Strings(w.Modules) return w } // scopeOfReach is the directories, in the change's repository, of the modules a reach rebuilds or adds; // nil — every definition judged — when there is no reach. func scopeOfReach(r *mergeReach, repository string) map[string]bool { if r == nil { return nil } scope := map[string]bool{} same := link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)} for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) { if sameRepository(e.Source.Repository, same) { scope[strings.Trim(e.Source.Path, "/")] = true } } for _, d := range r.Added { if d == "." { d = "" } scope[d] = true } return scope } // moduleDirsIn are the directories above the changed paths, never the root, that hold a module.json in // the tree. func moduleDirsIn(tree string, paths []string) []string { seen := map[string]bool{} var out []string for _, p := range paths { for dir := filepath.Dir(filepath.Clean(p)); dir != "." && dir != "/" && dir != ""; dir = filepath.Dir(dir) { if seen[dir] { continue } seen[dir] = true if _, err := os.Stat(filepath.Join(tree, dir, "module.json")); err == nil { out = append(out, filepath.ToSlash(dir)) } } } sort.Strings(out) return out } // difference is what b has that a has not, and what a has that b has not. func difference(a, b []string) (added, removed []string) { for _, x := range b { if !slices.Contains(a, x) { added = append(added, x) } } for _, x := range a { if !slices.Contains(b, x) { removed = append(removed, x) } } return added, removed } // newOnly is what now has that was had not. func newOnly(now, was []string) []string { var out []string for _, x := range now { if !slices.Contains(was, x) { out = append(out, x) } } return out } func firstOr(items, otherwise []string) string { if len(items) > 0 { return items[0] } if len(otherwise) > 0 { return otherwise[0] } return "it says nothing more" }