The controller accepts a reviewed move of a module between repositories on the operator's Approve (issue 1025, ADR 0420) #369
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# The unit tests of every Go module of its own below this repository's root, run from the root, for merge-check.sh
|
||||
# and for the test that tries it on a tree (merge_check_nested_test.go). Its own file so that the test needs no
|
||||
# particular merge-check.sh: the check that judges a change to merge-check.sh is main's.
|
||||
set -eu
|
||||
cgo="${cgo:-0}"
|
||||
race="${race:-}"
|
||||
|
||||
# nested_modules vets and tests every Go module of its own below the root, `modules/<name>/go.mod` (novox/hq ADR 0420
|
||||
# §5, issue 1025): `./...` stops at a nested go.mod, so a module that lives whole in this repository would have its
|
||||
# unit tests run by nobody. None is a pass. Each runs in its own directory, outside the vendor directory this
|
||||
# repository's own packages are built from; how its dependencies are found offline is issue 1019's (the move of the
|
||||
# first two modules in), and it is settled there. A failing module fails the check; the others are still run.
|
||||
nested_modules() {
|
||||
nested=0
|
||||
for gomod in modules/*/go.mod; do
|
||||
[ -e "$gomod" ] || continue
|
||||
dir=$(dirname "$gomod")
|
||||
echo "nested module $dir:"
|
||||
if ! (cd "$dir" && GOFLAGS= CGO_ENABLED=0 go vet ./... &&
|
||||
GOFLAGS= CGO_ENABLED="${cgo:-0}" go test ${race:-} -count=1 -timeout 30m ./...); then
|
||||
echo "nested module $dir FAILED"
|
||||
nested=1
|
||||
fi
|
||||
done
|
||||
return $nested
|
||||
}
|
||||
|
||||
nested_modules
|
||||
@@ -435,6 +435,20 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
|
||||
module, sourceWords(built.Repository, built.Seat))
|
||||
}
|
||||
}
|
||||
// **Or the move the operator approved** (novox/hq ADR 0420 §7, issue 1025): a known module built from another
|
||||
// repository by a merge, whose pull request mesh-delivery merged on the operator's Approve of that head.
|
||||
// A verb alone, or a merge with no such warrant, is refused as before.
|
||||
movedWhy := ""
|
||||
if why != "" && !terminal && !isNew {
|
||||
accepted, err := approvedMove(ctx, inv, forms, buildID, built)
|
||||
switch {
|
||||
case err == nil:
|
||||
movedWhy, why = accepted, ""
|
||||
case !errors.Is(err, errNotAMerge):
|
||||
fmt.Printf("%s: %s is built from another repository, and no approved move covers this build: %v\n",
|
||||
buildID, module, err)
|
||||
}
|
||||
}
|
||||
if why != "" && !terminal {
|
||||
return refuse(why)
|
||||
}
|
||||
@@ -471,7 +485,8 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
|
||||
}
|
||||
// The same repository by the forge's id, not only its name: one deleted and made again is another.
|
||||
recorded := map[string]int64{}
|
||||
if !isNew {
|
||||
// A moved module is registered from the repository the operator approved, not the one it was registered from.
|
||||
if !isNew && movedWhy == "" {
|
||||
id, err := inv.SourceIdentity(ctx, module)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -492,6 +507,10 @@ func mayRegisterFrom(ctx context.Context, inv *inventory.Inventory, module strin
|
||||
owner, name, m, facts.ID, m, id))
|
||||
}
|
||||
}
|
||||
if movedWhy != "" {
|
||||
fmt.Printf("%s: %s moved from %s to %s/%s — registered at the operator's Approve: %s\n", buildID, module,
|
||||
sourceWords(was.Repository, was.Seat), owner, name, movedWhy)
|
||||
}
|
||||
return facts.ID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -382,3 +384,75 @@ func TestAnUnknownIdentityKeepsTheRecordedOne(t *testing.T) {
|
||||
t.Fatalf("the recorded id became %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
// **The operator's Approve of a reviewed group registers a moved module from its new repository** (novox/hq ADR 0420 §7,
|
||||
// issue 1025). sudo is built from the catalogue; a merge into the repository of node-engine's module, announced by
|
||||
// mesh-delivery with the ask it merged on, brings its build from there. Accepted with the operator's Approve of the
|
||||
// merged head; refused with no kept merge, a warrant for other heads, a Decline, a person who is not the operator, and
|
||||
// through a verb.
|
||||
func TestAModuleMovesOnTheOperatorsApproveOfTheMergeThatMovedIt(t *testing.T) {
|
||||
const commit = "c0ffee0123456789"
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
change func(*anApprovedMerge)
|
||||
unkept bool
|
||||
request string // who asked: "merge", or "build" through a verb
|
||||
moved bool
|
||||
}{
|
||||
{what: "the operator's Approve of the merged head", request: "merge", moved: true},
|
||||
{what: "no merge mesh-delivery announced", unkept: true, request: "merge"},
|
||||
{what: "a warrant for other heads", request: "merge", change: func(a *anApprovedMerge) {
|
||||
a.merge.Heads = map[string]string{"novox/mesh-host@aaaaaaaaaaaa": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5"}
|
||||
}},
|
||||
{what: "a Decline", request: "merge", change: func(a *anApprovedMerge) {
|
||||
a.warrant.Option, a.warrant.Label, a.merge.Option = "decline", "Decline", "decline"
|
||||
}},
|
||||
{what: "a person who is not the operator", request: "merge", change: func(a *anApprovedMerge) { a.warrant.By.Who = "agent" }},
|
||||
{what: "a build asked through a verb", request: "build"},
|
||||
} {
|
||||
open := theCatalogue(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
a := approvedMerge(t)
|
||||
a.merge.Owner, a.merge.Repo, a.merge.Number, a.merge.HeadSHA = "novox", "mesh-host", 7, "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"
|
||||
if c.change != nil {
|
||||
c.change(&a)
|
||||
}
|
||||
wasKept, wasAsk := keptDeliveryMerge, routerAskOf
|
||||
keptDeliveryMerge = func(_ context.Context, owner, repo string, number int, head string) (deliveryMerge, bool, error) {
|
||||
return a.merge, !c.unkept && owner == "novox" && repo == "mesh-host" && number == 7 && head == a.merge.HeadSHA, nil
|
||||
}
|
||||
routerAskOf = func(_ context.Context, _ *inventory.Inventory, id string) (*asks.Ask, *asks.Warrant, error) {
|
||||
if id != a.merge.AskID {
|
||||
t.Errorf("%s: the router was asked for %q", c.what, id)
|
||||
}
|
||||
return &a.ask, &a.warrant, nil
|
||||
}
|
||||
t.Cleanup(func() { keptDeliveryMerge, routerAskOf = wasKept, wasAsk })
|
||||
event, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-host", "base": "main", "number": 7,
|
||||
"head_sha": a.merge.HeadSHA, "merge_commit_sha": commit})
|
||||
if _, err := inv.AddMerge(ctx, inventory.BatchedMerge{Repository: "novox/mesh-host", Branch: "main", Commit: commit,
|
||||
Merged: time.Now(), Heard: time.Now(), Event: event}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := fmt.Sprintf("build-%d", time.Now().Add(time.Hour).UnixNano())
|
||||
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-host", Seat: "git",
|
||||
Path: "modules/sudo", Ref: "main", Commit: commit, For: c.request}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := onTrunk(id, "novox/mesh-host", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "2"})
|
||||
moved.Commit = commit
|
||||
_, _, err := takeIn(ctx, inv, moved)
|
||||
src, _ := inv.SourceOf(ctx, "sudo")
|
||||
switch {
|
||||
case c.moved && err != nil:
|
||||
t.Errorf("%s: the move was refused: %v", c.what, err)
|
||||
case c.moved && src.Repository != "novox/mesh-host":
|
||||
t.Errorf("%s: sudo is built from %q", c.what, src.Repository)
|
||||
case !c.moved && !errors.Is(err, errNotItsSource):
|
||||
t.Errorf("%s: the move was not refused as ADR 0266 has it: %v", c.what, err)
|
||||
case !c.moved && src.Repository != "novox/mesh-catalog":
|
||||
t.Errorf("%s: a refused move changed sudo's source to %q", c.what, src.Repository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A repository's merge check runs every module's unit tests, a Go module of its own below the root included**
|
||||
// (novox/hq ADR 0420 §5, issue 1025): `./...` stops at a nested go.mod. This runs the script
|
||||
// check-nested-modules.sh in a tree with a `go` that only records where it was run and fails for a module named bad.
|
||||
func TestTheMergeCheckVetsAndTestsEveryNestedGoModule(t *testing.T) {
|
||||
script, err := filepath.Abs("../../check-nested-modules.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin, tree := t.TempDir(), t.TempDir()
|
||||
log := filepath.Join(t.TempDir(), "go.log")
|
||||
fake := "#!/bin/sh\necho \"$(basename \"$PWD\") $1 GOFLAGS=[$GOFLAGS]\" >>\"$LOG\"\n" +
|
||||
"[ \"$(basename \"$PWD\")\" = bad ] && [ \"$1\" = test ] && exit 1\nexit 0\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(fake), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := func() (string, error) {
|
||||
cmd := exec.Command("sh", script)
|
||||
cmd.Dir = tree
|
||||
cmd.Env = append(os.Environ(), "PATH="+bin+":"+os.Getenv("PATH"), "LOG="+log, "GOFLAGS=-mod=vendor")
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
// No modules directory: a pass, nothing run.
|
||||
if out, err := run(); err != nil {
|
||||
t.Fatalf("a repository with no nested module failed: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(log); err == nil {
|
||||
t.Fatal("go ran with no nested module")
|
||||
}
|
||||
for _, m := range []string{"good", "bad", "other"} {
|
||||
dir := filepath.Join(tree, "modules", m)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A directory with no go.mod is no Go module of its own.
|
||||
if err := os.MkdirAll(filepath.Join(tree, "modules", "typescript"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := run()
|
||||
if err == nil {
|
||||
t.Fatalf("a nested module whose tests fail passed the check:\n%s", out)
|
||||
}
|
||||
raw, _ := os.ReadFile(log)
|
||||
ran := string(raw)
|
||||
for _, want := range []string{"good vet GOFLAGS=[]", "good test GOFLAGS=[]", "other test GOFLAGS=[]", "bad test GOFLAGS=[]"} {
|
||||
if !strings.Contains(ran, want) {
|
||||
t.Errorf("%q was not run, nor outside the vendor directory:\n%s", want, ran)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ran, "typescript") {
|
||||
t.Errorf("a directory with no go.mod was run:\n%s", ran)
|
||||
}
|
||||
if !strings.Contains(out, "nested module modules/bad FAILED") {
|
||||
t.Errorf("the failing module is not named:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,25 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
||||
// 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...)
|
||||
// **A module one head removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025): neither
|
||||
// the removal of a module machines run (ADR 0236) nor a second definition of one module name (issue 239).
|
||||
// The change is judged with the module built from the repository that now defines it.
|
||||
moves, err := movesInGroup(heads, sources)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
changeSources := sources
|
||||
if len(moves) > 0 {
|
||||
changeSources = make(map[string]inventory.Source, len(sources))
|
||||
for k, o := range sources {
|
||||
changeSources[k] = o
|
||||
}
|
||||
for name, mv := range moves {
|
||||
o := changeSources[name]
|
||||
o.Repository, o.Path = mv.To, mv.Dir
|
||||
changeSources[name] = o
|
||||
}
|
||||
}
|
||||
for i, h := range heads {
|
||||
var r *mergeReach
|
||||
if h.Repository != "" && len(h.Changed) > 0 {
|
||||
@@ -289,13 +308,23 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
||||
continue
|
||||
}
|
||||
var failures []string
|
||||
change, failures, err = shelfWithChange(change, sources, in.facts, h.Repository, h.Tree, v.Modules,
|
||||
scopeOfReach(r, h.Repository))
|
||||
scope := scopeOfReach(r, h.Repository)
|
||||
if scope != nil {
|
||||
for _, mv := range moves {
|
||||
if mv.To == h.Repository {
|
||||
scope[mv.Dir] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
change, failures, err = shelfWithChange(change, changeSources, in.facts, h.Repository, h.Tree, v.Modules, scope)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Failures = append(v.Failures, failures...)
|
||||
}
|
||||
for name := range moves {
|
||||
v.Modules[name] = "moved"
|
||||
}
|
||||
if len(in.group) > 0 {
|
||||
say("a delivery group: %d head(s) composed together", len(heads))
|
||||
}
|
||||
@@ -332,7 +361,7 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
|
||||
}
|
||||
}
|
||||
sort.Strings(added)
|
||||
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added)
|
||||
after, tried, notes, err := composeEveryMachine(ctx, in, change, changeSources, added)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("the mesh with the change could not be raised: %w", err)
|
||||
}
|
||||
@@ -531,6 +560,98 @@ func sourcesOfFacts(f snapshot.Facts) map[string]inventory.Source {
|
||||
return out
|
||||
}
|
||||
|
||||
// moveOfModule is a module a delivery group moves: the repository the mesh builds it from, the one whose
|
||||
// head now defines it, and its directory there.
|
||||
type moveOfModule struct{ From, To, Dir string }
|
||||
|
||||
// movesInGroup is the modules one head of a group removes and another defines (novox/hq ADR 0420 §6): a name
|
||||
// the mesh builds from a head's repository that the head's tree no longer defines, and some other head's tree
|
||||
// defines. A name removed by a head with no other head defining it, or defined by a head the mesh builds from
|
||||
// another repository with none removing it, is no move.
|
||||
func movesInGroup(heads []groupChange, sources map[string]inventory.Source) (map[string]moveOfModule, error) {
|
||||
if len(heads) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
defined := make([]map[string]string, len(heads))
|
||||
for i, h := range heads {
|
||||
if h.Tree == "" {
|
||||
continue
|
||||
}
|
||||
d, err := namesDefinedIn(h.Tree)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defined[i] = d
|
||||
}
|
||||
var moves map[string]moveOfModule
|
||||
for name, s := range sources {
|
||||
if s.Repository == "" {
|
||||
continue
|
||||
}
|
||||
for i, h := range heads {
|
||||
if h.Tree == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(h.Repository), Repo: repoOf(h.Repository)}) {
|
||||
continue
|
||||
}
|
||||
if _, still := defined[i][name]; still {
|
||||
break
|
||||
}
|
||||
// Removed by head i: defined by another?
|
||||
for j, o := range heads {
|
||||
if dir, ok := defined[j][name]; ok && j != i && o.Repository != h.Repository {
|
||||
if moves == nil {
|
||||
moves = map[string]moveOfModule{}
|
||||
}
|
||||
moves[name] = moveOfModule{From: h.Repository, To: o.Repository, Dir: dir}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
// namesDefinedIn is the module names a tree's module.json files define, each with its directory; a manifest
|
||||
// that does not read is left to shelfWithChange to refuse.
|
||||
func namesDefinedIn(tree string) (map[string]string, error) {
|
||||
found := map[string]string{}
|
||||
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
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if json.Unmarshal(raw, &m) != nil || m.Module == "" {
|
||||
return nil
|
||||
}
|
||||
dir, err := filepath.Rel(tree, filepath.Dir(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
}
|
||||
if _, twice := found[m.Module]; !twice {
|
||||
found[m.Module] = dir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found, err
|
||||
}
|
||||
|
||||
// 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}
|
||||
@@ -1267,6 +1388,8 @@ func graphOfFacts(f snapshot.Facts) ([]inventory.Entry, map[string][]inventory.R
|
||||
read[mod.Name] = append(read[mod.Name], inventory.ReadRepository{Own: true, Paths: s.Paths})
|
||||
}
|
||||
}
|
||||
// A context naming the module's own repository is its own build source (novox/hq ADR 0420 §4).
|
||||
read[mod.Name] = inventory.OwnRepositoryAsOwn(read[mod.Name], mod.Repository)
|
||||
}
|
||||
var edges []inventory.Edge
|
||||
for _, e := range f.Edges {
|
||||
|
||||
@@ -434,3 +434,128 @@ func sdkPinnedByGoMod(t *testing.T) string {
|
||||
t.Fatalf("go.mod requires no %s", sdkModule)
|
||||
return ""
|
||||
}
|
||||
|
||||
// **A module one head of a group removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025):
|
||||
// neither a second definition of one module name (issue 239) nor the removal of a module machines run
|
||||
// (ADR 0236). Alone, each head is still refused. This lays the heads over the shelf as judgeChange does,
|
||||
// which needs no database.
|
||||
func TestAGroupsMoveOfAModuleIsNeitherTwoDefinitionsNorARemoval(t *testing.T) {
|
||||
const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller"
|
||||
built := func(repo, path string) inventory.Source {
|
||||
return inventory.Source{Repository: "http://forge.internal:20000/" + repo + ".git", Path: path, BuiltFrom: "c0ffee"}
|
||||
}
|
||||
sources := map[string]inventory.Source{"mover": built(catalogueRepo, "modules/mover")}
|
||||
raw := `{"module":"mover","version":"1"}`
|
||||
base := map[string]catalogue.Manifest{}
|
||||
m, err := catalogue.ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base["mover"] = m
|
||||
facts := snapshot.Facts{Machines: []snapshot.Machine{{Name: "anchor", Assigned: []string{"mover"}}}}
|
||||
|
||||
catalogueWithout := aTree(t, map[string]string{"other": `{"module":"other","version":"1"}`})
|
||||
controllerWith := func() string {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "modules", "mover"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "modules", "mover", "module.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}()
|
||||
removes := groupChange{Repository: catalogueRepo, Tree: catalogueWithout, Changed: []string{"modules/mover/module.json"}}
|
||||
defines := groupChange{Repository: controllerRepo, Tree: controllerWith, Changed: []string{"modules/mover/module.json"}}
|
||||
|
||||
lay := func(heads ...groupChange) (map[string]catalogue.Manifest, []string, map[string]string) {
|
||||
t.Helper()
|
||||
moves, err := movesInGroup(heads, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := sources
|
||||
if len(moves) > 0 {
|
||||
at = map[string]inventory.Source{}
|
||||
for k, v := range sources {
|
||||
at[k] = v
|
||||
}
|
||||
for name, mv := range moves {
|
||||
o := at[name]
|
||||
o.Repository, o.Path = mv.To, mv.Dir
|
||||
at[name] = o
|
||||
}
|
||||
}
|
||||
shelf, said := base, map[string]string{}
|
||||
var failures []string
|
||||
for _, h := range heads {
|
||||
var f []string
|
||||
if shelf, f, err = shelfWithChange(shelf, at, facts, h.Repository, h.Tree, said, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failures = append(failures, f...)
|
||||
}
|
||||
for name := range moves {
|
||||
said[name] = "moved"
|
||||
}
|
||||
return shelf, failures, said
|
||||
}
|
||||
for _, order := range [][]groupChange{{removes, defines}, {defines, removes}} {
|
||||
shelf, failures, said := lay(order...)
|
||||
if len(failures) > 0 {
|
||||
t.Fatalf("a move of a module machines run, laid %s first, was refused: %v", order[0].Repository, failures)
|
||||
}
|
||||
if said["mover"] != "moved" {
|
||||
t.Errorf("the move reads %v, not moved", said)
|
||||
}
|
||||
if _, held := shelf["mover"]; !held {
|
||||
t.Errorf("the moved module is not on the shelf after laying %s first", order[0].Repository)
|
||||
}
|
||||
}
|
||||
// Alone, a head is still refused: a second definition (issue 239), and a removal of what runs (ADR 0236).
|
||||
if _, failures, _ := lay(defines); len(failures) != 1 || !strings.Contains(failures[0], "issue 239") {
|
||||
t.Errorf("a definition of a module another repository builds, alone: %v", failures)
|
||||
}
|
||||
if _, failures, _ := lay(removes); len(failures) != 1 || !strings.Contains(failures[0], "ADR 0236") {
|
||||
t.Errorf("the removal of a module machines run, alone: %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
// A group that moves a module machines run passes the gate whole, in either order of its heads, and says the
|
||||
// module moved (novox/hq ADR 0420 §6, issue 1025); a head alone is still refused. Needs the test store.
|
||||
func TestADeliveryGroupMovingAModuleMachinesRunPassesTheGate(t *testing.T) {
|
||||
f, manifests := catalogueMeshWithAnApp(t)
|
||||
catTree := aTree(t, withEdit(manifests, "album", ""))
|
||||
app := t.TempDir()
|
||||
// The app module is built from this repository's root, and stays there: only album moves.
|
||||
if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(app, "modules", "album"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(app, "modules", "album", "module.json"), []byte(manifests["album"]), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removes := groupChange{Repository: "novox/mesh-catalog", Tree: catTree, Changed: []string{"modules/album/module.json"}}
|
||||
defines := groupChange{Repository: "novox/app", Tree: app, Changed: []string{"modules/album/module.json"}}
|
||||
judge := func(first groupChange, rest ...groupChange) mergeVerdict {
|
||||
v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"),
|
||||
repository: first.Repository, tree: first.Tree, changed: first.Changed, group: rest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
for _, v := range []mergeVerdict{judge(removes, defines), judge(defines, removes)} {
|
||||
if v.Verdict == "fail" || v.Modules["album"] != "moved" {
|
||||
t.Fatalf("a group moving a module machines run: %v\n%s", v.Modules, v.Report())
|
||||
}
|
||||
}
|
||||
if v := judge(defines); v.Verdict != "fail" {
|
||||
t.Errorf("a definition of a module another repository builds passed alone:\n%s", v.Report())
|
||||
}
|
||||
if v := judge(removes); v.Verdict != "fail" {
|
||||
t.Errorf("the removal of a module machines run passed alone:\n%s", v.Report())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The operator's Approve of a delivery group registers a moved module from its new repository (novox/hq ADR 0420
|
||||
// §7, issue 1025). ADR 0266 §44 lets a build's outcome register a known module only from the repository the
|
||||
// catalogue already builds it from. The one exception was a build asked at the controller's terminal. This is a
|
||||
// second, for a move: mesh-delivery announces each merge it makes with the ask whose Approve it merged on; the
|
||||
// controller keeps the announcement, and when the build of a merge of a known module comes from another repository
|
||||
// it reads that ask's warrant from the router and accepts only the operator's Approve of the merged head. A verb
|
||||
// alone, or a merge with no such warrant, never moves a module.
|
||||
|
||||
// theDelivery is the module that merges, and whose asks these are.
|
||||
const theDelivery = "mesh-delivery"
|
||||
|
||||
// optionApprove is the option of mesh-delivery's ask that merges (its OptionApprove).
|
||||
const optionApprove = "approve"
|
||||
|
||||
// deliveryMerge is mesh-delivery's announcement of a merge it made: the pull request and the head it merged, the
|
||||
// group it belongs to, and the ask whose answer it merged on with the option chosen and the heads that answer was
|
||||
// bound to (delivery id → commit).
|
||||
type deliveryMerge struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Number int `json:"number"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
Group string `json:"group,omitempty"`
|
||||
AskID string `json:"ask_id"`
|
||||
Option string `json:"option"`
|
||||
Heads map[string]string `json:"heads"`
|
||||
}
|
||||
|
||||
// parseDeliveryMerge reads an announcement, refusing one that names no pull request, head or ask.
|
||||
func parseDeliveryMerge(body []byte) (deliveryMerge, error) {
|
||||
var d deliveryMerge
|
||||
if err := json.Unmarshal(body, &d); err != nil {
|
||||
return d, fmt.Errorf("mesh-delivery's word on a merge cannot be read: %w", err)
|
||||
}
|
||||
if d.Owner == "" || d.Repo == "" || d.Number <= 0 || d.HeadSHA == "" || d.AskID == "" || d.Option == "" {
|
||||
return d, errors.New("mesh-delivery's word on a merge names no pull request, head, ask or option")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// deliveryMergeKey is where a merge is kept: bus keys allow no `#` or `@`, so the pull request and its head are
|
||||
// path-like tokens, as the forge spells the repository.
|
||||
func deliveryMergeKey(owner, repo string, number int, head string) string {
|
||||
return fmt.Sprintf("%s/%s/%d/%s", owner, repo, number, head)
|
||||
}
|
||||
|
||||
// mergeAct is what mesh-delivery's answer binds, spelled exactly as mesh-delivery spells it (its approval.go:
|
||||
// mergeAct and headsString): this ask, the option and the heads it covers.
|
||||
func mergeAct(ask, option string, heads map[string]string) asks.Act {
|
||||
var out []string
|
||||
for id, sha := range heads {
|
||||
out = append(out, id+"="+sha)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return asks.Act{"verb": theDelivery + ".merge", "ask": ask, "option": option, "heads": strings.Join(out, ",")}
|
||||
}
|
||||
|
||||
// movedOnApprove judges the warrant of the ask a merge was made on: it holds only if it is the operator's Approve,
|
||||
// proved by the channel it came through, of this very ask as it was published (digest), bound to the heads the
|
||||
// announcement names, and those heads hold the head merged. Answers why it holds, or why not.
|
||||
func movedOnApprove(d deliveryMerge, ask *asks.Ask, w *asks.Warrant) (string, error) {
|
||||
switch {
|
||||
case ask == nil || w == nil:
|
||||
return "", errors.New("the router holds no closed record of the ask the merge was made on")
|
||||
case ask.Who != asks.Operator:
|
||||
return "", fmt.Errorf("the ask %s is for %q to answer, not the operator", ask.ID, ask.Who)
|
||||
case d.Option != optionApprove:
|
||||
return "", fmt.Errorf("the merge was made on the option %q, not an Approve", d.Option)
|
||||
}
|
||||
option, err := w.For(theDelivery, *ask)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if option.ID != optionApprove || w.Option != optionApprove || d.Option != option.ID {
|
||||
return "", fmt.Errorf("the operator chose %q on the ask %s, not an Approve", w.Option, w.Ask)
|
||||
}
|
||||
if w.By == nil || w.By.Who != asks.Operator || w.By.Verified == "" {
|
||||
return "", fmt.Errorf("the ask %s was not answered by the operator on a channel that proves who answered", w.Ask)
|
||||
}
|
||||
if err := option.Performs(mergeAct(d.AskID, option.ID, d.Heads)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
named := false
|
||||
for _, sha := range d.Heads {
|
||||
named = named || sha == d.HeadSHA
|
||||
}
|
||||
if !named {
|
||||
return "", fmt.Errorf("the Approve covers %v, and the head merged, %.8s, is not among them", d.Heads, d.HeadSHA)
|
||||
}
|
||||
return w.Says() + " (ask " + w.Ask + ")", nil
|
||||
}
|
||||
|
||||
// ---- keeping what mesh-delivery announced ----------------------------------------------------------------------
|
||||
|
||||
// keepingDeliveryMerges keeps each merge mesh-delivery announces in the controller's bucket for them.
|
||||
type keepingDeliveryMerges struct {
|
||||
js func() *broker.JetStream
|
||||
}
|
||||
|
||||
func (k keepingDeliveryMerges) KeepDeliveryMerge(ctx context.Context, body []byte) error {
|
||||
d, err := parseDeliveryMerge(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
js := k.js()
|
||||
if js == nil {
|
||||
return fmt.Errorf("%w: the bus is not held", link.ErrTryAgain)
|
||||
}
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", link.ErrTryAgain, err)
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, broker.DeliveryMergesBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: the bucket of mesh-delivery's merges: %w", link.ErrTryAgain, err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, deliveryMergeKey(d.Owner, d.Repo, d.Number, d.HeadSHA), body); err != nil {
|
||||
return fmt.Errorf("%w: %w", link.ErrTryAgain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keptDeliveryMerge is the merge mesh-delivery announced for a pull request's head; false when none was kept.
|
||||
// A variable so a test can hand it one.
|
||||
var keptDeliveryMerge = func(ctx context.Context, owner, repo string, number int, head string) (deliveryMerge, bool, error) {
|
||||
serving := servingBus.Load()
|
||||
if serving == nil {
|
||||
return deliveryMerge{}, false, errors.New("only the serving controller keeps what mesh-delivery announced")
|
||||
}
|
||||
api, err := jetstream.New(serving.Conn())
|
||||
if err != nil {
|
||||
return deliveryMerge{}, false, err
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, broker.DeliveryMergesBucket)
|
||||
if err != nil {
|
||||
return deliveryMerge{}, false, err
|
||||
}
|
||||
e, err := kv.Get(ctx, deliveryMergeKey(owner, repo, number, head))
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return deliveryMerge{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return deliveryMerge{}, false, err
|
||||
}
|
||||
d, err := parseDeliveryMerge(e.Value())
|
||||
return d, err == nil, err
|
||||
}
|
||||
|
||||
// routerAskOf is the router's record of one of mesh-delivery's asks: the ask as published and its warrant. A
|
||||
// variable so a test can hand it one.
|
||||
var routerAskOf = func(ctx context.Context, inv *inventory.Inventory, id string) (*asks.Ask, *asks.Warrant, error) {
|
||||
serving := servingBus.Load()
|
||||
if serving == nil {
|
||||
return nil, nil, errors.New("only the serving controller reads the router's record")
|
||||
}
|
||||
bucket, err := asksRecords(ctx, inv)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if bucket == "" {
|
||||
return nil, nil, errors.New("no module declares the operator channel's records")
|
||||
}
|
||||
_, ask, w, err := readRouterEntry(ctx, serving.Conn(), bucket, theDelivery, id)
|
||||
return ask, w, err
|
||||
}
|
||||
|
||||
// readRouterEntry reads the router's record of one asker's ask: its state, the ask as published, and its warrant;
|
||||
// "" when there is none. The controller's grant reaches the JetStream API whole (`$JS.API.>`), so it reads any
|
||||
// asker's record.
|
||||
func readRouterEntry(ctx context.Context, conn *nats.Conn, bucket, asker, ask string) (string, *asks.Ask, *asks.Warrant, error) {
|
||||
reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+asker+"."+ask, nil)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
if status := reply.Header.Get("Status"); status != "" {
|
||||
if status == "404" {
|
||||
return "", nil, nil, nil
|
||||
}
|
||||
return "", nil, nil, fmt.Errorf("the router's record could not be read: %s %s", status, reply.Header.Get("Description"))
|
||||
}
|
||||
var rec struct {
|
||||
State string `json:"state"`
|
||||
Ask *asks.Ask `json:"ask"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
}
|
||||
if err := json.Unmarshal(reply.Data, &rec); err != nil {
|
||||
return "", nil, nil, fmt.Errorf("the router's record of %s's ask %s cannot be read: %w", asker, ask, err)
|
||||
}
|
||||
return rec.State, rec.Ask, rec.Warrant, nil
|
||||
}
|
||||
|
||||
// errNotAMerge says a build was not asked for a merge, so there is no move to look for.
|
||||
var errNotAMerge = errors.New("the build is not of a merge")
|
||||
|
||||
// approvedMove says whether a build of a known module from another repository is the move the operator approved
|
||||
// (ADR 0420 §7): the build is of a merge, that merge is a pull request mesh-delivery announced it made, and the
|
||||
// ask it made it on holds an Approve by the operator of that head. Answers why it was accepted; an error says why
|
||||
// it was not, and the refusal of ADR 0266 stands.
|
||||
func approvedMove(ctx context.Context, inv *inventory.Inventory, forms *sourceForms, buildID string,
|
||||
built inventory.Source) (string, error) {
|
||||
r, found, err := inv.BuildRequestByID(ctx, buildID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !found || r.For != "merge" || r.Commit == "" {
|
||||
return "", errNotAMerge
|
||||
}
|
||||
owner, name, onForge := forms.onTheForge(built.Repository, built.Seat)
|
||||
if !onForge {
|
||||
return "", errors.New("the repository is not on the mesh's forge")
|
||||
}
|
||||
merged, known, err := inv.MergeOf(ctx, owner+"/"+name, r.Commit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !known {
|
||||
return "", fmt.Errorf("the controller kept no merge of %s/%s at %.8s", owner, name, r.Commit)
|
||||
}
|
||||
k := announcedOf(merged)
|
||||
if k.Number <= 0 || k.HeadSHA == "" {
|
||||
return "", errors.New("the forge's announcement of the merge names no pull request or head")
|
||||
}
|
||||
d, kept, err := keptDeliveryMerge(ctx, owner, name, k.Number, k.HeadSHA)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !kept {
|
||||
return "", fmt.Errorf("mesh-delivery announced no merge of %s/%s#%d at %.8s", owner, name, k.Number, k.HeadSHA)
|
||||
}
|
||||
ask, w, err := routerAskOf(ctx, inv, d.AskID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
why, err := movedOnApprove(d, ask, w)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s/%s#%d merged at %.8s on %s", owner, name, k.Number, k.HeadSHA, why), nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// The move whole, against a real bus and the store (novox/hq ADR 0420 §7, issue 1025): mesh-delivery's word that it
|
||||
// merged a pull request is published on the subject the controller consumes and kept in its bucket; the router's
|
||||
// record of the ask is read back from the router's bucket by a direct get; and the build of a module from the
|
||||
// repository that merge was made in is registered from there only when that record holds the operator's Approve of
|
||||
// the merged head. Store-backed: skips without one.
|
||||
func TestAModuleBuiltFromANewRepositoryIsRegisteredOnlyOnTheOperatorsApproveOnTheBus(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
answer bool // the router holds the operator's Approve of the merged heads
|
||||
moved bool
|
||||
}{
|
||||
{what: "the router holds the operator's Approve", answer: true, moved: true},
|
||||
{what: "the router holds no record of the ask"},
|
||||
} {
|
||||
t.Run(c.what, func(t *testing.T) {
|
||||
open := theCatalogue(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
// The module declaring the router's records, as the controller finds them.
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "operator-channel", Version: "1",
|
||||
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.AsksSeat, Records: []string{"asks"}}}},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/operator-channel", BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
js, err := broker.Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureControllerBuckets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api, err := jetstream.New(js.Conn())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := api.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: broker.BucketName("operator-channel", "asks")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := approvedMerge(t)
|
||||
a.merge.Owner, a.merge.Repo, a.merge.Number = "novox", "mesh-host", 7
|
||||
if c.answer {
|
||||
record, _ := json.Marshal(map[string]any{"state": "closed", "ask": a.ask, "warrant": a.warrant})
|
||||
if _, err := records.Put(ctx, theDelivery+"."+a.merge.AskID, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
server := link.ConnectNats(js, nil, noListener{})
|
||||
if err := server.KeepsDeliveryMerges(keepingDeliveryMerges{js: server.JetStream}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The events consumer exists, from now on, before the word is published: Receive would make it too late.
|
||||
if err := broker.AssertServingConsumers(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serving, stop := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); _ = server.Serve(serving) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
servingBus.Store(js)
|
||||
t.Cleanup(func() { servingBus.Store(nil) })
|
||||
|
||||
// mesh-delivery says it merged the pull request, and the controller keeps what it said.
|
||||
body, _ := json.Marshal(a.merge)
|
||||
if _, err := js.Context().Publish(broker.DeliveryMergedSubject, body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
becomes(t, 15*time.Second, "the merge being kept", func() bool {
|
||||
_, kept, err := keptDeliveryMerge(ctx, "novox", "mesh-host", 7, a.merge.HeadSHA)
|
||||
return err == nil && kept
|
||||
})
|
||||
|
||||
// The forge's merge of that pull request, as the controller heard it, and the build it asked for.
|
||||
const commit = "c0ffee0123456789"
|
||||
event, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-host", "base": "main", "number": 7,
|
||||
"head_sha": a.merge.HeadSHA, "merge_commit_sha": commit})
|
||||
if _, err := inv.AddMerge(ctx, inventory.BatchedMerge{Repository: "novox/mesh-host", Branch: "main", Commit: commit,
|
||||
Merged: time.Now(), Heard: time.Now(), Event: event}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := fmt.Sprintf("build-%d", time.Now().Add(time.Hour).UnixNano())
|
||||
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-host", Seat: "git",
|
||||
Path: "modules/sudo", Ref: "main", Commit: commit, For: "merge"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved := onTrunk(id, "novox/mesh-host", "git", "modules/sudo", map[string]any{"module": "sudo", "version": "2"})
|
||||
moved.Commit = commit
|
||||
_, _, err = takeIn(ctx, inv, moved)
|
||||
src, _ := inv.SourceOf(ctx, "sudo")
|
||||
if c.moved && (err != nil || src.Repository != "novox/mesh-host") {
|
||||
t.Fatalf("the Approved move was not registered from its new repository: %v (built from %q)", err, src.Repository)
|
||||
}
|
||||
if !c.moved && (!errors.Is(err, errNotItsSource) || src.Repository != "novox/mesh-catalog") {
|
||||
t.Fatalf("a move with no Approve was not refused as it is: %v (built from %q)", err, src.Repository)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// A merge mesh-delivery made, with the ask whose answer it merged on: the heads that answer was bound to, the
|
||||
// ask as the router published it, and the warrant the operator's Approve left (novox/hq ADR 0420 §7, issue 1025).
|
||||
type anApprovedMerge struct {
|
||||
merge deliveryMerge
|
||||
ask asks.Ask
|
||||
warrant asks.Warrant
|
||||
}
|
||||
|
||||
func approvedMerge(t *testing.T) anApprovedMerge {
|
||||
t.Helper()
|
||||
heads := map[string]string{"novox/mesh-controller@aaaaaaaaaaaa": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
"novox/mesh-catalog@bbbbbbbbbbbb": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}
|
||||
m := deliveryMerge{Owner: "novox", Repo: "mesh-controller", Number: 7, HeadSHA: "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1",
|
||||
Group: "g1", AskID: "merge-g1", Option: optionApprove, Heads: heads}
|
||||
bind := func(option string, h map[string]string) string {
|
||||
d, err := asks.ActDigest(mergeAct(m.AskID, option, h))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
at := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
ask := asks.Ask{ID: m.AskID, Headline: "Merge the group", Explanation: "Two pull requests move a module.", Who: asks.Operator,
|
||||
Expires: at.Add(time.Hour), OnExpiry: "asked again",
|
||||
Options: []asks.Option{
|
||||
{ID: optionApprove, Label: "Approve", Does: "merges the group", Level: asks.Approve, Binds: bind(optionApprove, heads)},
|
||||
{ID: "decline", Label: "Decline", Does: "leaves it", Level: asks.Approve, Binds: bind("decline", heads)}}}
|
||||
w := asks.Warrant{Ask: m.AskID, Asker: theDelivery, Outcome: asks.OutcomeChosen, Option: optionApprove, Label: "Approve",
|
||||
Level: asks.Approve, By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "1", Verified: "user id"},
|
||||
At: at, AskDigest: ask.Digest()}
|
||||
return anApprovedMerge{merge: m, ask: ask, warrant: w}
|
||||
}
|
||||
|
||||
// The operator's Approve of the merged head holds, and every other answer does not: a Decline, a person who is not the
|
||||
// operator or not proved by their channel, a warrant for other heads or another ask than the router published, the
|
||||
// merged head outside the heads the Approve covered, and a router that holds nothing.
|
||||
func TestAMovesWarrantHoldsOnlyForTheOperatorsApproveOfTheMergedHead(t *testing.T) {
|
||||
ok := approvedMerge(t)
|
||||
if _, err := movedOnApprove(ok.merge, &ok.ask, &ok.warrant); err != nil {
|
||||
t.Fatalf("the operator's Approve of the merged heads was refused: %v", err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
change func(*anApprovedMerge)
|
||||
}{
|
||||
{"a Decline", func(a *anApprovedMerge) {
|
||||
a.warrant.Option, a.warrant.Label = "decline", "Decline"
|
||||
a.merge.Option = "decline"
|
||||
}},
|
||||
{"no person chose", func(a *anApprovedMerge) { a.warrant.Outcome = asks.OutcomeExpired }},
|
||||
{"a person who is not the operator", func(a *anApprovedMerge) { a.warrant.By.Who = "agent" }},
|
||||
{"a channel that proves nobody", func(a *anApprovedMerge) { a.warrant.By.Verified = "" }},
|
||||
{"other heads than the ask was bound to", func(a *anApprovedMerge) {
|
||||
a.merge.Heads = map[string]string{"novox/mesh-controller@aaaaaaaaaaaa": "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3"}
|
||||
}},
|
||||
{"a head merged that the Approve did not cover", func(a *anApprovedMerge) { a.merge.HeadSHA = "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" }},
|
||||
{"another ask than the one the warrant answers", func(a *anApprovedMerge) { a.ask.Headline = "Something else" }},
|
||||
{"a warrant of another asker", func(a *anApprovedMerge) { a.warrant.Asker = "claude-code" }},
|
||||
{"an ask for no operator to answer", func(a *anApprovedMerge) { a.ask.Who = "agent" }},
|
||||
{"an Approve given after the ask expired", func(a *anApprovedMerge) { a.warrant.At = a.ask.Expires.Add(time.Minute) }},
|
||||
} {
|
||||
a := approvedMerge(t)
|
||||
c.change(&a)
|
||||
if why, err := movedOnApprove(a.merge, &a.ask, &a.warrant); err == nil {
|
||||
t.Errorf("%s was accepted: %s", c.what, why)
|
||||
}
|
||||
}
|
||||
if _, err := movedOnApprove(ok.merge, nil, &ok.warrant); err == nil {
|
||||
t.Error("a warrant with no ask to check it against was accepted")
|
||||
}
|
||||
if _, err := movedOnApprove(ok.merge, &ok.ask, nil); err == nil {
|
||||
t.Error("an ask with no warrant was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// mesh-delivery's word is read whole or refused, and kept under a key the bus allows.
|
||||
func TestADeliveryMergesWordIsReadWholeAndKeptUnderABusKey(t *testing.T) {
|
||||
body := `{"owner":"novox","repo":"mesh-controller","number":7,"head_sha":"abc","group":"g","ask_id":"merge-g",` +
|
||||
`"option":"approve","heads":{"novox/mesh-controller@abc":"abc"}}`
|
||||
d, err := parseDeliveryMerge([]byte(body))
|
||||
if err != nil || d.Number != 7 || d.Heads["novox/mesh-controller@abc"] != "abc" {
|
||||
t.Fatalf("read %+v, %v", d, err)
|
||||
}
|
||||
if key := deliveryMergeKey(d.Owner, d.Repo, d.Number, d.HeadSHA); key != "novox/mesh-controller/7/abc" ||
|
||||
strings.ContainsAny(key, "#@") {
|
||||
t.Errorf("key %q", key)
|
||||
}
|
||||
for _, bad := range []string{`{`, `{"owner":"novox","repo":"r","number":0,"head_sha":"a","ask_id":"x","option":"approve"}`,
|
||||
`{"owner":"novox","repo":"r","number":1,"head_sha":"a","option":"approve"}`} {
|
||||
if _, err := parseDeliveryMerge([]byte(bad)); err == nil {
|
||||
t.Errorf("%s was read", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,3 +680,35 @@ func TestAPlanSaysWhyEachModuleIsInIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A context naming the module's own repository is the module's own build source** (novox/hq ADR 0420 §4,
|
||||
// issue 1025). A module registered from the controller's repository whose image compiles cmd/x through a
|
||||
// context naming that same repository is moved by a change to the program, and by none outside its closure.
|
||||
func TestAContextNamingTheModulesOwnRepositoryIsItsOwnBuildSource(t *testing.T) {
|
||||
const controllerRepo = "http://forge.internal:20000/novox/mesh-controller.git"
|
||||
entries := []inventory.Entry{fromRepo("mover", controllerRepo, "modules/mover")}
|
||||
said := []inventory.ReadRepository{
|
||||
{Repository: "novox/mesh-controller", Ref: "main", Paths: []string{"go.mod", "cmd/x/", "internal/y/"}},
|
||||
{Own: true, Paths: []string{"modules/mover/module.json"}},
|
||||
}
|
||||
read := map[string][]inventory.ReadRepository{"mover": inventory.OwnRepositoryAsOwn(said, controllerRepo)}
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
{"the program its image compiles", []string{"cmd/x/main.go"}, "mover"},
|
||||
{"a package the program imports", []string{"internal/y/y.go"}, "mover"},
|
||||
{"its own manifest", []string{"modules/mover/module.json"}, "mover"},
|
||||
{"a file outside its closure", []string{"cmd/other/main.go"}, ""},
|
||||
} {
|
||||
if _, got := planMerge(t, "mesh-controller", c.paths, entries, read, nil); got != c.want {
|
||||
t.Errorf("%s: planned %q, wanted %q", c.what, got, c.want)
|
||||
}
|
||||
}
|
||||
// A context of another repository stays a context read, and a context of this one that said no paths too.
|
||||
other := []inventory.ReadRepository{{Repository: "novox/mesh-catalog", Paths: []string{"a/"}}, {Repository: "novox/mesh-controller"}}
|
||||
if got := inventory.OwnRepositoryAsOwn(other, controllerRepo); len(got) != 2 || got[0].Own || got[1].Own {
|
||||
t.Errorf("a context that is not this repository's with paths became its own: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,11 @@ func serve(ctx context.Context) (err error) {
|
||||
if err := server.Follows(following{open: open}); err != nil {
|
||||
return err
|
||||
}
|
||||
// And the merges mesh-delivery made, kept a week with the ask each was made on: what registers a module that
|
||||
// moved between repositories on the operator's Approve (novox/hq ADR 0420 §7, issue 1025).
|
||||
if err := server.KeepsDeliveryMerges(keepingDeliveryMerges{js: server.JetStream}); err != nil {
|
||||
return err
|
||||
}
|
||||
// No timer reads the merges back (novox/hq ADR 0407 decision 3, issue 973): the forge's event and the durable
|
||||
// consumer on the bus keep every merge, and one the controller cannot record yet is held and handed over again.
|
||||
// And a catalogue that has just started, asking for what it missed. The same type answers
|
||||
|
||||
@@ -15,7 +15,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -64,24 +63,8 @@ func warrantedAct(asker, ask, caller, state string, w *asks.Warrant) (link.HandA
|
||||
// readRouterRecord reads the router's record of one asker's ask: its state and warrant, or "" when there is none.
|
||||
// The controller's grant reaches the JetStream API whole (`$JS.API.>`), so it reads any asker's record.
|
||||
func readRouterRecord(ctx context.Context, conn *nats.Conn, bucket, asker, ask string) (string, *asks.Warrant, error) {
|
||||
reply, err := conn.RequestWithContext(ctx, "$JS.API.DIRECT.GET.KV_"+bucket+".$KV."+bucket+"."+asker+"."+ask, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if status := reply.Header.Get("Status"); status != "" {
|
||||
if status == "404" {
|
||||
return "", nil, nil
|
||||
}
|
||||
return "", nil, fmt.Errorf("the router's record could not be read: %s %s", status, reply.Header.Get("Description"))
|
||||
}
|
||||
var rec struct {
|
||||
State string `json:"state"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
}
|
||||
if err := json.Unmarshal(reply.Data, &rec); err != nil {
|
||||
return "", nil, fmt.Errorf("the router's record of %s's ask %s cannot be read: %w", asker, ask, err)
|
||||
}
|
||||
return rec.State, rec.Warrant, nil
|
||||
state, _, w, err := readRouterEntry(ctx, conn, bucket, asker, ask)
|
||||
return state, w, err
|
||||
}
|
||||
|
||||
func handActWarrantCommand(ctx context.Context, args []string) error {
|
||||
|
||||
@@ -56,8 +56,17 @@ var (
|
||||
// waiting request for a head and trunk commit a key names drops it. **Written by the build seat's holders, several by
|
||||
// design** — each the key of the check it runs.
|
||||
CheckRunningBucket = BucketName(ControllerSeat, "checks-running")
|
||||
// DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged
|
||||
// on (novox/hq ADR 0420 §7, issue 1025): per merge a key `<owner>/<repo>/<number>/<head sha>` holding the
|
||||
// announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new
|
||||
// repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of
|
||||
// those heads. Written by the controller alone, from the announcement, when it holds the lease.
|
||||
DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges")
|
||||
)
|
||||
|
||||
// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are.
|
||||
const DeliveryMergesKeptFor = 7 * 24 * time.Hour
|
||||
|
||||
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
|
||||
// record, one key per record, written from the store by the lease holder only when the record changed, with
|
||||
// one event on the controller's seat per write. Read by whatever names them in `reads` as
|
||||
@@ -141,13 +150,15 @@ func IsControllerBucket(bucket string) bool {
|
||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
|
||||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
|
||||
bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket
|
||||
bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket ||
|
||||
bucket == DeliveryMergesBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket}
|
||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket,
|
||||
DeliveryMergesBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
@@ -290,6 +301,20 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", CheckRunningBucket, err)
|
||||
}
|
||||
// **The merges mesh-delivery announced: keys that age** (ADR 0420 §7, issue 1025). One message a key; a week.
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: DeliveryMergesBucket,
|
||||
Description: "the merges mesh-delivery announced it made, each with the ask whose Approve it merged on (novox/hq " +
|
||||
"ADR 0420): `<owner>/<repo>/<number>/<head sha>` holds the announcement, kept a week; what lets the build " +
|
||||
"of a moved module register it from its new repository; written by the controller alone",
|
||||
History: 1,
|
||||
TTL: DeliveryMergesKeptFor,
|
||||
MaxValueSize: 16 << 10,
|
||||
MaxBytes: 16 << 20,
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", DeliveryMergesBucket, err)
|
||||
}
|
||||
// **The record mirrors: no age** (design 54 §5). A key is removed when its record is, by a purge whose
|
||||
// marker lives a day; a key that expired would be a record the bus forgot while the store still holds it.
|
||||
for _, m := range []struct{ bucket, why string }{
|
||||
|
||||
@@ -397,6 +397,11 @@ var DegradableGrants = []DegradableGrant{
|
||||
Reason: "refused: the controller says a tier sent to a machine only for outsiders, and a send that could not " +
|
||||
"be said is printed and goes on (ADR 0406 §5, issue 974)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + DeliveryMergesBucket + ".>",
|
||||
Reason: "refused: the controller then keeps no merge mesh-delivery announced, so the build of a moved module is " +
|
||||
"refused as it is without the operator's Approve — failing closed (ADR 0420 §7, issue 1025)",
|
||||
},
|
||||
{
|
||||
Subject: "$KV." + RolloutSilentBucket + ".>",
|
||||
Reason: "refused: the controller looks at the plans when a wait for a machine's report is up, by a timer of its own " +
|
||||
|
||||
@@ -242,7 +242,7 @@ func eventsTheControllerFollows() []string {
|
||||
out = append(out, subject)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return append(out, ControllerHears...)
|
||||
}
|
||||
|
||||
// DeliverSubjectFor is where a push consumer's messages land.
|
||||
@@ -428,6 +428,19 @@ var ControllerFollows = []string{
|
||||
DecidedSubject,
|
||||
}
|
||||
|
||||
// ControllerHears are events the controller's events consumer takes beside the ones it follows, and that need no
|
||||
// subscribe grant of their own. **A push consumer delivers onto its deliver subject** (`_DELIVER.controller.>`, already
|
||||
// granted), so the subject a consumer filters on is never one the controller subscribes: listing this one in
|
||||
// ControllerFollows would add a subscribe grant the installer's carried user list, which lives in mesh-host, would
|
||||
// have to carry too (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), for nothing the consumer uses.
|
||||
//
|
||||
// A merge mesh-delivery made, announced (novox/hq ADR 0420 §7, issue 1025): with the ask whose Approve it merged on and
|
||||
// the heads that Approve covered, kept a week to read the warrant a moved module's registration needs.
|
||||
var ControllerHears = []string{DeliveryMergedSubject}
|
||||
|
||||
// DeliveryMergedSubject is where mesh-delivery says it merged a pull request.
|
||||
var DeliveryMergedSubject = moduleEventSubject("mesh-delivery", "merged")
|
||||
|
||||
// AsksSeat is the seat an ask is made on and its warrant heard from (novox/hq ADR 0259): the router's.
|
||||
const AsksSeat = "operator-channel"
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
|
||||
@@ -130,8 +130,11 @@ var WritersTable = []WriterRow{
|
||||
// written by the lease holder.
|
||||
{State: "plans and their tiers", Writer: "controller (lease holder), compare-and-set on the plan's revision",
|
||||
KeptIn: "the controller's store; a batch's merge window in key-value " + MergeWindowBucket +
|
||||
", a rollout's wait for a machine's report in key-value " + RolloutSilentBucket, Others: "read through plans",
|
||||
Subjects: append(kvOf(MergeWindowBucket), kvOf(RolloutSilentBucket)...), Writes: isController},
|
||||
", a rollout's wait for a machine's report in key-value " + RolloutSilentBucket +
|
||||
", and the merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), in key-value " +
|
||||
DeliveryMergesBucket, Others: "read through plans",
|
||||
Subjects: append(append(kvOf(MergeWindowBucket), kvOf(RolloutSilentBucket)...), kvOf(DeliveryMergesBucket)...),
|
||||
Writes: isController},
|
||||
{State: "conditions", Writer: "controller", KeptIn: "key-value " + ConditionsBucket + " (and its history, " +
|
||||
ConditionHistoryBucket + "); how often each was observed, when last and its newest evidence in the " +
|
||||
"controller's store, so a look that changes nothing writes no bucket", Others: "raise or clear only through observations the controller reads",
|
||||
|
||||
@@ -428,7 +428,8 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
|
||||
}
|
||||
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select distinct on (module) module, built_contexts, build_sources, coalesce(asked, at)
|
||||
`select distinct on (module) module, built_contexts, build_sources, coalesce(asked, at),
|
||||
(select source from module where module.name = build.module)
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, `+newestRequestFirst)
|
||||
@@ -442,7 +443,8 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
|
||||
var module string
|
||||
var raw, rawSources []byte
|
||||
var built time.Time
|
||||
if err := rows.Scan(&module, &raw, &rawSources, &built); err != nil {
|
||||
var own *string
|
||||
if err := rows.Scan(&module, &raw, &rawSources, &built, &own); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var of []ReadRepository
|
||||
@@ -462,6 +464,9 @@ func (i *Inventory) ReadRepositories(ctx context.Context) (map[string][]ReadRepo
|
||||
sources, whole = nil, "a newer build of it failed"
|
||||
}
|
||||
of = WithBuildSources(of, sources)
|
||||
if own != nil {
|
||||
of = OwnRepositoryAsOwn(of, *own)
|
||||
}
|
||||
if whole != "" {
|
||||
of = append(of, ReadRepository{Own: true, Whole: whole})
|
||||
}
|
||||
@@ -498,6 +503,57 @@ func WithBuildSources(read []ReadRepository, sources []BuildSource) []ReadReposi
|
||||
return out
|
||||
}
|
||||
|
||||
// OwnRepositoryAsOwn reads a context that names the module's own registered repository, with the paths its
|
||||
// build read there, as the module's own build source (novox/hq ADR 0420 §4): a change to the program it
|
||||
// compiles moves the module, as one in its own directory does. A context of that repository that said
|
||||
// no paths stays a context read, whole.
|
||||
func OwnRepositoryAsOwn(read []ReadRepository, repository string) []ReadRepository {
|
||||
if repository == "" {
|
||||
return read
|
||||
}
|
||||
out := make([]ReadRepository, 0, len(read))
|
||||
var moved []ReadRepository
|
||||
for _, r := range read {
|
||||
if !r.Own && len(r.Paths) > 0 && r.Repository != "" && namesRepository(repository, r.Repository) {
|
||||
moved = append(moved, r)
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if len(moved) == 0 {
|
||||
return read
|
||||
}
|
||||
// One entry of its own: ownSource reads the first that has paths.
|
||||
at := -1
|
||||
for k := range out {
|
||||
if out[k].Own && len(out[k].Paths) > 0 {
|
||||
at = k
|
||||
break
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
out = append(out, ReadRepository{Own: true})
|
||||
at = len(out) - 1
|
||||
}
|
||||
for _, r := range moved {
|
||||
out[at].Paths = append(append([]string(nil), out[at].Paths...), r.Paths...)
|
||||
if out[at].Ref == "" {
|
||||
out[at].Ref = r.Ref
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// namesRepository is whether a context's repository, as owner/name, is the registered one, which may be
|
||||
// spelled as the URL it was cloned from.
|
||||
func namesRepository(registered, named string) bool {
|
||||
trim := func(s string) string {
|
||||
return strings.TrimSuffix(strings.TrimRight(strings.ToLower(strings.TrimSpace(s)), "/"), ".git")
|
||||
}
|
||||
reg, name := trim(registered), trim(named)
|
||||
return reg == name || strings.HasSuffix(reg, "/"+name)
|
||||
}
|
||||
|
||||
// manifestOrNil keeps the difference between "declared nothing" and "predates this being kept".
|
||||
//
|
||||
// A build recorded before the mesh kept manifests has no manifest, and that is not the same as one
|
||||
|
||||
@@ -51,6 +51,8 @@ var Contracts = map[string]Contract{
|
||||
"at the router, and the controller acts on it once, recording that it did under the ask's id — so a word " +
|
||||
"heard again, or late, does nothing more (novox/hq ADR 0259)",
|
||||
Tests: []string{"TestAWarrantIsActedOnOnce"}},
|
||||
KindDeliveryMerged: {Unordered: "mesh-delivery's word that it merged a pull request, by the pull request and its head: " +
|
||||
"kept under that key once, so a word heard again or late rewrites the same value (novox/hq ADR 0420 §7)"},
|
||||
KindCatchUp: {Unordered: "a catalogue asking what it missed: answered from the record, whenever asked"},
|
||||
KindProvisioner: {Unordered: "a provider's newest word about a consumer, said again every fifteen minutes " +
|
||||
"while it holds (ADR 0224): the condition keeps the last observed, and S8 says when the words stop. " +
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestEveryConsumedKindHasAContract(t *testing.T) {
|
||||
subjects := []string{EnrolSubject, BuiltSubject, ReportSubject("anchor"), AliveSubject("anchor"),
|
||||
ToolsAliveSubject("anchor"), HealthSubject("anchor"), "mesh.mod.postgres.event.provisioner.failing"}
|
||||
subjects = append(subjects, broker.ControllerFollows...)
|
||||
subjects = append(subjects, broker.ControllerHears...)
|
||||
kinds := map[string]bool{}
|
||||
for _, s := range subjects {
|
||||
kind, known := kindOfSubject(s)
|
||||
|
||||
@@ -47,6 +47,9 @@ const (
|
||||
// KindDecided is the router's warrant for an ask the controller made, or that ask's end without one
|
||||
// (novox/hq ADR 0259): said to the controller alone, under its own name.
|
||||
KindDecided = "decided"
|
||||
// KindDeliveryMerged is mesh-delivery saying it merged a pull request, with the ask whose Approve it merged on
|
||||
// (novox/hq ADR 0420 §7, issue 1025).
|
||||
KindDeliveryMerged = "delivery-merged"
|
||||
)
|
||||
|
||||
// Control is one thing a node or a module said, as the controller must act on it.
|
||||
|
||||
@@ -62,7 +62,8 @@ func Nats(js *broker.JetStream) Inbound {
|
||||
// whatever was asked for — and not at all when nothing was.
|
||||
func (n *natsInbound) Also(kind string) error {
|
||||
switch kind {
|
||||
case KindModuleMoved, KindCatchUp, KindSourceMoved, KindProvisioner, KindPullUpdated, KindDecided:
|
||||
case KindModuleMoved, KindCatchUp, KindSourceMoved, KindProvisioner, KindPullUpdated, KindDecided,
|
||||
KindDeliveryMerged:
|
||||
n.follows[kind] = true
|
||||
return nil
|
||||
default:
|
||||
@@ -301,6 +302,8 @@ func kindOfSubject(subject string) (string, bool) {
|
||||
return KindPullUpdated, true
|
||||
case broker.DecidedSubject:
|
||||
return KindDecided, true
|
||||
case broker.DeliveryMergedSubject:
|
||||
return KindDeliveryMerged, true
|
||||
case BuildOutcome(), BuildOutcomeOf(TheBuildMachineBefore):
|
||||
// A build's outcome is the role's event now, so it arrives on the events stream rather than
|
||||
// the control branch — and is acted on by the same handler, because what the controller does
|
||||
|
||||
@@ -98,6 +98,8 @@ type Server struct {
|
||||
healths Healths
|
||||
// decider acts on the operator's warrants for what the controller asked (novox/hq ADR 0259).
|
||||
decider Decider
|
||||
// deliveryMerges keeps mesh-delivery's merges (KeepsDeliveryMerges).
|
||||
deliveryMerges DeliveryMergeKeeper
|
||||
|
||||
log *log.Logger
|
||||
// giveUp is how long one message is held for the store; zero means GiveUpAfter.
|
||||
@@ -155,6 +157,21 @@ func (s *Server) Decides(d Decider) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeliveryMergeKeeper is what the controller does with mesh-delivery's word that it merged a pull request (novox/hq
|
||||
// ADR 0420 §7): keep it, to be read when a moved module's build is taken in.
|
||||
type DeliveryMergeKeeper interface {
|
||||
KeepDeliveryMerge(ctx context.Context, body []byte) error
|
||||
}
|
||||
|
||||
// KeepsDeliveryMerges says what to do about mesh-delivery's merges, and asks for them to be delivered.
|
||||
func (s *Server) KeepsDeliveryMerges(k DeliveryMergeKeeper) error {
|
||||
if err := s.inbound.Also(KindDeliveryMerged); err != nil {
|
||||
return err
|
||||
}
|
||||
s.deliveryMerges = k
|
||||
return nil
|
||||
}
|
||||
|
||||
// Answers says what to do about a catalogue's catch-up request, and asks for them to be delivered.
|
||||
func (s *Server) Answers(r Replayer) error {
|
||||
if err := s.inbound.Also(KindCatchUp); err != nil {
|
||||
@@ -229,6 +246,8 @@ func (s *Server) act(ctx context.Context, m Control) {
|
||||
s.pullUpdated(ctx, m)
|
||||
case KindDecided:
|
||||
s.decided(ctx, m)
|
||||
case KindDeliveryMerged:
|
||||
s.deliveryMerged(ctx, m)
|
||||
default:
|
||||
// Dropped: a message nothing understands will not be understood on the next attempt
|
||||
// either, and asking for it again would spin.
|
||||
@@ -728,6 +747,24 @@ func (s *Server) decided(ctx context.Context, m Control) {
|
||||
_ = m.Took()
|
||||
}
|
||||
|
||||
// deliveryMerged hands mesh-delivery's word on a merge to its keeper; a failure to keep it is held for the store
|
||||
// like any word that must not be lost, and a word that cannot be read is said and let go.
|
||||
func (s *Server) deliveryMerged(ctx context.Context, m Control) {
|
||||
if s.deliveryMerges == nil {
|
||||
_ = m.Took()
|
||||
return
|
||||
}
|
||||
err := s.deliveryMerges.KeepDeliveryMerge(ctx, m.Body())
|
||||
switch s.decide(ctx, m, "mesh-delivery's word on a merge", "", "", err) {
|
||||
case Hold:
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Printf("mesh-delivery's word on a merge could not be kept: %v", err)
|
||||
}
|
||||
_ = m.Took()
|
||||
}
|
||||
|
||||
// saysWhatItDid states what a machine now runs, or what it would not take, as a fact on the bus
|
||||
// (novox/hq ADR 0134).
|
||||
//
|
||||
|
||||
+10
-1
@@ -36,6 +36,11 @@ judge_store_tests() {
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
}
|
||||
# Every Go module of its own below the root, vetted and tested (novox/hq ADR 0420 §5, issue 1025): `./...` stops at a
|
||||
# nested go.mod. Its own file, check-nested-modules.sh, which takes $cgo and $race from here.
|
||||
nested_modules() {
|
||||
cgo="$cgo" race="$race" sh ./check-nested-modules.sh
|
||||
}
|
||||
# `sh merge-check.sh judge-store-tests <package> <status> <output> <name>…` runs only the judging, to try it on a
|
||||
# recorded `go test -v` output.
|
||||
if [ "${1:-}" = judge-store-tests ]; then
|
||||
@@ -46,7 +51,10 @@ if [ "${1:-}" = judge-store-tests ]; then
|
||||
exit
|
||||
fi
|
||||
|
||||
unformatted=$(gofmt -l cmd internal examples)
|
||||
gofmt_dirs="cmd internal examples"
|
||||
[ -d modules ] && gofmt_dirs="$gofmt_dirs modules"
|
||||
# shellcheck disable=SC2086 # the directories are one word each, split on purpose
|
||||
unformatted=$(gofmt -l $gofmt_dirs)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "not gofmt'd:"
|
||||
echo "$unformatted"
|
||||
@@ -232,6 +240,7 @@ else
|
||||
cat "$work/rest.out"
|
||||
[ "$(cat "$work/rest.status")" -eq 0 ] || suite_failed=1
|
||||
say_shards || suite_failed=1
|
||||
nested_modules || suite_failed=1
|
||||
fi
|
||||
|
||||
if [ -z "$listed" ]; then
|
||||
|
||||
Vendored
+3
@@ -137,3 +137,6 @@
|
||||
./cmd/mesh-controller TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt
|
||||
./cmd/mesh-controller TestAWalkLetGoByTheDeliverVerbIsAskedWithNoOtherEvent
|
||||
./cmd/mesh-controller TestTwoMergesWithinAMinuteLeaveEachOpenPullRequestOneQueuedCheck
|
||||
./cmd/mesh-controller TestADeliveryGroupMovingAModuleMachinesRunPassesTheGate
|
||||
./cmd/mesh-controller TestAModuleMovesOnTheOperatorsApproveOfTheMergeThatMovedIt
|
||||
./cmd/mesh-controller TestAModuleBuiltFromANewRepositoryIsRegisteredOnlyOnTheOperatorsApproveOnTheBus
|
||||
|
||||
Reference in New Issue
Block a user