Compare commits
26
Commits
@@ -0,0 +1,6 @@
|
|||||||
|
# Release 57: config and provisioning
|
||||||
|
|
||||||
|
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||||
|
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||||
|
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||||
|
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||||
+19
-39
@@ -26,7 +26,6 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -273,12 +272,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
// role's own events under the build's id (novox/hq ADR 0157) — so whoever asked, and anybody
|
// role's own events under the build's id (novox/hq ADR 0157) — so whoever asked, and anybody
|
||||||
// watching, reads the same lines this container's log holds, live, and after the fact from the
|
// watching, reads the same lines this container's log holds, live, and after the fact from the
|
||||||
// stream. Said first, before anything runs, so a build that hangs is one that visibly started.
|
// stream. Said first, before anything runs, so a build that hangs is one that visibly started.
|
||||||
// One line at a time: a check's builds for its merge say their lines while its tests say theirs (novox/hq
|
|
||||||
// issue 716).
|
|
||||||
var saying sync.Mutex
|
|
||||||
say := func(step, message string) {
|
say := func(step, message string) {
|
||||||
saying.Lock()
|
|
||||||
defer saying.Unlock()
|
|
||||||
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
||||||
work.Say(step, message)
|
work.Say(step, message)
|
||||||
if mine != nil && step != "run" && step != "output" {
|
if mine != nil && step != "run" && step != "output" {
|
||||||
@@ -319,25 +313,20 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
registry = r.Address
|
registry = r.Address
|
||||||
}
|
}
|
||||||
spec := checkSpecOf(request)
|
spec := checkSpecOf(request)
|
||||||
// The judge cache the build agent declares as its own data, never in the workspace: only judge builds and
|
|
||||||
// mesh-lab's replays mount it, never a check's containers, so no pull request's script can write what a judge
|
|
||||||
// is built with or kept as, or what the replays run (novox/hq ADR 0358 §6, issue 709).
|
|
||||||
spec.JudgeCache = judgeCache()
|
|
||||||
// What the merge would move, built by the check for the walk to take (novox/hq ADR 0350 §1): into the
|
// What the merge would move, built by the check for the walk to take (novox/hq ADR 0350 §1): into the
|
||||||
// scratch namespace. **Never with the package registry's credential**: this is code nobody has approved
|
// scratch namespace. **Never with the package registry's credential**: this is code nobody has approved
|
||||||
// yet, and a recipe could read the credential it is handed (issue 462). A module that resolves packages
|
// yet, and a recipe could read the credential it is handed (issue 462). A module that resolves packages
|
||||||
// at build time has no reuse key anyway, so nothing is lost by refusing it here.
|
// at build time has no reuse key anyway, so nothing is lost by refusing it here.
|
||||||
if isRegistry && len(spec.Builds) > 0 {
|
if isRegistry && len(spec.Builds) > 0 {
|
||||||
spec.BuildWith = &builder.CheckBuilding{Publish: publisher, Scratch: registryHere, Held: request.Held,
|
spec.BuildWith = &builder.CheckBuilding{Publish: publisher, Scratch: registryHere, Held: request.Held,
|
||||||
Running: request.Check.Running, Seats: request.Seats, Named: namedOf(request.Check.Named)}
|
Seats: request.Seats}
|
||||||
}
|
}
|
||||||
var v builder.CheckVerdict
|
var v builder.CheckVerdict
|
||||||
v, err = builder.Check(building, builder.Command, spec, workspace, registry, forgeFrom(), say)
|
v, err = builder.Check(building, builder.Command, spec, workspace, registry, forgeFrom(), say)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
|
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
|
||||||
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
|
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
|
||||||
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built),
|
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built)}
|
||||||
Release: v.Release, JudgedBy: v.JudgedBy, BesideAt: v.BesideAt, Validator: v.Validator}
|
|
||||||
// What each of a group's heads was judged as (novox/hq ADR 0323 §6).
|
// What each of a group's heads was judged as (novox/hq ADR 0323 §6).
|
||||||
for _, m := range v.Members {
|
for _, m := range v.Members {
|
||||||
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
|
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
|
||||||
@@ -363,7 +352,15 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
reuse.Pulls = append(reuse.Pulls, builder.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number})
|
reuse.Pulls = append(reuse.Pulls, builder.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number})
|
||||||
}
|
}
|
||||||
// Only what a verdict names is taken (ADR 0350 §2).
|
// Only what a verdict names is taken (ADR 0350 §2).
|
||||||
reuse.Named = namedOf(request.Reuse.Builds)
|
for _, b := range request.Reuse.Builds {
|
||||||
|
named := builder.NamedBuild{Pull: builder.PullRef{Owner: b.Pull.Owner, Repo: b.Pull.Repo,
|
||||||
|
Number: b.Pull.Number}, Check: b.Check, Module: b.Built.Module, Key: b.Built.Key,
|
||||||
|
Digests: map[string]string{}}
|
||||||
|
for _, m := range b.Built.Made {
|
||||||
|
named.Digests[m.Name] = builder.DigestOf(m.Reference)
|
||||||
|
}
|
||||||
|
reuse.Named = append(reuse.Named, named)
|
||||||
|
}
|
||||||
built, err = builder.BuildReusing(building, builder.Labelled(builder.Command, request.ID), publisher, reuse,
|
built, err = builder.BuildReusing(building, builder.Labelled(builder.Command, request.ID), publisher, reuse,
|
||||||
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
|
||||||
forgeFrom(), say, request.Seats)
|
forgeFrom(), say, request.Seats)
|
||||||
@@ -427,6 +424,11 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
|
|||||||
for _, s := range built.Sources {
|
for _, s := range built.Sources {
|
||||||
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
result.Sources = append(result.Sources, link.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
||||||
}
|
}
|
||||||
|
// The list of predefined keys the manifest was judged against, for the controller to publish
|
||||||
|
// (novox/hq ADR 0348 §7).
|
||||||
|
if p := built.Predefined; p != nil {
|
||||||
|
result.Predefined = &link.PredefinedList{Keys: p.Keys, Generation: p.Generation}
|
||||||
|
}
|
||||||
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
say("built", built.Manifest.Module+" from "+short(built.Commit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,9 +469,9 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
|
|||||||
Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{},
|
Owner: c.Owner, Repo: c.Repo, Number: c.Number, Paths: c.Paths, Beside: map[string]builder.Beside{},
|
||||||
Modules: c.Modules, New: c.New, Manifests: c.Manifests, Judge: c.Judge, Base: c.Base,
|
Modules: c.Modules, New: c.New, Manifests: c.Manifests, Judge: c.Judge, Base: c.Base,
|
||||||
Toolchain: builder.ToolchainOf(request.Held), Toolchains: builder.ToolchainsOf(request.Held),
|
Toolchain: builder.ToolchainOf(request.Held), Toolchains: builder.ToolchainsOf(request.Held),
|
||||||
Tests: c.Tests, StoreOnly: c.StoreOnly, Release: c.Release}
|
Tests: c.Tests, StoreOnly: c.StoreOnly}
|
||||||
for dir, b := range c.Beside {
|
for dir, b := range c.Beside {
|
||||||
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref, Else: b.Else}
|
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
|
||||||
}
|
}
|
||||||
for _, b := range c.Builds {
|
for _, b := range c.Builds {
|
||||||
spec.Builds = append(spec.Builds, builder.CheckBuildSpec{Module: b.Module, Repository: b.Repository, Path: b.Path,
|
spec.Builds = append(spec.Builds, builder.CheckBuildSpec{Module: b.Module, Repository: b.Repository, Path: b.Path,
|
||||||
@@ -482,11 +484,6 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
|
|||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
// judgeCache is the judge cache the build agent's manifest declares (MESH_JUDGE_CACHE, novox/hq ADR 0358 §6): a
|
|
||||||
// directory mounted at the same path on the host, so a judge build's mounts name what the agent sees. Empty when the
|
|
||||||
// agent declares none, and every judge is then built cold under its check's root.
|
|
||||||
func judgeCache() string { return os.Getenv("MESH_JUDGE_CACHE") }
|
|
||||||
|
|
||||||
// triedOf is a try's outcome as its log line says it (novox/hq issue 686): the repository's own check's verdict,
|
// triedOf is a try's outcome as its log line says it (novox/hq issue 686): the repository's own check's verdict,
|
||||||
// or the check's where that did not run — a head that does not merge — and the lines naming what failed.
|
// or the check's where that did not run — a head that does not merge — and the lines naming what failed.
|
||||||
func triedOf(v builder.CheckVerdict) link.Tried {
|
func triedOf(v builder.CheckVerdict) link.Tried {
|
||||||
@@ -504,28 +501,11 @@ func triedOf(v builder.CheckVerdict) link.Tried {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// namedOf are the builds the seat's verdicts name, as the builder believes a scratch record by them (novox/hq ADR
|
|
||||||
// 0350 §2, issue 703): each module's key with every artifact's digest.
|
|
||||||
func namedOf(builds []link.CheckedBuild) []builder.NamedBuild {
|
|
||||||
var out []builder.NamedBuild
|
|
||||||
for _, b := range builds {
|
|
||||||
named := builder.NamedBuild{Pull: builder.PullRef{Owner: b.Pull.Owner, Repo: b.Pull.Repo,
|
|
||||||
Number: b.Pull.Number}, Check: b.Check, Module: b.Built.Module, Key: b.Built.Key,
|
|
||||||
Digests: map[string]string{}}
|
|
||||||
for _, m := range b.Built.Made {
|
|
||||||
named.Digests[m.Name] = builder.DigestOf(m.Reference)
|
|
||||||
}
|
|
||||||
out = append(out, named)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkBuiltOf is a check's builds for its merge as the outcome carries them (novox/hq ADR 0350 §1).
|
// checkBuiltOf is a check's builds for its merge as the outcome carries them (novox/hq ADR 0350 §1).
|
||||||
func checkBuiltOf(built []builder.CheckBuilt) []link.CheckBuilt {
|
func checkBuiltOf(built []builder.CheckBuilt) []link.CheckBuilt {
|
||||||
var out []link.CheckBuilt
|
var out []link.CheckBuilt
|
||||||
for _, b := range built {
|
for _, b := range built {
|
||||||
c := link.CheckBuilt{Module: b.Module, Key: b.Key, NotBuilt: b.NotBuilt, Took: b.Took.Round(time.Second).String(),
|
c := link.CheckBuilt{Module: b.Module, Key: b.Key, NotBuilt: b.NotBuilt, Took: b.Took.Round(time.Second).String()}
|
||||||
Kept: b.Kept, Same: b.Same, SameAs: b.SameAs}
|
|
||||||
for _, m := range b.Made {
|
for _, m := range b.Made {
|
||||||
c.Made = append(c.Made, link.MadeArtifact{Name: m.Name, Kind: m.Kind, Reference: m.Reference})
|
c.Made = append(c.Made, link.MadeArtifact{Name: m.Name, Kind: m.Kind, Reference: m.Reference})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A pull request into a release branch reaches the check as one** (novox/hq ADR 0358): the build seat hands the
|
|
||||||
// check the release branch and, beside it, the trunk each repository is read at when it has no branch of that name;
|
|
||||||
// and it hands the check the judge cache the build agent declares (MESH_JUDGE_CACHE), never a path of its own.
|
|
||||||
func TestTheBuildSeatHandsTheCheckItsReleaseBranch(t *testing.T) {
|
|
||||||
spec := checkSpecOf(link.BuildRequest{ID: "b", Repository: "r", Ref: "abc", Check: &link.CheckRequest{
|
|
||||||
Owner: "novox", Repo: "mesh-catalog", Base: "feat/57", Release: "feat/57",
|
|
||||||
Beside: map[string]link.CheckedOut{"mesh-host": {Repository: "h", Ref: "feat/57", Else: "main"}}}})
|
|
||||||
if spec.Release != "feat/57" || spec.Beside["mesh-host"].Ref != "feat/57" || spec.Beside["mesh-host"].Else != "main" {
|
|
||||||
t.Fatalf("the check was handed release %q and mesh-host %+v", spec.Release, spec.Beside["mesh-host"])
|
|
||||||
}
|
|
||||||
t.Setenv("MESH_JUDGE_CACHE", "/var/lib/mesh/build-agent/judge-cache")
|
|
||||||
if got := judgeCache(); got != "/var/lib/mesh/build-agent/judge-cache" {
|
|
||||||
t.Fatalf("the judge cache the manifest declares is read as %q", got)
|
|
||||||
}
|
|
||||||
t.Setenv("MESH_JUDGE_CACHE", "")
|
|
||||||
if got := judgeCache(); got != "" {
|
|
||||||
t.Fatalf("with no judge cache declared, %q is used", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,11 +26,8 @@ import (
|
|||||||
//
|
//
|
||||||
// - **The serving controller** takes it before it does anything else — before it asserts the bus's
|
// - **The serving controller** takes it before it does anything else — before it asserts the bus's
|
||||||
// objects, which are the controller's to write — waiting while another holds it, and renews it.
|
// objects, which are the controller's to write — waiting while another holds it, and renews it.
|
||||||
// Another holder seen, or the key's age passed with no renewal made, is the lease lost: the gate
|
// A renewal refused or failed is the lease lost: the gate closes at once and the process exits, so
|
||||||
// closes at once and the process exits, so its service manager restarts it as a candidate (serve,
|
// its service manager restarts it as a candidate (serve, in push.go).
|
||||||
// in push.go). A renewal the bus does not answer in time is not a loss (novox/hq issue 477): the
|
|
||||||
// lease is kept, no new act starts once half the key's age passed unrenewed, and the next renewal
|
|
||||||
// made opens the gate again.
|
|
||||||
// - **A command run at a shell** — `push` in the installer, the lab, a person repairing a mesh whose
|
// - **A command run at a shell** — `push` in the installer, the lab, a person repairing a mesh whose
|
||||||
// controller is down (issue 201) — acts **under the holder's epoch** when a controller holds the
|
// controller is down (issue 201) — acts **under the holder's epoch** when a controller holds the
|
||||||
// lease: it is the same mesh's word, composed and sent under the store's hold of each machine like
|
// lease: it is the same mesh's word, composed and sent under the store's hold of each machine like
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// And a replay to the catalogue says where the store is now.
|
// And a replay to the catalogue says where the store is now.
|
||||||
announced, err := following{open: open}.Announceable(ctx)
|
announced, err := following{open}.Announceable(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,8 +424,9 @@ func comparisonOf(module string, c comparison, opts takeOptions) (preview string
|
|||||||
}
|
}
|
||||||
// And its settings on this machine, composed against its definition (rule 1, rule 6).
|
// And its settings on this machine, composed against its definition (rule 1, rule 6).
|
||||||
for _, layer := range c.layers {
|
for _, layer := range c.layers {
|
||||||
keys := make([]string, 0, len(layer.Values))
|
said := layer.Whole()
|
||||||
for k := range layer.Values {
|
keys := make([]string, 0, len(said))
|
||||||
|
for k := range said {
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ func spelledOf(m inventory.BatchedMerge) string {
|
|||||||
func namedOf(m inventory.BatchedMerge, repository string) inventory.PlanMerge {
|
func namedOf(m inventory.BatchedMerge, repository string) inventory.PlanMerge {
|
||||||
k := announcedOf(m)
|
k := announcedOf(m)
|
||||||
return inventory.PlanMerge{Repository: repository, Commit: m.Commit, Number: k.Number, Title: k.Title, Moves: k.Moves,
|
return inventory.PlanMerge{Repository: repository, Commit: m.Commit, Number: k.Number, Title: k.Title, Moves: k.Moves,
|
||||||
Head: k.HeadSHA, Merged: m.Merged, Heard: m.Heard}
|
Merged: m.Merged, Heard: m.Heard}
|
||||||
}
|
}
|
||||||
|
|
||||||
// laterMerge says a was merged after b: by the forge's merge time, then by when each was heard.
|
// laterMerge says a was merged after b: by the forge's merge time, then by when each was heard.
|
||||||
@@ -1000,15 +1000,10 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
|
|||||||
plan.Delivery.Merges = named
|
plan.Delivery.Merges = named
|
||||||
plan.Delivery.Alone = batch.Delivery != nil && batch.Delivery.Alone
|
plan.Delivery.Alone = batch.Delivery != nil && batch.Delivery.Alone
|
||||||
plan.Delivery.Withheld = withheld
|
plan.Delivery.Withheld = withheld
|
||||||
// **Left where it is, a module whose program did not change** (novox/hq issue 721): its check found it the same as
|
|
||||||
// the build the mesh holds.
|
|
||||||
for n, why := range leaveSameAsRunning(ctx, inv, &plan, moved, append(append([]inventory.Edge{}, edges...), ordered...)) {
|
|
||||||
unchanged[n] = why
|
|
||||||
}
|
|
||||||
plan.Delivery.Unchanged = unchangedLeft(unchanged, plan.Modules)
|
plan.Delivery.Unchanged = unchangedLeft(unchanged, plan.Modules)
|
||||||
// **Its moments and its class** (novox/hq ADR 0282 decision 6): measured, never acted on.
|
// **Its moments and its class** (novox/hq ADR 0282 decision 6): measured, never acted on.
|
||||||
plan.Times = walkTimesAtCut(*batch, plan, entries, now)
|
plan.Times = walkTimesAtCut(*batch, plan, entries, now)
|
||||||
if len(moved) == 0 || len(plan.Modules) == 0 {
|
if len(moved) == 0 {
|
||||||
plan.State = inventory.PlanDone
|
plan.State = inventory.PlanDone
|
||||||
plan.Tiers = [][]string{}
|
plan.Tiers = [][]string{}
|
||||||
plan.Note = "moved nothing the mesh holds: nothing to walk"
|
plan.Note = "moved nothing the mesh holds: nothing to walk"
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ func TestTheCatchUpKeepsWhatACutMadeHistory(t *testing.T) {
|
|||||||
return entries, read, err
|
return entries, read, err
|
||||||
}
|
}
|
||||||
if err := catchUpOnMerges(ctx, time.Now(), unheardMerges{announcedList(lost), open.inventory}, catalogued,
|
if err := catchUpOnMerges(ctx, time.Now(), unheardMerges{announcedList(lost), open.inventory}, catalogued,
|
||||||
(following{open: open}).SourceMoved, t.Logf); err != nil {
|
(following{open}).SourceMoved, t.Logf); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
got, _ := open.inventory.PlanByID(ctx, w.ID)
|
||||||
|
|||||||
@@ -183,8 +183,10 @@ func buildFrom(result link.BuildResult) inventory.Build {
|
|||||||
kept.Manifest = recordedManifest(result.Manifest, announced)
|
kept.Manifest = recordedManifest(result.Manifest, announced)
|
||||||
// The module name comes from the manifest, which only exists when the build got that far.
|
// The module name comes from the manifest, which only exists when the build got that far.
|
||||||
if len(kept.Manifest) > 0 {
|
if len(kept.Manifest) > 0 {
|
||||||
if m, err := catalogue.ParseManifest(kept.Manifest); err == nil {
|
if list, err := judgedWith(result); err == nil {
|
||||||
kept.Module = m.Module
|
if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil {
|
||||||
|
kept.Module = m.Module
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return kept
|
return kept
|
||||||
@@ -603,6 +605,11 @@ func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module
|
|||||||
// written with the same values.
|
// written with the same values.
|
||||||
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
||||||
catalogue.Manifest, inventory.Build, error) {
|
catalogue.Manifest, inventory.Build, error) {
|
||||||
|
// The list of predefined keys as the store keeps it now: another controller process may have kept a newer one.
|
||||||
|
if err := loadPredefined(ctx, inv); err != nil {
|
||||||
|
return catalogue.Manifest{}, inventory.Build{}, fmt.Errorf("the list of predefined keys kept cannot be "+
|
||||||
|
"read: %w", err)
|
||||||
|
}
|
||||||
kept := buildFrom(result)
|
kept := buildFrom(result)
|
||||||
if err := inv.RecordBuild(ctx, kept); err != nil {
|
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||||
return catalogue.Manifest{}, kept, err
|
return catalogue.Manifest{}, kept, err
|
||||||
@@ -613,7 +620,15 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
||||||
result.On, result.Repository, result.Failed)
|
result.On, result.Repository, result.Failed)
|
||||||
}
|
}
|
||||||
manifest, err := catalogue.ParseManifest(kept.Manifest)
|
// Judged first against the list the build was judged against: the catalogue's at the commit built (novox/hq
|
||||||
|
// ADR 0348 §7), so a module naming a key its own commit adds is read; and again below against the list the
|
||||||
|
// mesh publishes, before it is registered.
|
||||||
|
list, err := judgedWith(result)
|
||||||
|
if err != nil {
|
||||||
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
|
||||||
|
result.Repository, short(result.Commit), err)
|
||||||
|
}
|
||||||
|
manifest, err := catalogue.ParseManifestWith(kept.Manifest, list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||||
result.On, result.Repository, err)
|
result.On, result.Repository, err)
|
||||||
@@ -687,6 +702,23 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
"back: it is recorded and not registered again — a newer build is", result.On, manifest.Module,
|
"back: it is recorded and not registered again — a newer build is", result.On, manifest.Module,
|
||||||
short(result.Commit))
|
short(result.Commit))
|
||||||
}
|
}
|
||||||
|
// **The newest trunk build's list of predefined keys is published** (novox/hq ADR 0348 §7): a build of the
|
||||||
|
// catalogue that may be registered keeps its list when its commit is newer than the one kept. Then the module
|
||||||
|
// is judged against the list the mesh publishes, as every manifest read again is: one naming a key that list
|
||||||
|
// does not hold, or of another type, is refused here and never enters the catalogue.
|
||||||
|
if carried, err := carriedList(result); err != nil {
|
||||||
|
return manifest, kept, err
|
||||||
|
} else if carried != nil {
|
||||||
|
if err := publishPredefined(ctx, inv, result, carried); err != nil {
|
||||||
|
fmt.Printf("%s: the list of predefined keys it carried is not published: %v\n", result.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if manifest.NamesPredefined() {
|
||||||
|
if _, err := catalogue.ParseManifest(kept.Manifest); err != nil {
|
||||||
|
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: judged against the "+
|
||||||
|
"list of predefined keys the mesh publishes, %w", result.On, manifest.Module, short(result.Commit), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
// **A build whose source is unchanged is never a move** (novox/hq issue 280): a rebuild made from
|
// **A build whose source is unchanged is never a move** (novox/hq issue 280): a rebuild made from
|
||||||
// what the build the mesh stands on was made from registers that build's artifacts at the new
|
// what the build the mesh stands on was made from registers that build's artifacts at the new
|
||||||
// commit, so no machine is sent a new digest for a source nobody changed — an image is not
|
// commit, so no machine is sent a new digest for a source nobody changed — an image is not
|
||||||
@@ -899,34 +931,6 @@ type answers struct {
|
|||||||
//
|
//
|
||||||
// Routed through the artifact store as the network reaches it now (novox/hq 04-ISSUES/102): a
|
// Routed through the artifact store as the network reaches it now (novox/hq 04-ISSUES/102): a
|
||||||
// base is recorded by digest and path, and a build machine needs something it can pull.
|
// base is recorded by digest and path, and a build machine needs something it can pull.
|
||||||
// runningBy is, keyed "<module>/<artifact>", each artifact of the build the mesh has registered for its module, as a
|
|
||||||
// builder reaches the artifact store (novox/hq issue 721): what a check compares its builds with. Empty when it cannot
|
|
||||||
// be read, said, and then nothing is said unchanged.
|
|
||||||
func runningBy(ctx context.Context) map[string]string {
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not read what this mesh runs, so the check compares nothing: %v\n", err)
|
|
||||||
// empty-on-error: said above; a check given nothing to compare with says no module unchanged, and all move
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
running, err := open.inventory.RegisteredHeld(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not read what this mesh runs, so the check compares nothing: %v\n", err)
|
|
||||||
// empty-on-error: said above; a check given nothing to compare with says no module unchanged, and all move
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
address, err := whereABuilderReachesTheStore(ctx, open.inventory)
|
|
||||||
if err != nil || address == "" {
|
|
||||||
return running
|
|
||||||
}
|
|
||||||
routed := make(map[string]string, len(running))
|
|
||||||
for key, reference := range running {
|
|
||||||
routed[key] = catalogue.Rerouted(reference, address)
|
|
||||||
}
|
|
||||||
return routed
|
|
||||||
}
|
|
||||||
|
|
||||||
func heldBy(ctx context.Context) map[string]string {
|
func heldBy(ctx context.Context) map[string]string {
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/conditions"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **Every user's slow consumer is S9's, not the controller's alone** (novox/hq ADR 0357, issue 270). On
|
|
||||||
// 2026-10-07 the bus closed two clients on the laptop as slow consumers and nothing raised a condition:
|
|
||||||
// the controller heard only its own connection. Said by the bus module and read back, a user the bus
|
|
||||||
// closes is one condition naming the module and its machine, raised at the first look and cleared an hour
|
|
||||||
// after the last closure.
|
|
||||||
func TestAUserTheBusClosedAsASlowConsumerIsOneConditionNamingItsModuleAndMachine(t *testing.T) {
|
|
||||||
closed := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
||||||
now := closed.Add(20 * time.Second)
|
|
||||||
store := conditions.NewInMemory()
|
|
||||||
k := conditions.NewKeeper(t.Context(), conditions.Options{Store: store, History: store,
|
|
||||||
Teller: &conditions.Told{}, Now: func() time.Time { return now }})
|
|
||||||
t.Cleanup(func() { k.Close(t.Context()) })
|
|
||||||
|
|
||||||
advisories := link.BusClosures([]link.BusClosure{
|
|
||||||
{User: "laptop.claude-code", CID: 10, Reason: "Slow Consumer (Write Deadline)", Opened: closed.Add(-time.Hour),
|
|
||||||
Closed: closed.Add(-10 * time.Minute)},
|
|
||||||
{User: "laptop.claude-code", CID: 44, Reason: "Slow Consumer (Write Deadline)", Opened: closed.Add(-time.Minute),
|
|
||||||
Closed: closed},
|
|
||||||
{User: "anchor.desk", CID: 7, Reason: "Maximum Message Payload Exceeded", Opened: closed.Add(-time.Hour),
|
|
||||||
Closed: closed},
|
|
||||||
}, []string{link.AdvisorySlowConsumer, link.AdvisorySlowConsumer, link.AdvisoryConnectionClosed}, closed.Add(-time.Hour))
|
|
||||||
look := func() {
|
|
||||||
t.Helper()
|
|
||||||
f := &signalFacts{now: now, host: "anchor", advisories: advisories}
|
|
||||||
if err := k.Reconcile(t.Context(), "S9", watchAdvisories(f)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
look()
|
|
||||||
|
|
||||||
slow, found, err := conditions.ReadOne(t.Context(), store, "bus.laptop.claude-code.slow-consumer")
|
|
||||||
if err != nil || !found {
|
|
||||||
t.Fatalf("no slow-consumer condition for the user: %v %v", found, err)
|
|
||||||
}
|
|
||||||
if slow.Subject.Machine != "laptop" || slow.Observations != 2 || !slow.LastObserved.Equal(closed) {
|
|
||||||
t.Fatalf("the slow consumer is said on %q, %d closure(s), last %s: %+v", slow.Subject.Machine, slow.Observations,
|
|
||||||
slow.LastObserved, slow)
|
|
||||||
}
|
|
||||||
if !strings.Contains(slow.Summary, "claude-code on laptop") || strings.Contains(slow.Summary, "laptop.claude-code") {
|
|
||||||
t.Fatalf("the summary does not name the module in words, or carries the dotted user: %q", slow.Summary)
|
|
||||||
}
|
|
||||||
if !strings.Contains(slow.Headline, "Claude-code on laptop") {
|
|
||||||
t.Fatalf("the headline is %q", slow.Headline)
|
|
||||||
}
|
|
||||||
cut, found, err := conditions.ReadOne(t.Context(), store, "bus.anchor.desk.connection-closed")
|
|
||||||
if err != nil || !found || cut.Subject.Machine != "anchor" || !strings.Contains(cut.Summary, "maximum message payload exceeded") {
|
|
||||||
t.Fatalf("the user cut off for breaking the bus's rules: %v %v %+v", found, err, cut)
|
|
||||||
}
|
|
||||||
|
|
||||||
// An hour after the newest closure without another, both clear.
|
|
||||||
now = closed.Add(time.Hour + time.Minute)
|
|
||||||
look()
|
|
||||||
for _, key := range []string{"bus.laptop.claude-code.slow-consumer", "bus.anchor.desk.connection-closed"} {
|
|
||||||
if _, found, _ := k.Get(t.Context(), key); found {
|
|
||||||
t.Fatalf("%s is still open an hour after the last closure", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A read of the bus module's events that fails does not blind S9** (review of #326): the controller's own
|
|
||||||
// signals are still said, and the failed read is a condition of its own.
|
|
||||||
func TestABusModuleReadThatFailsKeepsTheControllersOwnSignals(t *testing.T) {
|
|
||||||
at := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
||||||
f := &signalFacts{now: at.Add(time.Minute), host: "anchor",
|
|
||||||
advisories: []link.Advisory{{Kind: link.AdvisorySlowConsumer, ID: "controller", Said: "the controller fell behind",
|
|
||||||
First: at, Last: at, Count: 1}},
|
|
||||||
busFaultsErr: errors.New("nats: timeout")}
|
|
||||||
keys := map[string]bool{}
|
|
||||||
for _, o := range watchAdvisories(f) {
|
|
||||||
keys[o.Key()] = true
|
|
||||||
}
|
|
||||||
if !keys["bus.controller.slow-consumer"] || !keys["bus.bus-module.bus-faults-unread"] {
|
|
||||||
t.Fatalf("observed %v: the controller's own slow consumer and the unread events, both", keys)
|
|
||||||
}
|
|
||||||
for _, row := range signalsTable {
|
|
||||||
if row.Row == "S9" && row.needs(f) != nil {
|
|
||||||
t.Fatal("the S9 row went blind for a read of the bus module's events")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,19 +60,9 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// And every module's own stream (novox/hq ADR 0363), from the catalogue, before the consumers on it.
|
|
||||||
modStreams, err := inv.DeclaredStreams(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
// Every one tried, and every failure named: one module's consumer the bus refuses is no reason
|
||||||
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
// the modules after it in the list hear nothing (novox/hq issue 208, where this runs on each send).
|
||||||
var failed []error
|
var failed []error
|
||||||
for _, s := range broker.ModuleStreams(modStreams) {
|
|
||||||
if err := r.EnsureStream(s); err != nil {
|
|
||||||
failed = append(failed, fmt.Errorf("the module stream %s: %w", s.Name, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, s := range trafficStreams {
|
for _, s := range trafficStreams {
|
||||||
if err := r.EnsureStream(s); err != nil {
|
if err := r.EnsureStream(s); err != nil {
|
||||||
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
|
failed = append(failed, fmt.Errorf("the work queue %s: %w", s.Name, err))
|
||||||
@@ -83,30 +73,11 @@ func assertBusObjects(ctx context.Context, inv *inventory.Inventory, r broker.Ra
|
|||||||
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
|
failed = append(failed, fmt.Errorf("the worker %s on %s: %w", c.Name, c.Stream, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
restricted := map[string]bool{}
|
|
||||||
for _, s := range modStreams {
|
|
||||||
restricted[s.Name] = s.Restricted
|
|
||||||
}
|
|
||||||
for _, c := range consumers {
|
for _, c := range consumers {
|
||||||
if err := broker.EnsureHeard(r, c.Consumer, restricted[c.Consumer.Stream]); err != nil {
|
if err := r.EnsureConsumer(c.Consumer); err != nil {
|
||||||
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
failed = append(failed, fmt.Errorf("how %s on %s hears what it consumes: %w", c.Module, c.Node, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **And a consumer no longer granted loses what it reads through** (novox/hq ADR 0363 §6): every consumer
|
|
||||||
// on a module's stream the composition no longer asks for is removed, on this very send.
|
|
||||||
if p, prunes := r.(broker.Pruner); prunes && len(modStreams) > 0 {
|
|
||||||
var wanted []broker.Consumer
|
|
||||||
for _, c := range consumers {
|
|
||||||
wanted = append(wanted, c.Consumer)
|
|
||||||
}
|
|
||||||
removed, err := broker.PruneHeard(p, modStreams, wanted)
|
|
||||||
if err != nil {
|
|
||||||
failed = append(failed, err)
|
|
||||||
}
|
|
||||||
for _, gone := range removed {
|
|
||||||
fmt.Printf(" removed the consumer %s: its module is no longer granted that stream (novox/hq ADR 0363)\n", gone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(failed) > 0 {
|
if len(failed) > 0 {
|
||||||
return nil, errors.Join(failed...)
|
return nil, errors.Join(failed...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
|||||||
shelf := catalogue.Shelf{}
|
shelf := catalogue.Shelf{}
|
||||||
faulted := map[string]bool{}
|
faulted := map[string]bool{}
|
||||||
failed := 0
|
failed := 0
|
||||||
|
// The list of predefined keys at the root of the repository above each manifest (novox/hq ADR 0348 §7), read
|
||||||
|
// once per repository: a manifest naming a key is judged against the list its own tree holds.
|
||||||
|
lists := map[string]catalogue.PredefinedList{}
|
||||||
|
listOf := func(path string) (catalogue.PredefinedList, error) {
|
||||||
|
root := catalogue.RepositoryRoot(filepath.Dir(path))
|
||||||
|
if root == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if list, read := lists[root]; read {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
list, err := catalogue.ReadPredefined(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lists[root] = list
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +72,18 @@ func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
|||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m, err := catalogue.ParseManifest(raw)
|
list, err := listOf(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, err := catalogue.ParseManifestWith(raw, list)
|
||||||
|
if err == nil {
|
||||||
|
// Each template's text read from the tree beside the manifest, and judged as inline content is
|
||||||
|
// (novox/hq ADR 0349 §2): a missing template is refused by name.
|
||||||
|
m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(out, "%s: %v\n", path, err)
|
fmt.Fprintf(out, "%s: %v\n", path, err)
|
||||||
failed++
|
failed++
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) {
|
|||||||
// build as it judges one the walk built. A scratch reference is refused. Needs the store (MESH_TEST_POSTGRES), and
|
// build as it judges one the walk built. A scratch reference is refused. Needs the store (MESH_TEST_POSTGRES), and
|
||||||
// is named in testdata/store-tests.
|
// is named in testdata/store-tests.
|
||||||
func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.T) {
|
func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
g := aGateMesh(t)
|
g := aGateMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := g.open.inventory
|
inv := g.open.inventory
|
||||||
@@ -191,90 +190,27 @@ func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A walk's leave names the builds its own pull requests' verdicts made at the head each merge merged — never an
|
// A walk's leave names the builds its own pull requests' verdicts made, and none when they cannot be read: the
|
||||||
// earlier head's — read once per walk, and none when they cannot be read: the builder takes nothing a verdict does
|
// builder takes nothing a verdict does not name (novox/hq ADR 0350 §2).
|
||||||
// not name (novox/hq ADR 0350 §2, issue 703).
|
|
||||||
func TestAWalksLeaveNamesTheBuildsItsPullRequestsVerdictsMade(t *testing.T) {
|
func TestAWalksLeaveNamesTheBuildsItsPullRequestsVerdictsMade(t *testing.T) {
|
||||||
was := checkedBuildsOf
|
was := checkedBuildsOf
|
||||||
forgetWalkVerdicts()
|
t.Cleanup(func() { checkedBuildsOf = was })
|
||||||
t.Cleanup(func() { checkedBuildsOf = was; forgetWalkVerdicts() })
|
var asked []link.PullRef
|
||||||
var asked [][]link.PullRef
|
named := []link.CheckedBuild{{Pull: link.PullRef{Owner: "novox", Repo: "shop", Number: 7}, Check: "build-1",
|
||||||
pull := link.PullRef{Owner: "novox", Repo: "shop", Number: 7}
|
Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}}
|
||||||
atHead := link.CheckedBuild{Pull: pull, Check: "build-2", Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:2"}}
|
|
||||||
earlier := link.CheckedBuild{Pull: pull, Check: "build-1", Head: "aaaaaaaaaaaa", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
|
|
||||||
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
|
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
|
||||||
asked = append(asked, pulls)
|
asked = pulls
|
||||||
return []link.CheckedBuild{atHead, earlier}, nil
|
return named, nil
|
||||||
}
|
}
|
||||||
pulls := []link.PullRef{pull}
|
pulls := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}
|
||||||
heads := map[link.PullRef]string{pull: "bbbbbbbbbbbb0123456789"}
|
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); !reflect.DeepEqual(got.Builds, named) ||
|
||||||
for range 3 {
|
!reflect.DeepEqual(asked, pulls) {
|
||||||
if got := reuseWithVerdicts(t.Context(), "plan-1", &link.ReuseAsk{Pulls: pulls}, heads); !reflect.DeepEqual(got.Builds,
|
t.Fatalf("the leave names %+v, read for %+v", got.Builds, asked)
|
||||||
[]link.CheckedBuild{atHead}) {
|
|
||||||
t.Fatalf("the leave names %+v, not only the merged head's build", got.Builds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(asked, [][]link.PullRef{pulls}) {
|
|
||||||
t.Fatalf("the verdicts were read %d times for one walk: %v", len(asked), asked)
|
|
||||||
}
|
|
||||||
// A merge whose head was not said takes nothing.
|
|
||||||
if got := reuseWithVerdicts(t.Context(), "plan-1", &link.ReuseAsk{Pulls: pulls}, nil); got.Builds != nil {
|
|
||||||
t.Fatalf("a merge of no said head names %+v", got.Builds)
|
|
||||||
}
|
}
|
||||||
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
|
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
|
||||||
return nil, errors.New("no bus")
|
return nil, errors.New("no bus")
|
||||||
}
|
}
|
||||||
if got := reuseWithVerdicts(t.Context(), "plan-2", &link.ReuseAsk{Pulls: pulls}, heads); got.Builds != nil {
|
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); got.Builds != nil {
|
||||||
t.Fatalf("verdicts that could not be read named %+v", got.Builds)
|
t.Fatalf("verdicts that could not be read named %+v", got.Builds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A check is handed only the builds earlier checks of its own pull request at its own head made: another pull
|
|
||||||
// request's build, or an earlier head's, is never kept (novox/hq issue 703). None when they cannot be read.
|
|
||||||
func TestACheckIsHandedOnlyItsOwnHeadsBuilds(t *testing.T) {
|
|
||||||
was := checkedBuildsOf
|
|
||||||
t.Cleanup(func() { checkedBuildsOf = was })
|
|
||||||
pull := link.PullRef{Owner: "novox", Repo: "shop", Number: 7}
|
|
||||||
mine := link.CheckedBuild{Pull: pull, Check: "build-1", Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
|
|
||||||
earlier := link.CheckedBuild{Pull: pull, Check: "build-0", Head: "aaaaaaaaaaaa", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
|
|
||||||
another := link.CheckedBuild{Pull: link.PullRef{Owner: "novox", Repo: "shop", Number: 8}, Check: "build-9",
|
|
||||||
Head: "bbbbbbbbbbbb", Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}
|
|
||||||
var asked [][]link.PullRef
|
|
||||||
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
|
|
||||||
asked = append(asked, pulls)
|
|
||||||
return []link.CheckedBuild{mine, earlier, another}, nil
|
|
||||||
}
|
|
||||||
p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Commit: "bbbbbbbbbbbb0123456789"}
|
|
||||||
builds := []link.CheckBuild{{Module: "app"}}
|
|
||||||
if got := namedForTheCheck(t.Context(), p, builds); !reflect.DeepEqual(got, []link.CheckedBuild{mine}) ||
|
|
||||||
!reflect.DeepEqual(asked, [][]link.PullRef{{pull}}) {
|
|
||||||
t.Fatalf("the check is handed %+v, read for %v", got, asked)
|
|
||||||
}
|
|
||||||
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
|
|
||||||
return nil, errors.New("no bus")
|
|
||||||
}
|
|
||||||
if got := namedForTheCheck(t.Context(), p, builds); got != nil {
|
|
||||||
t.Fatalf("verdicts that could not be read named %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func forgetWalkVerdicts() {
|
|
||||||
walkVerdicts.Lock()
|
|
||||||
defer walkVerdicts.Unlock()
|
|
||||||
walkVerdicts.read = map[string]verdictsRead{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A walk's merged heads are the forge's word for each merge; a merge announced without its head, or a pull request
|
|
||||||
// merged at two heads in one walk, has none, and its walk takes no check's build (novox/hq issue 703).
|
|
||||||
func TestAWalksMergedHeadsAreTheForgesWord(t *testing.T) {
|
|
||||||
merge := func(number int, head string) inventory.PlanMerge {
|
|
||||||
return inventory.PlanMerge{Repository: "novox/shop", Number: number, Head: head}
|
|
||||||
}
|
|
||||||
p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
|
|
||||||
merge(7, "aaaaaaaaaaaa1"), merge(8, ""), merge(9, "bbbbbbbbbbbb"), merge(9, "cccccccccccc")}}}
|
|
||||||
heads := mergedHeadsOf(p)
|
|
||||||
shop := func(n int) link.PullRef { return link.PullRef{Owner: "novox", Repo: "shop", Number: n} }
|
|
||||||
if heads[shop(7)] != "aaaaaaaaaaaa1" || heads[shop(8)] != "" || heads[shop(9)] != "" {
|
|
||||||
t.Fatalf("merged heads %v", heads)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+25
-193
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -189,8 +188,6 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
|||||||
if err := ask.Ask(ctx, request); err != nil {
|
if err := ask.Ask(ctx, request); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// A new head ends the check of the old one (novox/hq issue 726).
|
|
||||||
endingSupersededChecks(ctx, seat, p, request.ID, f.oneShot)
|
|
||||||
what := "its own merge-check.sh alone: " + noModuleTouched
|
what := "its own merge-check.sh alone: " + noModuleTouched
|
||||||
if scope.gated() {
|
if scope.gated() {
|
||||||
what = fmt.Sprintf("the gate over %s (a merge would build %d module(s) in %d tier(s))",
|
what = fmt.Sprintf("the gate over %s (a merge would build %d module(s) in %d tier(s))",
|
||||||
@@ -251,16 +248,31 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return link.BuildRequest{}, err
|
return link.BuildRequest{}, err
|
||||||
}
|
}
|
||||||
// **A pull request into a release branch is judged by that branch** (novox/hq ADR 0358): asked of the forge once.
|
// Beside it, at what the mesh runs: each core repository by the module the mesh builds from it.
|
||||||
release, err := releaseBranchOf(ctx, p, scope, entries)
|
beside := map[string]link.CheckedOut{}
|
||||||
if err != nil {
|
for _, e := range entries {
|
||||||
return link.BuildRequest{}, err
|
dir, core := coreModules[e.Manifest.Module]
|
||||||
|
if !core || e.Provided || e.Source.Repository == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url, err := clone(e.Source)
|
||||||
|
if err != nil {
|
||||||
|
return link.BuildRequest{}, err
|
||||||
|
}
|
||||||
|
refs := besideRefs(dir, current[e.Manifest.Module].Commit)
|
||||||
|
beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]}
|
||||||
|
if dir == "mesh-controller" {
|
||||||
|
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
|
||||||
|
if e.Source.Seat != "" {
|
||||||
|
for _, sibling := range []string{"mesh-lab", "mesh-sdk"} {
|
||||||
|
if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
|
||||||
|
sibling)}); err == nil {
|
||||||
|
beside[sibling] = link.CheckedOut{Repository: url, Ref: refs[sibling]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone, release)
|
|
||||||
if err != nil {
|
|
||||||
return link.BuildRequest{}, err
|
|
||||||
}
|
|
||||||
builds := checkBuildsOf(p, scope, entries, clone)
|
|
||||||
return link.BuildRequest{
|
return link.BuildRequest{
|
||||||
ID: link.NewBuildID(time.Now()),
|
ID: link.NewBuildID(time.Now()),
|
||||||
Repository: repository,
|
Repository: repository,
|
||||||
@@ -270,188 +282,10 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
|
|||||||
Source: sourceOnSeat(source),
|
Source: sourceOnSeat(source),
|
||||||
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
||||||
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
|
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
|
||||||
Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: namedForTheCheck(ctx, p, builds),
|
Manifests: scope.Manifests, Judge: scope.Judge, Builds: checkBuildsOf(p, scope, entries, clone)},
|
||||||
Release: release, Running: runningBy(ctx)},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// besideOf is what a check reads beside its own repository, by the directory it is found under: each core repository
|
|
||||||
// by the module the mesh builds from it, at besideRefs — or, for a pull request into a release branch (novox/hq ADR
|
|
||||||
// 0358 §3), at its branch of that name where it has one and at its trunk otherwise, the controller's main kept for
|
|
||||||
// its judge, and mesh-lab at its trunk whatever the base: its replays run with the container runtime's socket. running
|
|
||||||
// is the commit the mesh runs of a module.
|
|
||||||
func besideOf(entries []inventory.Entry, running func(module string) string,
|
|
||||||
clone func(inventory.Source) (string, error), release string) (map[string]link.CheckedOut, error) {
|
|
||||||
beside := map[string]link.CheckedOut{}
|
|
||||||
for _, e := range entries {
|
|
||||||
dir, core := coreModules[e.Manifest.Module]
|
|
||||||
if !core || e.Provided || e.Source.Repository == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
url, err := clone(e.Source)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
refs := besideRefs(dir, running(e.Manifest.Module))
|
|
||||||
at := func(dir, trunk string) link.CheckedOut {
|
|
||||||
if release == "" {
|
|
||||||
return link.CheckedOut{Repository: url, Ref: refs[dir]}
|
|
||||||
}
|
|
||||||
if trunk == "" {
|
|
||||||
trunk = "main"
|
|
||||||
}
|
|
||||||
return link.CheckedOut{Repository: url, Ref: release, Else: trunk}
|
|
||||||
}
|
|
||||||
beside[dir] = at(dir, followedBranch(e.Source.Ref))
|
|
||||||
if dir == "mesh-controller" {
|
|
||||||
// The controller's main stays its main: it judges only a check the running controller predates.
|
|
||||||
beside["mesh-controller-main"] = link.CheckedOut{Repository: url, Ref: refs["mesh-controller-main"]}
|
|
||||||
if release != "" {
|
|
||||||
// mesh-controller has the release branch: that is what makes it one (ADR 0358 §1).
|
|
||||||
beside[dir] = link.CheckedOut{Repository: url, Ref: release}
|
|
||||||
}
|
|
||||||
if e.Source.Seat != "" {
|
|
||||||
for _, sibling := range []string{"mesh-lab", "mesh-sdk"} {
|
|
||||||
if url, err := clone(inventory.Source{Seat: e.Source.Seat, Repository: siblingOf(e.Source.Repository,
|
|
||||||
sibling)}); err == nil {
|
|
||||||
b := link.CheckedOut{Repository: url, Ref: refs[sibling]}
|
|
||||||
// **mesh-lab is read at its trunk, always** (ADR 0358): its replays run with the container
|
|
||||||
// runtime's socket, so they are only ever reviewed code — never a branch anyone may create.
|
|
||||||
if release != "" && sibling != "mesh-lab" {
|
|
||||||
b = link.CheckedOut{Repository: url, Ref: release, Else: "main"}
|
|
||||||
}
|
|
||||||
beside[sibling] = b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return beside, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseBranchOf is the release branch a pull request merges into (novox/hq ADR 0358 §1): its base, when that is not
|
|
||||||
// its repository's trunk and mesh-controller has a branch of that name; empty otherwise, and the pull request is
|
|
||||||
// checked as before it. **A base that is a trunk asks nothing**: the branch a module built from the repository follows
|
|
||||||
// (main when it records none), or mesh-controller's own trunk. Any other base is asked of the forge once, and an
|
|
||||||
// answer it cannot give is an error, never read as "no release branch".
|
|
||||||
func releaseBranchOf(ctx context.Context, p link.PullUpdated, scope checkScope, entries []inventory.Entry) (string, error) {
|
|
||||||
base := p.Base
|
|
||||||
if base == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if scope.From != nil && base == trunkOf(scope.From.Source) {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
var controller *inventory.Entry
|
|
||||||
for i, e := range entries {
|
|
||||||
if e.Manifest.Module == "mesh-controller" && !e.Provided && e.Source.Repository != "" {
|
|
||||||
controller = &entries[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if controller == nil || base == trunkOf(controller.Source) {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if !tryRef.MatchString(base) || strings.Contains(base, "..") {
|
|
||||||
return "", nil // no branch the forge could hold: checked as before
|
|
||||||
}
|
|
||||||
owner, repo := sourceOwner(controller.Source.Repository), sourceName(controller.Source.Repository)
|
|
||||||
if owner == "" || repo == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
has, err := forgeHasBranch(ctx, owner, repo, base)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("whether %s/%s#%d merges into a release branch cannot be told — the forge did not say "+
|
|
||||||
"whether %s/%s has a branch %s (novox/hq ADR 0358): %w", p.Owner, p.Repo, p.Number, owner, repo, base, err)
|
|
||||||
}
|
|
||||||
if !has {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return base, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// trunkOf is the branch a module's source follows: main when it records none, or records a commit.
|
|
||||||
func trunkOf(s inventory.Source) string {
|
|
||||||
if t := strings.TrimSpace(followedBranch(s.Ref)); t != "" {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
return "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceName is the name of a recorded repository, a path on the git seat or a URL: novox/mesh-host → mesh-host.
|
|
||||||
func sourceName(repository string) string {
|
|
||||||
parts := strings.Split(strings.Trim(strings.TrimSuffix(repository, ".git"), "/"), "/")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// forgeHasBranch asks the forge, through its module's tools on the bus, whether a repository has a branch. A
|
|
||||||
// variable so a test needs no forge.
|
|
||||||
var forgeHasBranch = func(ctx context.Context, owner, repo, branch string) (bool, error) {
|
|
||||||
js, err := aBus()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
defer js.Close()
|
|
||||||
raw, _ := json.Marshal(map[string]any{"path": "/repos/" + owner + "/" + repo + "/branches/" + branch})
|
|
||||||
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return branchAnswered(answer, branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// branchAnswered reads the forge's answer about a branch: a 404 is "no such branch"; an answer naming the branch is
|
|
||||||
// "it has it"; anything else — another error, an unreadable answer, one naming no branch or another — is an error,
|
|
||||||
// never read as "no release branch".
|
|
||||||
func branchAnswered(answer link.Answer, branch string) (bool, error) {
|
|
||||||
if answer.Error != "" {
|
|
||||||
if strings.Contains(answer.Error, " 404 ") {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, errors.New(answer.Error)
|
|
||||||
}
|
|
||||||
var found struct {
|
|
||||||
Result struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"result"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(answer.Result, &found); err != nil {
|
|
||||||
return false, fmt.Errorf("the forge's answer about branch %s cannot be read: %w", branch, err)
|
|
||||||
}
|
|
||||||
if found.Result.Name != branch {
|
|
||||||
return false, fmt.Errorf("the forge answered about branch %s naming %q", branch, found.Result.Name)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// namedForTheCheck are the builds earlier checks of the same pull request at the same head named, read once for the
|
|
||||||
// check (novox/hq issue 703): asked again because only the trunk moved, the check keeps what they built of a key
|
|
||||||
// still the same. Another pull request's build, or one of an earlier head, is never kept. None when they cannot be
|
|
||||||
// read, and the check builds, as before.
|
|
||||||
func namedForTheCheck(ctx context.Context, p link.PullUpdated, builds []link.CheckBuild) []link.CheckedBuild {
|
|
||||||
if len(builds) == 0 || p.Commit == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pull := link.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number}
|
|
||||||
read, err := checkedBuildsOf(ctx, []link.PullRef{pull})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" the earlier checks' builds of %s/%s#%d could not be read, so the check builds: %v\n",
|
|
||||||
p.Owner, p.Repo, p.Number, err)
|
|
||||||
// empty-on-error: said above; a check handed no named build builds every module, as before issue 703
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var named []link.CheckedBuild
|
|
||||||
for _, b := range read {
|
|
||||||
if b.Pull == pull && link.SameHead(b.Head, p.Commit) {
|
|
||||||
named = append(named, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return named
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkBuildsOf are the modules a merge of the change would build, in the walk's order, as the check builds them
|
// checkBuildsOf are the modules a merge of the change would build, in the walk's order, as the check builds them
|
||||||
// for the walk to take (novox/hq ADR 0350 §1): a module built from the repository checked, at the branch it merges
|
// for the walk to take (novox/hq ADR 0350 §1): a module built from the repository checked, at the branch it merges
|
||||||
// into, from the merge the check makes; any other — a module standing on one — from its own repository at the
|
// into, from the merge the check makes; any other — a module standing on one — from its own repository at the
|
||||||
@@ -598,8 +432,6 @@ func checkedOf(result link.BuildResult) link.Checked {
|
|||||||
// repositories read beside it.
|
// repositories read beside it.
|
||||||
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
|
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
|
||||||
c.Built = result.Check.Built
|
c.Built = result.Check.Built
|
||||||
c.Release, c.JudgedBy, c.BesideAt = result.Check.Release, result.Check.JudgedBy, result.Check.BesideAt
|
|
||||||
c.Validator = result.Check.Validator
|
|
||||||
if c.Gate == nil {
|
if c.Gate == nil {
|
||||||
// A build seat from before the layers: its verdict is the gate's.
|
// A build seat from before the layers: its verdict is the gate's.
|
||||||
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
||||||
|
|||||||
@@ -952,7 +952,7 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
|||||||
if *group == "" && len(members) == 1 {
|
if *group == "" && len(members) == 1 {
|
||||||
// One head, checked again as the forge's announcement would have it (a recheck): the controller's
|
// One head, checked again as the forge's announcement would have it (a recheck): the controller's
|
||||||
// own path for a pull request, run because the delivery's owner asked.
|
// own path for a pull request, run because the delivery's owner asked.
|
||||||
if err := sayingOnTheBus(ctx, inv, func() error { return (following{open: open, oneShot: true}).PullUpdated(ctx, members[0].pull()) }); err != nil {
|
if err := sayingOnTheBus(ctx, inv, func() error { return (following{open}).PullUpdated(ctx, members[0].pull()) }); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return answer(map[string]any{"rechecked": members[0].ID})
|
return answer(map[string]any{"rechecked": members[0].ID})
|
||||||
|
|||||||
@@ -348,11 +348,11 @@ func gatherFacts(ctx context.Context, open *stores, busVersion string) (snapshot
|
|||||||
if layer.From == catalogue.MeshWideLayer {
|
if layer.From == catalogue.MeshWideLayer {
|
||||||
if !meshWide[module] {
|
if !meshWide[module] {
|
||||||
meshWide[module] = true
|
meshWide[module] = true
|
||||||
f.Settings = append(f.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Values)})
|
f.Settings = append(f.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Whole())})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.Settings = append(m.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Values)})
|
m.Settings = append(m.Settings, snapshot.Settings{Module: module, Values: scrub.Values(layer.Whole())})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.Declaration = declarationFacts(ctx, open, n.Name, gens, gensErr, scrub)
|
m.Declaration = declarationFacts(ctx, open, n.Name, gens, gensErr, scrub)
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ func foundDirectory(module string, since time.Time) inventory.ResourceHealth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
|
func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sent := now.Add(-time.Minute)
|
sent := now.Add(-time.Minute)
|
||||||
f := gateFacts{now: now, health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now,
|
f := gateFacts{now: now, health: map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now,
|
||||||
@@ -49,7 +48,6 @@ func TestADirectoryFoundBeforeTheSendIsAWaitForAPerson(t *testing.T) {
|
|||||||
// The whole walk: a module whose directory was used as found long before still gets its fix to every machine,
|
// The whole walk: a module whose directory was used as found long before still gets its fix to every machine,
|
||||||
// its pass kept and the wait said; a directory this very send found holds it and puts it back.
|
// its pass kept and the wait said; a directory this very send found holds it and puts it back.
|
||||||
func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
|
func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
name string
|
name string
|
||||||
found time.Duration // when the directory was found, against now
|
found time.Duration // when the directory was found, against now
|
||||||
@@ -118,7 +116,6 @@ func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
|
|||||||
|
|
||||||
// The condition says the wait in its own kind: the operator's, never urgent, and cleared once handed over.
|
// The condition says the wait in its own kind: the operator's, never urgent, and cleared once handed over.
|
||||||
func TestADirectoryUsedAsFoundIsItsOwnCondition(t *testing.T) {
|
func TestADirectoryUsedAsFoundIsItsOwnCondition(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
k, _ := withConditionsInMemory(t)
|
k, _ := withConditionsInMemory(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
rs := map[string][]inventory.ResourceHealth{"notes": {foundDirectory("notes", time.Now().Add(-time.Hour))}}
|
rs := map[string][]inventory.ResourceHealth{"notes": {foundDirectory("notes", time.Now().Add(-time.Hour))}}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ func healthyContainer(module string) inventory.ResourceHealth {
|
|||||||
// tools' witness put its build back at once, and the send waits for the healthy module's own verdict — a pass,
|
// tools' witness put its build back at once, and the send waits for the healthy module's own verdict — a pass,
|
||||||
// counted over the gate's spacing — before it says its own. The broken one alone is put back.
|
// counted over the gate's spacing — before it says its own. The broken one alone is put back.
|
||||||
func TestAFaultBeforeTheSettleTimeWaitsForTheModuleBesideIt(t *testing.T) {
|
func TestAFaultBeforeTheSettleTimeWaitsForTheModuleBesideIt(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -100,7 +99,6 @@ const broker_runtime = "node-tools"
|
|||||||
// **A pass is counted only the gate's spacing after the one before** (review (c)): a send judged every tick
|
// **A pass is counted only the gate's spacing after the one before** (review (c)): a send judged every tick
|
||||||
// while a module beside it is not yet healthy counts the healthy one once.
|
// while a module beside it is not yet healthy counts the healthy one once.
|
||||||
func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
|
func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
withGateBounds(t, time.Hour, time.Hour, time.Hour)
|
withGateBounds(t, time.Hour, time.Hour, time.Hour)
|
||||||
@@ -125,7 +123,6 @@ func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
|
|||||||
|
|
||||||
// **A carried build's pass carries its own wait, never another's** (review (e)).
|
// **A carried build's pass carries its own wait, never another's** (review (e)).
|
||||||
func TestACarriedPassCarriesOnlyItsOwnWait(t *testing.T) {
|
func TestACarriedPassCarriesOnlyItsOwnWait(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := b.open.inventory
|
inv := b.open.inventory
|
||||||
@@ -150,7 +147,6 @@ func TestACarriedPassCarriesOnlyItsOwnWait(t *testing.T) {
|
|||||||
// carried its own module, healthy, and a build waiting on that machine that never became healthy. At the
|
// carried its own module, healthy, and a build waiting on that machine that never became healthy. At the
|
||||||
// bound the waiting one is put back and marked; the plan's own module keeps its pass, so no walk waits on it.
|
// bound the waiting one is put back and marked; the plan's own module keeps its pass, so no walk waits on it.
|
||||||
func TestThePlansOwnModuleKeepsItsPassWhenItsSendFails(t *testing.T) {
|
func TestThePlansOwnModuleKeepsItsPassWhenItsSendFails(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
g := aGateMesh(t)
|
g := aGateMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := g.open.inventory
|
inv := g.open.inventory
|
||||||
@@ -231,7 +227,6 @@ func TestThePlansOwnModuleKeepsItsPassWhenItsSendFails(t *testing.T) {
|
|||||||
// **A provider waiting for a login says who waits on it** (review (g)): its consumer, failing a check that
|
// **A provider waiting for a login says who waits on it** (review (g)): its consumer, failing a check that
|
||||||
// needs it, is held under it, and the provider's relogin-needed condition lists it and is urgent.
|
// needs it, is held under it, and the provider's relogin-needed condition lists it and is urgent.
|
||||||
func TestAProviderWaitingForALoginSaysWhoWaitsOnIt(t *testing.T) {
|
func TestAProviderWaitingForALoginSaysWhoWaitsOnIt(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
@@ -294,7 +289,6 @@ func TestAProviderWaitingForALoginSaysWhoWaitsOnIt(t *testing.T) {
|
|||||||
// tools are marked and put back in the very judging that found them broken — their registered build is the
|
// tools are marked and put back in the very judging that found them broken — their registered build is the
|
||||||
// one before, and the laptop is sent it — while `app` goes on being judged, recovers, and keeps its own pass.
|
// one before, and the laptop is sent it — while `app` goes on being judged, recovers, and keeps its own pass.
|
||||||
func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testing.T) {
|
func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -437,7 +431,6 @@ func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testi
|
|||||||
// **A move from a build not known excuses no wait** (issue 318 review): a plan's own module whose previous
|
// **A move from a build not known excuses no wait** (issue 318 review): a plan's own module whose previous
|
||||||
// build was not recorded, or whose previous manifest is not kept, cannot be shown to have added the group.
|
// build was not recorded, or whose previous manifest is not kept, cannot be shown to have added the group.
|
||||||
func TestAMoveFromAnUnknownBuildExcusesNoWait(t *testing.T) {
|
func TestAMoveFromAnUnknownBuildExcusesNoWait(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ func aGateMesh(t *testing.T) *gateMesh {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
g := &gateMesh{open: open, health: map[string]health{}}
|
g := &gateMesh{open: open, health: map[string]health{}}
|
||||||
// The gate's own tests judge a first machine, as walks a controller before novox/hq ADR 0360 started still are.
|
|
||||||
wasGated := gateFirstMachine
|
|
||||||
gateFirstMachine = true
|
|
||||||
t.Cleanup(func() { gateFirstMachine = wasGated })
|
|
||||||
g.keeper, _ = withConditionsInMemory(t)
|
g.keeper, _ = withConditionsInMemory(t)
|
||||||
g.told = &conditions.Told{}
|
g.told = &conditions.Told{}
|
||||||
was := doctorFrom
|
was := doctorFrom
|
||||||
@@ -275,34 +271,6 @@ func TestABuildThatPassesItsGateRollsEverywhereUnattended(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A build rolls everywhere with no hand and no gate (novox/hq ADR 0360 §1): both machines in one send, no health
|
|
||||||
// judged, and the walk done once both reported on the send. Before, anchor was judged first, then laptop sent.
|
|
||||||
func TestABuildRollsToEveryMachineAtOnceWithNoGate(t *testing.T) {
|
|
||||||
g := aGateMesh(t)
|
|
||||||
gateFirstMachine = false
|
|
||||||
ctx := t.Context()
|
|
||||||
judged := false
|
|
||||||
wasGather := gatherGateFacts
|
|
||||||
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
|
|
||||||
judged = true
|
|
||||||
return wasGather(ctx, open, component)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { gatherGateFacts = wasGather })
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
advancePlans(ctx, g.open)
|
|
||||||
}
|
|
||||||
p := g.plan(t)
|
|
||||||
if !reflect.DeepEqual(g.sent, [][]string{{"anchor", "laptop"}}) {
|
|
||||||
t.Fatalf("sent %v, not both machines in one send", g.sent)
|
|
||||||
}
|
|
||||||
if s := p.Modules["app"]; p.State != inventory.PlanDone || s.Gate != nil || s.FirstAt != nil || judged {
|
|
||||||
t.Fatalf("the plan is %s (%s), app %+v, judged %v", p.State, p.Note, s, judged)
|
|
||||||
}
|
|
||||||
if obs, err := probeGates(ctx, doctorFrom); err != nil || len(obs) != 0 {
|
|
||||||
t.Fatalf("a rolled build left a condition: %+v %v", obs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A leaf module passes its gate on one reading, as soon as its first machine is healthy, without waiting out the
|
// A leaf module passes its gate on one reading, as soon as its first machine is healthy, without waiting out the
|
||||||
// core's settle time (novox/hq ADR 0282 decision 4 as ADR 0304 decision 3 changed it, issue 524).
|
// core's settle time (novox/hq ADR 0282 decision 4 as ADR 0304 decision 3 changed it, issue 524).
|
||||||
func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
|
func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import (
|
|||||||
// another send reaches anchor, unreported; the gate still passes. And a send that moves a judged module
|
// another send reaches anchor, unreported; the gate still passes. And a send that moves a judged module
|
||||||
// to another build supersedes the judging: no verdict, nothing put back.
|
// to another build supersedes the judging: no verdict, nothing put back.
|
||||||
func TestReplay352(t *testing.T) {
|
func TestReplay352(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
t.Run("a newer send to the judged machine does not unreport the gate's", testANewerSendDoesNotUnreportTheGatesOwn)
|
t.Run("a newer send to the judged machine does not unreport the gate's", testANewerSendDoesNotUnreportTheGatesOwn)
|
||||||
t.Run("a send that moves the module supersedes the judging", testASendThatMovesTheModuleSupersedesTheJudging)
|
t.Run("a send that moves the module supersedes the judging", testASendThatMovesTheModuleSupersedesTheJudging)
|
||||||
@@ -68,7 +67,6 @@ func testANewerSendDoesNotUnreportTheGatesOwn(t *testing.T) {
|
|||||||
|
|
||||||
// A plan's own first send waits while a release judges the same module on that machine with another build.
|
// A plan's own first send waits while a release judges the same module on that machine with another build.
|
||||||
func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
|
func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -84,7 +82,6 @@ func TestAPlansFirstSendWaitsForAReleaseJudgingTheModuleThere(t *testing.T) {
|
|||||||
|
|
||||||
// A merge plan's judging is superseded the same way: another send moved its module on the first machine.
|
// A merge plan's judging is superseded the same way: another send moved its module on the first machine.
|
||||||
func TestAPlansJudgingIsSupersededByASendThatMovesItsModule(t *testing.T) {
|
func TestAPlansJudgingIsSupersededByASendThatMovesItsModule(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
g := aGateMesh(t)
|
g := aGateMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -143,7 +140,6 @@ func testASendThatMovesTheModuleSupersedesTheJudging(t *testing.T) {
|
|||||||
// A report is on the gate's own send: the declaration itself, or one sequenced after it; a gate kept
|
// A report is on the gate's own send: the declaration itself, or one sequenced after it; a gate kept
|
||||||
// without its send reads the report against the send made last, as before. Pure.
|
// without its send reads the report against the send made last, as before. Pure.
|
||||||
func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
|
func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
sent := inventory.SentDeclaration{Digest: "d-490", Sequence: 490}
|
sent := inventory.SentDeclaration{Digest: "d-490", Sequence: 490}
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
r inventory.Reported
|
r inventory.Reported
|
||||||
@@ -189,7 +185,6 @@ func TestAReportIsHeldAgainstTheGatesOwnSend(t *testing.T) {
|
|||||||
// A gate judges only the build the machine was last sent: last sent another build of the module, the
|
// A gate judges only the build the machine was last sent: last sent another build of the module, the
|
||||||
// judging is superseded, whatever the machine reports. Pure.
|
// judging is superseded, whatever the machine reports. Pure.
|
||||||
func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
|
func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
at := time.Now()
|
at := time.Now()
|
||||||
f := gateFacts{now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor", Outcome: inventory.OutcomeApplied,
|
f := gateFacts{now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor", Outcome: inventory.OutcomeApplied,
|
||||||
At: &at, Current: true}}, engines: map[string]string{}, served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
At: &at, Current: true}}, engines: map[string]string{}, served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
||||||
@@ -217,7 +212,6 @@ func TestAGateJudgesOnlyTheBuildTheMachineWasLastSent(t *testing.T) {
|
|||||||
// A move of another build of a module to a machine where a release or a plan is judging that module
|
// A move of another build of a module to a machine where a release or a plan is judging that module
|
||||||
// waits for that judging; the same build to that machine is already there. Pure.
|
// waits for that judging; the same build to that machine is already there. Pure.
|
||||||
func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
|
func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
at := time.Now()
|
at := time.Now()
|
||||||
release := inventory.Plan{ID: "release-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{
|
release := inventory.Plan{ID: "release-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{
|
||||||
Gate: &inventory.PlanGate{Machines: []string{"novox"}, Carried: []inventory.CarriedMove{
|
Gate: &inventory.PlanGate{Machines: []string{"novox"}, Carried: []inventory.CarriedMove{
|
||||||
@@ -255,7 +249,6 @@ func TestAWalkWaitsForAJudgingOfTheSameModuleOnThatMachine(t *testing.T) {
|
|||||||
// The controller is never put back to a build that reaches less of the store's schema than the store
|
// The controller is never put back to a build that reaches less of the store's schema than the store
|
||||||
// has, or to one that never said what it reaches: the current build is kept, and the condition says so.
|
// has, or to one that never said what it reaches: the current build is kept, and the condition says so.
|
||||||
func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T) {
|
func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
@@ -352,7 +345,6 @@ func TestTheControllerIsNotPutBackToABuildOlderThanTheStoresSchema(t *testing.T)
|
|||||||
|
|
||||||
// The lease's holder names the build the declaration told it it is, and the version stamp only without one.
|
// The lease's holder names the build the declaration told it it is, and the version stamp only without one.
|
||||||
func TestTheHolderNamesTheBuildTheDeclarationToldIt(t *testing.T) {
|
func TestTheHolderNamesTheBuildTheDeclarationToldIt(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
t.Setenv(RunningBuildVar, " ad62528c47c7 ")
|
t.Setenv(RunningBuildVar, " ad62528c47c7 ")
|
||||||
if h := holderOf("x"); h.Build != "ad62528c47c7" {
|
if h := holderOf("x"); h.Build != "ad62528c47c7" {
|
||||||
t.Fatalf("the holder's build is %q", h.Build)
|
t.Fatalf("the holder's build is %q", h.Build)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ func TestANoMoveTierWaitsForNoNewReport(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := g.open.inventory
|
inv := g.open.inventory
|
||||||
|
|
||||||
// app holds the build seat, so a module of the next tier is built by it; since novox/hq ADR 0360 §3 that gates
|
// app is the build machine, so a module of the next tier is built by it: the tier gates the next on app
|
||||||
// nothing.
|
// running on its machines.
|
||||||
for _, m := range []catalogue.Manifest{
|
for _, m := range []catalogue.Manifest{
|
||||||
{Module: "app", Version: "c2", Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}},
|
{Module: "app", Version: "c2", Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}},
|
||||||
{Module: "web", Version: "w1"},
|
{Module: "web", Version: "w1"},
|
||||||
@@ -71,8 +71,7 @@ func TestANoMoveTierWaitsForNoNewReport(t *testing.T) {
|
|||||||
t.Fatalf("the walk waits on a no-move tier for a report nothing will cause: tier %d, %s %q", p.Tier, p.State, p.Note)
|
t.Fatalf("the walk waits on a no-move tier for a report nothing will cause: tier %d, %s %q", p.Tier, p.State, p.Note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A machine that has not answered its last send is not waited on either: a built-by edge gates no tier
|
// A machine that has not answered its last send is still waited on: no move is not "never sent".
|
||||||
// (novox/hq ADR 0360 §3). Before, the walk waited for app on laptop.
|
|
||||||
p.Tier, p.State, p.Note = 0, inventory.PlanRolling, ""
|
p.Tier, p.State, p.Note = 0, inventory.PlanRolling, ""
|
||||||
if err := inv.RecordSent(ctx, nodeID(t, g.open, "laptop"), "d-laptop-later", map[string]string{"app": "c1"}); err != nil {
|
if err := inv.RecordSent(ctx, nodeID(t, g.open, "laptop"), "d-laptop-later", map[string]string{"app": "c1"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -82,7 +81,7 @@ func TestANoMoveTierWaitsForNoNewReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
advancePlans(ctx, g.open)
|
advancePlans(ctx, g.open)
|
||||||
p = g.plan(t)
|
p = g.plan(t)
|
||||||
if p.Tier < 1 || strings.Contains(p.Note, "waiting for app") {
|
if p.Tier != 0 || !strings.Contains(p.Note, "waiting for app on laptop") {
|
||||||
t.Fatalf("the walk waits on a built-by edge: tier %d, %q", p.Tier, p.Note)
|
t.Fatalf("a machine that has not answered its last send was not waited on: tier %d, %q", p.Tier, p.Note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq ADR 0360 §4, §5: a walk ends on its machines' reports — every machine sent has reported on its send,
|
|
||||||
// or is silent past ApplySilentAfter — and names a failed or refused apply; it never waits for a health reading.
|
|
||||||
func TestAWalkEndsOnItsMachinesReports(t *testing.T) {
|
|
||||||
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
||||||
sent := now.Add(-time.Minute)
|
|
||||||
long := now.Add(-inventory.ApplySilentAfter - time.Minute)
|
|
||||||
at := now.Add(-10 * time.Second)
|
|
||||||
p := inventory.Plan{Modules: map[string]*inventory.PlanModule{
|
|
||||||
"app": {Rest: map[string]inventory.SentDeclaration{
|
|
||||||
"ace": {Digest: "d-ace", At: sent},
|
|
||||||
"novox": {Digest: "d-novox", At: sent},
|
|
||||||
"shanks": {Digest: "d-shanks", At: sent},
|
|
||||||
"laptop": {Digest: "d-laptop", At: long},
|
|
||||||
}},
|
|
||||||
"proxy": {Rest: map[string]inventory.SentDeclaration{"ace": {Digest: "d-ace", At: sent}}},
|
|
||||||
}}
|
|
||||||
reports := []inventory.Reported{
|
|
||||||
{Node: "ace", Outcome: inventory.OutcomeApplied, At: &at, Declared: "d-ace"},
|
|
||||||
{Node: "novox", Outcome: inventory.OutcomeFailed, At: &at, Declared: "d-novox"},
|
|
||||||
{Node: "shanks", Outcome: inventory.OutcomeApplied, At: &at, Declared: "d-before"},
|
|
||||||
}
|
|
||||||
waiting, failed, silent := walkReports(p, reports, now)
|
|
||||||
if !slices.Equal(waiting, []string{"shanks"}) || !slices.Equal(failed, []string{"novox"}) ||
|
|
||||||
!slices.Equal(silent, []string{"laptop"}) {
|
|
||||||
t.Fatalf("waiting %v, failed %v, silent %v", waiting, failed, silent)
|
|
||||||
}
|
|
||||||
reports[2].Declared = "d-shanks"
|
|
||||||
if waiting, _, _ := walkReports(p, reports, now); len(waiting) != 0 {
|
|
||||||
t.Fatalf("the walk still waits once every machine reported: %v", waiting)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withTheGate turns the first-machine gate on for a test of its own path: kept for the walks a controller before
|
|
||||||
// novox/hq ADR 0360 started.
|
|
||||||
func withTheGate(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
was := gateFirstMachine
|
|
||||||
gateFirstMachine = true
|
|
||||||
t.Cleanup(func() { gateFirstMachine = was })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every machine heard from is sent at once and passed on its applied report, with no judging (novox/hq ADR 0360 §7);
|
|
||||||
// before, the first machine was judged before the rest were sent (ADR 0353 §1).
|
|
||||||
func TestAReleaseSendsEveryMachineAtOnce(t *testing.T) {
|
|
||||||
judgeLeavesAsTheCore(t)
|
|
||||||
b := aBacklogOnThree(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
said := events(t)
|
|
||||||
for i := 0; i < 12; i++ {
|
|
||||||
advancePlans(ctx, b.open)
|
|
||||||
}
|
|
||||||
p := b.release(t)
|
|
||||||
if p.State != inventory.PlanDone || len(p.Release.Done) != 3 {
|
|
||||||
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
|
|
||||||
}
|
|
||||||
for _, n := range []string{"desk", "laptop", "anchor"} {
|
|
||||||
if !slices.Contains(*said, "send "+n) {
|
|
||||||
t.Fatalf("%s was not sent: %v", n, *said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slices.Contains(*said, "judge") {
|
|
||||||
t.Fatalf("a machine was judged by its health: %v", *said)
|
|
||||||
}
|
|
||||||
if v, found, err := b.open.inventory.GateOf(ctx, "build-late-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
|
|
||||||
t.Fatalf("late's pass on laptop was not kept: %+v %v %v", v, found, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A machine that reports its apply failed is named and fails the release, with nothing put back, and every other
|
|
||||||
// machine goes on to its own report (novox/hq ADR 0360 §5, §7); before, it was put back (ADR 0353 §3).
|
|
||||||
func TestAMachineThatFailsItsApplyIsNamedAndNothingIsPutBack(t *testing.T) {
|
|
||||||
judgeLeavesAsTheCore(t)
|
|
||||||
b := aBacklogOnThree(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
inv := b.open.inventory
|
|
||||||
b.broken["desk"] = true // desk fails what it is sent
|
|
||||||
advancePlans(ctx, b.open)
|
|
||||||
for i := 0; i < 12 && b.release(t).Open(); i++ {
|
|
||||||
advancePlans(ctx, b.open)
|
|
||||||
}
|
|
||||||
p := b.release(t)
|
|
||||||
if p.State != inventory.PlanFailed || !slices.Equal(p.Release.Failed, []string{"desk"}) {
|
|
||||||
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
|
|
||||||
}
|
|
||||||
if !slices.Contains(p.Release.Done, "laptop") || !slices.Contains(p.Release.Done, "anchor") {
|
|
||||||
t.Fatalf("the others have no verdict of their own: %+v", p.Release)
|
|
||||||
}
|
|
||||||
if v, found, err := inv.GateOf(ctx, "build-late-c2"); err != nil || !found || v.Verdict != inventory.GatePassed {
|
|
||||||
t.Fatalf("late's pass on laptop was not kept: %+v %v %v", v, found, err)
|
|
||||||
}
|
|
||||||
if sent, _, _ := inv.SentBuilds(ctx, "desk"); sent["app"] != "c2" {
|
|
||||||
t.Fatalf("desk was put back: %v", sent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A release's send kept before it was made, and made, is judged by the controller that takes over without being
|
|
||||||
// made again (ADR 0353 §8), with every machine sent at once (ADR 0360 §7).
|
|
||||||
func TestAReleaseSendKeptAndMadeIsNotMadeAgainAtOnce(t *testing.T) {
|
|
||||||
judgeLeavesAsTheCore(t)
|
|
||||||
b := aBacklog(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
inv := b.open.inventory
|
|
||||||
p, err := releaseBacklog(ctx, b.open, "")
|
|
||||||
if err != nil || p == nil {
|
|
||||||
t.Fatalf("no release opened: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := advanceRelease(ctx, b.open, p); err != nil || p.Release.Gates["anchor"] == nil || !p.Release.Gates["anchor"].Pending {
|
|
||||||
t.Fatalf("the send was not kept before it was made: %+v %v", p.Release, err)
|
|
||||||
}
|
|
||||||
if err := inv.SavePlan(ctx, p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// The send is made, and the controller is replaced before it keeps it.
|
|
||||||
if _, err := sendRollout(ctx, b.open, []string{"anchor"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
advancePlans(ctx, b.open)
|
|
||||||
if n := len(slices.DeleteFunc(slices.Clone(b.sent), func(s []string) bool { return !slices.Equal(s, []string{"anchor"}) })); n != 1 {
|
|
||||||
t.Fatalf("anchor was sent again: %v", b.sent)
|
|
||||||
}
|
|
||||||
g := b.release(t).Release.Gates["anchor"]
|
|
||||||
if g == nil || g.Pending || g.Since == nil || len(g.Carried) == 0 {
|
|
||||||
t.Fatalf("anchor is not judged on the send made: %+v", g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A machine of the rest that says nothing on its send is left silent after fifteen minutes: not failed, not put
|
|
||||||
// back; a rest that went silent whole ends failed, said as a condition (ADR 0353 §4); its step also sent anchor, so the release is done, not failed (ADR 0360 §7).
|
|
||||||
func TestAMachineSilentPastItsBoundIsLeftAndSaidAtOnce(t *testing.T) {
|
|
||||||
judgeLeavesAsTheCore(t)
|
|
||||||
b := aBacklog(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
inv := b.open.inventory
|
|
||||||
reporting := sendRollout
|
|
||||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
|
||||||
if slices.Equal(names, []string{"laptop"}) { // laptop sleeps: sent, and says nothing
|
|
||||||
b.sent = append(b.sent, names)
|
|
||||||
current, _ := open.inventory.CurrentBuilds(ctx)
|
|
||||||
return names, open.inventory.RecordSent(ctx, nodeID(t, open, "laptop"), "d-laptop-asleep",
|
|
||||||
map[string]string{"app": current["app"].Commit, "late": current["late"].Commit, "same": current["same"].Commit})
|
|
||||||
}
|
|
||||||
return reporting(ctx, open, names)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { sendRollout = reporting })
|
|
||||||
for i := 0; i < 8; i++ {
|
|
||||||
advancePlans(ctx, b.open)
|
|
||||||
}
|
|
||||||
p := b.release(t)
|
|
||||||
g := p.Release.Gates["laptop"]
|
|
||||||
if !p.Open() || g == nil || g.Verdict != "" || g.Silent {
|
|
||||||
t.Fatalf("laptop is judged before fifteen minutes: %s %s %+v", p.State, p.Note, g)
|
|
||||||
}
|
|
||||||
since := time.Now().Add(-inventory.ApplySilentAfter - time.Minute)
|
|
||||||
g.Since = &since
|
|
||||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
advancePlans(ctx, b.open)
|
|
||||||
p = b.release(t)
|
|
||||||
// anchor applied in the same step, so the release is done, laptop named silent (novox/hq ADR 0360 §7).
|
|
||||||
if p.State != inventory.PlanDone || !slices.Equal(p.Release.Silent, []string{"laptop"}) || len(p.Release.Failed) > 0 {
|
|
||||||
t.Fatalf("the release is %s: %s %+v", p.State, p.Note, p.Release)
|
|
||||||
}
|
|
||||||
if failed, _ := inv.GateFailed(ctx, "build-late-c2"); failed {
|
|
||||||
t.Fatal("a silent machine's build was marked failed")
|
|
||||||
}
|
|
||||||
if sent, _, _ := inv.SentBuilds(ctx, "laptop"); sent["late"] != "c2" {
|
|
||||||
t.Fatalf("laptop was put back: %v", sent)
|
|
||||||
}
|
|
||||||
obs, err := silentObservation(ctx, inv)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(obs) != 1 || obs[0].Kind != "release-silent" || !slices.Equal(obs[0].Also, []string{"laptop"}) {
|
|
||||||
t.Fatalf("the silence is not said: %+v", obs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -550,8 +550,17 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
|
|||||||
out[k] = m
|
out[k] = m
|
||||||
}
|
}
|
||||||
var failures []string
|
var failures []string
|
||||||
|
// The list of predefined keys at the tree's root (novox/hq ADR 0348 §7): what the build of this tree judges each
|
||||||
|
// manifest against and registration publishes, so what the gate judges each against too. A list that is
|
||||||
|
// wrong is refused by name, and every manifest is then judged against none.
|
||||||
|
predefined, err := catalogue.ReadPredefined(tree)
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
|
||||||
|
catalogue.PredefinedFile, version, oneLine(err.Error())))
|
||||||
|
predefined = nil
|
||||||
|
}
|
||||||
found := map[string]string{} // module → its directory in the tree
|
found := map[string]string{} // module → its directory in the tree
|
||||||
err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error {
|
err = filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -575,7 +584,12 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m, err := catalogue.ParseManifest(raw)
|
m, err := catalogue.ParseManifestWith(raw, predefined)
|
||||||
|
if err == nil {
|
||||||
|
// Each template's text from the tree beside the manifest, as the build reads it back from the archive it
|
||||||
|
// packs (novox/hq ADR 0349 §2): so a change to a template alone is a change to its module.
|
||||||
|
m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(path))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if inScope(dir) {
|
if inScope(dir) {
|
||||||
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
|
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
|
||||||
@@ -605,7 +619,7 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
|
|||||||
// As registration reads a built manifest: strictly, again (takeIn).
|
// As registration reads a built manifest: strictly, again (takeIn).
|
||||||
var raw []byte
|
var raw []byte
|
||||||
if raw, err = json.Marshal(resolved); err == nil {
|
if raw, err = json.Marshal(resolved); err == nil {
|
||||||
resolved, err = catalogue.ParseManifest(raw)
|
resolved, err = catalogue.ParseManifestWith(raw, predefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -33,7 +33,21 @@ import (
|
|||||||
func aMesh(t *testing.T) *stores {
|
func aMesh(t *testing.T) *stores {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
inventory.ForTest(t) // raises the store, migrates it, and points the environment at it
|
inventory.ForTest(t) // raises the store, migrates it, and points the environment at it
|
||||||
licences.ForTest(t) // planning reaches this one too, by name and never by connection
|
return aMeshOnTheStore(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aMeshBefore is aMesh on a store migrated only below migration `number`, with the function that runs the
|
||||||
|
// rest: for a test of what a migration that moves data does to what the mesh composes (novox/hq issue 651).
|
||||||
|
func aMeshBefore(t *testing.T, number int) (*stores, func()) {
|
||||||
|
t.Helper()
|
||||||
|
_, migrate := inventory.ForTestBefore(t, number)
|
||||||
|
return aMeshOnTheStore(t), migrate
|
||||||
|
}
|
||||||
|
|
||||||
|
// aMeshOnTheStore is the mesh aMesh makes, on the store the environment points at.
|
||||||
|
func aMeshOnTheStore(t *testing.T) *stores {
|
||||||
|
t.Helper()
|
||||||
|
licences.ForTest(t) // planning reaches this one too, by name and never by connection
|
||||||
|
|
||||||
open, err := openStores(t.Context())
|
open, err := openStores(t.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func owedToTheRecord(ctx context.Context, announced merges, m link.SourceMoved,
|
|||||||
|
|
||||||
// catchingUpOnMerges reads back the forge's announcements on a timer, until the context ends.
|
// catchingUpOnMerges reads back the forge's announcements on a timer, until the context ends.
|
||||||
func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
func catchingUpOnMerges(ctx context.Context, open *stores, announced merges) {
|
||||||
f := following{open: open}
|
f := following{open}
|
||||||
catalogued := func(ctx context.Context) ([]inventory.Entry, map[string][]inventory.ReadRepository, error) {
|
catalogued := func(ctx context.Context) ([]inventory.Entry, map[string][]inventory.ReadRepository, error) {
|
||||||
entries, err := open.inventory.Catalogued(ctx)
|
entries, err := open.inventory.Catalogued(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -240,7 +240,6 @@ func judgeModuleHealth(ctx context.Context, inv *inventory.Inventory, k *conditi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
o := moduleUnhealthyObservation(m, node, unhealthy[m])
|
o := moduleUnhealthyObservation(m, node, unhealthy[m])
|
||||||
declaredSeverity(&o, hold, m)
|
|
||||||
if hold != nil {
|
if hold != nil {
|
||||||
if by, held := hold.heldWith(node, m, unhealthy[m]); held {
|
if by, held := hold.heldWith(node, m, unhealthy[m]); held {
|
||||||
// Held under the provider's condition: nothing of its own, and the provider's says it waits. The
|
// Held under the provider's condition: nothing of its own, and the provider's says it waits. The
|
||||||
@@ -348,7 +347,6 @@ func sayWaiters(ctx context.Context, k *conditions.Keeper, hold *holding, p cata
|
|||||||
} else {
|
} else {
|
||||||
_, rest := besideAWait(p.Module, rs)
|
_, rest := besideAWait(p.Module, rs)
|
||||||
o = moduleUnhealthyObservation(p.Module, p.Node, rest)
|
o = moduleUnhealthyObservation(p.Module, p.Node, rest)
|
||||||
declaredSeverity(&o, hold, p.Module)
|
|
||||||
if said, waits := personWait(p.Module, p.Node, rest); waits {
|
if said, waits := personWait(p.Module, p.Node, rest); waits {
|
||||||
o = reloginObservation(p.Module, p.Node, said, operatorOn(ctx, hold.inv, p.Node), rest)
|
o = reloginObservation(p.Module, p.Node, said, operatorOn(ctx, hold.inv, p.Node), rest)
|
||||||
}
|
}
|
||||||
@@ -483,19 +481,6 @@ func sayWaitingOn(o *conditions.Observation, waiters []string) {
|
|||||||
o.Explanation += fmt.Sprintf(" %d module(s) that depend on it wait for it.", len(waiters))
|
o.Explanation += fmt.Sprintf(" %d module(s) that depend on it wait for it.", len(waiters))
|
||||||
}
|
}
|
||||||
|
|
||||||
// declaredSeverity raises a module's unhealthy condition urgent when its manifest declares `unhealthy` urgent
|
|
||||||
// (novox/hq ADR 0355 §1). Only that condition changes (ADR 0355 §4): a wait for a new login, a module that needs
|
|
||||||
// the operator and a directory used as found keep their severities, and without the field, or without a manifest
|
|
||||||
// to read it from, it stays the warning it was.
|
|
||||||
func declaredSeverity(o *conditions.Observation, hold *holding, module string) {
|
|
||||||
if o.Kind != kindModuleUnhealthy || hold == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if m, ok := hold.manifestOf(module); ok && m.UnhealthyIsUrgent() {
|
|
||||||
o.Severity = conditions.Urgent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// moduleUnhealthyObservation is a module unhealthy on a machine, in words: the summary names the module,
|
// moduleUnhealthyObservation is a module unhealthy on a machine, in words: the summary names the module,
|
||||||
// the machine and what is wrong with each resource; the detail — targets, streaks, since — is evidence.
|
// the machine and what is wrong with each resource; the detail — targets, streaks, since — is evidence.
|
||||||
func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHealth) conditions.Observation {
|
func moduleUnhealthyObservation(module, node string, rs []inventory.ResourceHealth) conditions.Observation {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -127,6 +128,10 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// A manifest handed over by hand takes each template's text from the tree beside it (novox/hq ADR 0349 §2).
|
||||||
|
if m, err = catalogue.WithTemplatesFrom(m, filepath.Dir(positionals[0])); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self)
|
from, err := whereItComesFrom(*repo, *ref, *commit, *path, *self)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -537,6 +542,10 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
if p.SetBy != "" {
|
if p.SetBy != "" {
|
||||||
by = "; " + p.SetBy
|
by = "; " + p.SetBy
|
||||||
}
|
}
|
||||||
|
if p.Why != "" {
|
||||||
|
// A layer whose placement moved out of it (migration 0097): still the layer, as it stands.
|
||||||
|
by += "; " + p.Why
|
||||||
|
}
|
||||||
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
|
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
|
||||||
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
|
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ func userUnit(module, account string) inventory.ResourceHealth {
|
|||||||
// Only what depends on the new login alone is a wait; anything else unhealthy of the module is judged as
|
// Only what depends on the new login alone is a wait; anything else unhealthy of the module is judged as
|
||||||
// before, so that a fault is never excused.
|
// before, so that a fault is never excused.
|
||||||
func TestOnlyWhatDependsOnTheNewLoginIsAWait(t *testing.T) {
|
func TestOnlyWhatDependsOnTheNewLoginIsAWait(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
notInGroup := relogin("lights", "operator")
|
notInGroup := relogin("lights", "operator")
|
||||||
notInGroup.Reason = "not in the group lights: the apply has not put operator there; its outcome says why"
|
notInGroup.Reason = "not in the group lights: the apply has not put operator there; its outcome says why"
|
||||||
systemUnit := userUnit("lights", "operator")
|
systemUnit := userUnit("lights", "operator")
|
||||||
@@ -75,7 +74,6 @@ func TestOnlyWhatDependsOnTheNewLoginIsAWait(t *testing.T) {
|
|||||||
// The gate reads a wait for a person as its own reading, never a fault, and passes with it; the same module
|
// The gate reads a wait for a person as its own reading, never a fault, and passes with it; the same module
|
||||||
// with a container down beside it is not yet healthy, as before.
|
// with a container down beside it is not yet healthy, as before.
|
||||||
func TestAWaitForAPersonIsAPassCarriedAlong(t *testing.T) {
|
func TestAWaitForAPersonIsAPassCarriedAlong(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
since := now.Add(-time.Minute)
|
since := now.Add(-time.Minute)
|
||||||
account := relogin("lights", "operator")
|
account := relogin("lights", "operator")
|
||||||
@@ -104,7 +102,6 @@ func TestAWaitForAPersonIsAPassCarriedAlong(t *testing.T) {
|
|||||||
// The module's condition says the wait in one sentence for a person, and clears on the first statement that
|
// The module's condition says the wait in one sentence for a person, and clears on the first statement that
|
||||||
// no longer says it — said as the module's own fault when its unit still fails after the new login.
|
// no longer says it — said as the module's own fault when its unit still fails after the new login.
|
||||||
func TestTheReloginConditionSaysTheWaitAndClearsAfterTheLogin(t *testing.T) {
|
func TestTheReloginConditionSaysTheWaitAndClearsAfterTheLogin(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
k, _ := withConditionsInMemory(t)
|
k, _ := withConditionsInMemory(t)
|
||||||
@@ -161,7 +158,6 @@ func TestTheReloginConditionSaysTheWaitAndClearsAfterTheLogin(t *testing.T) {
|
|||||||
// A module that is healthy on its own keeps its pass when another module of the same send fails at the
|
// A module that is healthy on its own keeps its pass when another module of the same send fails at the
|
||||||
// bound: it is neither put back nor left without a verdict for every other walk to wait on.
|
// bound: it is neither put back nor left without a verdict for every other walk to wait on.
|
||||||
func TestAModuleHealthyOnItsOwnKeepsItsPassWhenItsSendFails(t *testing.T) {
|
func TestAModuleHealthyOnItsOwnKeepsItsPassWhenItsSendFails(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := b.open.inventory
|
inv := b.open.inventory
|
||||||
@@ -211,7 +207,6 @@ func TestAModuleHealthyOnItsOwnKeepsItsPassWhenItsSendFails(t *testing.T) {
|
|||||||
// What the operator reads of the wait (ADR 0253, ADR 0254): it needs them, so it is never quiet, and it offers
|
// What the operator reads of the wait (ADR 0253, ADR 0254): it needs them, so it is never quiet, and it offers
|
||||||
// no button, since nothing but their own new login can do it.
|
// no button, since nothing but their own new login can do it.
|
||||||
func TestTheReloginConditionNeedsTheOperatorAndOffersNoButton(t *testing.T) {
|
func TestTheReloginConditionNeedsTheOperatorAndOffersNoButton(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
o := reloginObservation("openrazer", "g14", "relogin needed on g14: …", "operator", []inventory.ResourceHealth{
|
o := reloginObservation("openrazer", "g14", "relogin needed on g14: …", "operator", []inventory.ResourceHealth{
|
||||||
relogin("openrazer", "operator"), userUnit("openrazer", "operator")})
|
relogin("openrazer", "operator"), userUnit("openrazer", "operator")})
|
||||||
plainExample(t, o, "openrazer waits for a new login on g14",
|
plainExample(t, o, "openrazer waits for a new login on g14",
|
||||||
@@ -243,7 +238,6 @@ func TestTheReloginConditionNeedsTheOperatorAndOffersNoButton(t *testing.T) {
|
|||||||
// When a module not working turns into one waiting for a new login, and back, the clearing line says what it
|
// When a module not working turns into one waiting for a new login, and back, the clearing line says what it
|
||||||
// became, never that it works again (issue 318 review).
|
// became, never that it works again (issue 318 review).
|
||||||
func TestAConditionThatBecameTheOtherKindSaysSoWhenItClears(t *testing.T) {
|
func TestAConditionThatBecameTheOtherKindSaysSoWhenItClears(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
store := conditions.NewInMemory()
|
store := conditions.NewInMemory()
|
||||||
@@ -292,7 +286,6 @@ func TestAConditionThatBecameTheOtherKindSaysSoWhenItClears(t *testing.T) {
|
|||||||
|
|
||||||
// Which moves add an account group, read from the builds' manifests (issue 318 review).
|
// Which moves add an account group, read from the builds' manifests (issue 318 review).
|
||||||
func TestOnlyAMoveThatAddsAnAccountGroupCanBringAWait(t *testing.T) {
|
func TestOnlyAMoveThatAddsAnAccountGroupCanBringAWait(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
user := func(groups ...any) catalogue.Manifest {
|
user := func(groups ...any) catalogue.Manifest {
|
||||||
return catalogue.Manifest{Module: "lights", Resources: []map[string]any{{"id": "operator", "type": "user",
|
return catalogue.Manifest{Module: "lights", Resources: []map[string]any{{"id": "operator", "type": "user",
|
||||||
"name": "operator", "groups": groups}}}
|
"name": "operator", "groups": groups}}}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **Placement leaves the settings layers and no machine is told anything new** (novox/hq ADR 0348 clause
|
||||||
|
// 11, issue 651). A mesh is seeded as the store holds it before migration 0097 — `places`, `accesses` and a
|
||||||
|
// kept network among the settings, at the mesh's level and a machine's, one layer of placement only — then
|
||||||
|
// migrated, and every machine's declaration is composed. What each machine is told of the seeded modules is
|
||||||
|
// byte for byte what the controller before this change composed from the same seed: the golden files were
|
||||||
|
// written by the integration branch's controller at bfc79c6 running this same test (MESH_WRITE_GOLDEN=1),
|
||||||
|
// where there was no migration 0097 to run.
|
||||||
|
func TestADeclarationIsTheSameAfterPlacementMoves(t *testing.T) {
|
||||||
|
open, migrate := aMeshBefore(t, 97)
|
||||||
|
ctx := t.Context()
|
||||||
|
inv := open.inventory
|
||||||
|
image := "registry.invalid:5000/library@sha256:" + strings.Repeat("a", 64)
|
||||||
|
register(t, open, catalogue.Manifest{Module: "library", Version: "1",
|
||||||
|
Accesses: []catalogue.Access{{ID: "series"}, {ID: "downloads", Mode: catalogue.AccessRead}},
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "config", "type": "directory", "mode": "0750"},
|
||||||
|
{"id": "server", "type": "container", "image": image,
|
||||||
|
"volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:downloads}:/downloads:ro"}},
|
||||||
|
// Today's form: a mergeable file naming no keys takes every key of the layer (ADR 0326 §3).
|
||||||
|
{"id": "runtime-config", "type": "file", "path": "${dir:config}/runtime.json", "mode": "0640",
|
||||||
|
"content": `{"log":"info"}`, "merge": "json"},
|
||||||
|
}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "found", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "image": image}}})
|
||||||
|
if err := inv.SetAdopted(ctx, "anchor", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, l := range []struct{ node, module, values string }{
|
||||||
|
{"", "library", `{"accesses": {"downloads": "/storage/downloads"}, "log": "warn"}`},
|
||||||
|
{"anchor", "library", `{"places": {"config": {"path": "/var/lib/library/config", "owner": "1001:2000"}},
|
||||||
|
"accesses": {"series": "/storage/media/series"}, "workers": 4}`},
|
||||||
|
{"laptop", "library", `{"accesses": {"series": "/home/media/series"}}`},
|
||||||
|
{"anchor", "found", `{"networks": {"server": ["predecessor_default"]}}`},
|
||||||
|
} {
|
||||||
|
if l.node == "" {
|
||||||
|
inv.ExecForTest(t, `insert into settings (node, module, values) values (null, $1, $2)`, l.module, l.values)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inv.ExecForTest(t, `insert into settings (node, module, values) select id, $2, $3 from node where name = $1`,
|
||||||
|
l.node, l.module, l.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate()
|
||||||
|
// Assigned once the store is migrated: an assignment reads the layers, as this controller holds them.
|
||||||
|
for _, a := range [][]string{{"anchor", "library", "found"}, {"laptop", "library"}} {
|
||||||
|
if _, err := assign(ctx, open, a[0], a[1:]...); err != nil {
|
||||||
|
t.Fatalf("assign %v: %v", a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gens, err := generators(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, node := range []string{"anchor", "laptop"} {
|
||||||
|
plan, settings, err := planFor(ctx, open, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
with, _, err := renderingFor(ctx, open, node, plan, settings, gens, Allocating)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
composed, err := plan.Compose(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(composed.LeftOut) > 0 {
|
||||||
|
t.Fatalf("%s leaves out %v", node, composed.LeftOut)
|
||||||
|
}
|
||||||
|
var told []map[string]any
|
||||||
|
for _, r := range composed.Resources {
|
||||||
|
if owner := composed.Owner[r["id"].(string)]; owner == "library" || owner == "found" {
|
||||||
|
told = append(told, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(told, func(i, j int) bool { return told[i]["id"].(string) < told[j]["id"].(string) })
|
||||||
|
got, err := json.MarshalIndent(told, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
golden := filepath.Join("testdata", "placement-moved", node+".json")
|
||||||
|
if os.Getenv("MESH_WRITE_GOLDEN") != "" {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(golden, append(got, '\n'), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want, err := os.ReadFile(golden)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(want) != string(got)+"\n" {
|
||||||
|
t.Errorf("%s is told something else after placement moved:\n%s\nwas:\n%s", node, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -620,18 +620,6 @@ var plainWordings = map[string]func(conditions.Observation) words{
|
|||||||
Explanation: "The bus reports a listener too slow to keep up, so messages to it are late.",
|
Explanation: "The bus reports a listener too slow to keep up, so messages to it are late.",
|
||||||
Resolved: "The listener keeps up again"}
|
Resolved: "The listener keeps up again"}
|
||||||
}),
|
}),
|
||||||
kindBusFaultsUnread: worded(func(o conditions.Observation) words {
|
|
||||||
return words{Headline: "Slow clients of the bus cannot be checked",
|
|
||||||
Explanation: "The controller could not read what the bus said about its clients' connections, so a " +
|
|
||||||
"client the bus cut off would go unnoticed. It tries again at every check.",
|
|
||||||
Resolved: "Resolved: the bus's clients are checked again"}
|
|
||||||
}),
|
|
||||||
"connection-closed": worded(func(o conditions.Observation) words {
|
|
||||||
return words{Headline: "The bus cut a client off",
|
|
||||||
Explanation: "The bus closed a client's connection because it broke the bus's rules, so what it was " +
|
|
||||||
"sending was lost.",
|
|
||||||
Resolved: "Resolved: the client keeps to the bus's rules"}
|
|
||||||
}),
|
|
||||||
kindBusReconnects: worded(func(o conditions.Observation) words {
|
kindBusReconnects: worded(func(o conditions.Observation) words {
|
||||||
return words{Headline: "A client keeps losing the bus",
|
return words{Headline: "A client keeps losing the bus",
|
||||||
Explanation: "One of the mesh's clients lost its connection to the bus again and again in the last hour. " +
|
Explanation: "One of the mesh's clients lost its connection to the bus again and again in the last hour. " +
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func TestASharedRepositoryIsPlannedFromTheRecordedBuildSources(t *testing.T) {
|
|||||||
{"the route proxy's program: the route proxy alone", "mesh-controller",
|
{"the route proxy's program: the route proxy alone", "mesh-controller",
|
||||||
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
|
[]string{"examples/route-proxy/main.go"}, "route-proxy", true},
|
||||||
{"a package all three build from: all three", "mesh-controller",
|
{"a package all three build from: all three", "mesh-controller",
|
||||||
[]string{"internal/broker/broker.go"}, "mesh-controller,route-proxy | build-agent", false},
|
[]string{"internal/broker/broker.go"}, "mesh-controller | build-agent | route-proxy", false},
|
||||||
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
{"the route proxy's directory in the catalogue: it alone", "mesh-catalog",
|
||||||
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
[]string{"modules/route-proxy/module.json"}, "route-proxy", false},
|
||||||
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
{"the build agent's directory: it alone, nothing it builds", "mesh-catalog",
|
||||||
|
|||||||
@@ -145,9 +145,8 @@ func TestAPlanIsWhatTheChangeTouchedAndWhatIsBuiltOnIt(t *testing.T) {
|
|||||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
||||||
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
{what: "built-by never widens", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||||
// Whichever builder runs builds it (novox/hq ADR 0360 §3): built-by orders nothing, as before it ordered.
|
{what: "built-by orders", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
||||||
{what: "built-by does not order", edges: []inventory.Edge{dep("x", builtBy, "a")},
|
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a | x"},
|
||||||
repo: "one", paths: []string{"modules/a/x", "modules/x/y"}, want: "a,x"},
|
|
||||||
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
{what: "worker-of never widens", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
||||||
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
repo: "one", paths: []string{"modules/a/x"}, want: "a"},
|
||||||
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
{what: "worker-of orders", edges: []inventory.Edge{dep("x", workerOf, "a")},
|
||||||
@@ -297,18 +296,18 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
|||||||
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
|
[]string{"internal/inventory/migrations/0088-a-build-says-its-build-source.sql"}, read,
|
||||||
"mesh-controller | build-agent", ""},
|
"mesh-controller | build-agent", ""},
|
||||||
// A package all three build from: all three; the build seat's holder after the controller whose worker
|
// A package all three build from: all three; the build seat's holder after the controller whose worker
|
||||||
// it binds (worker-of, issue 206), the proxy beside the controller: built-by orders nothing (ADR 0360 §3).
|
// it binds (worker-of, issue 206), the proxy after the holder that builds it (built-by).
|
||||||
{"a package all three build from: all three", "mesh-controller",
|
{"a package all three build from: all three", "mesh-controller",
|
||||||
[]string{"internal/broker/broker.go"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
[]string{"internal/broker/broker.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
||||||
{"a vendored package all three build from: all three", "mesh-controller",
|
{"a vendored package all three build from: all three", "mesh-controller",
|
||||||
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
[]string{"vendor/github.com/nats-io/nats.go/nats.go"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
||||||
{"go.sum: all three", "mesh-controller",
|
{"go.sum: all three", "mesh-controller",
|
||||||
[]string{"go.sum"}, read, "mesh-controller,route-proxy | build-agent", ""},
|
[]string{"go.sum"}, read, "mesh-controller | build-agent | route-proxy", ""},
|
||||||
{"a file added to the proxy's package: the proxy", "mesh-controller",
|
{"a file added to the proxy's package: the proxy", "mesh-controller",
|
||||||
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
|
[]string{"examples/route-proxy/new.go"}, read, "route-proxy", ""},
|
||||||
// Before any build source is said: as before.
|
// Before any build source is said: as before.
|
||||||
{"no build source said: a README moves all three, as before", "mesh-controller",
|
{"no build source said: a README moves all three, as before", "mesh-controller",
|
||||||
[]string{"README.md"}, unsaid, "mesh-controller,route-proxy | build-agent", ""},
|
[]string{"README.md"}, unsaid, "mesh-controller | build-agent | route-proxy", ""},
|
||||||
// In the catalogue, where they live, each by its own build source.
|
// In the catalogue, where they live, each by its own build source.
|
||||||
{"the route proxy's recipe: it alone", "mesh-catalog",
|
{"the route proxy's recipe: it alone", "mesh-catalog",
|
||||||
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
|
[]string{"modules/route-proxy/Dockerfile"}, read, "route-proxy", ""},
|
||||||
@@ -332,7 +331,7 @@ func TestASharedRepositoryMovesOnlyWhatItsBuildSourceHolds(t *testing.T) {
|
|||||||
// Files not all said: everything the repository builds, as before.
|
// Files not all said: everything the repository builds, as before.
|
||||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-controller", Base: "main", Commit: "head",
|
||||||
Paths: []string{"README.md"}, PathsTruncated: true}
|
Paths: []string{"README.md"}, PathsTruncated: true}
|
||||||
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller,route-proxy | build-agent" {
|
if got := tiered(reachOfMerge(m, entries, read, edges).Plan.Tiers); got != "mesh-controller | build-agent | route-proxy" {
|
||||||
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
|
t.Errorf("a merge whose files were not all said: planned %q, wanted all three", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,9 +452,8 @@ func TestAPlanIsTheTouchedModulesAndWhatIsReachableAlongTheWideningEdges(t *test
|
|||||||
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
kinds := []string{inventory.EdgeStandsOn, inventory.EdgeDeclared, inventory.EdgePackages,
|
||||||
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
inventory.EdgeBuiltBy, inventory.EdgeWorkerOf}
|
||||||
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true}
|
widens := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true}
|
||||||
// Built-by orders nothing since novox/hq ADR 0360 §3.
|
|
||||||
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
orders := map[string]bool{inventory.EdgeStandsOn: true, inventory.EdgeDeclared: true,
|
||||||
inventory.EdgeWorkerOf: true}
|
inventory.EdgeBuiltBy: true, inventory.EdgeWorkerOf: true}
|
||||||
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
// Directory names drawn from one pool, so two repositories hold directories of the same name, and one
|
||||||
// is a prefix of another.
|
// is a prefix of another.
|
||||||
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
|
dirs := []string{"a", "ab", "b", "c", "lib/x", "lib/y", "modules/a", "modules/a/sub"}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The list of predefined keys the mesh publishes (novox/hq ADR 0348 §7, design 57 package K2, issue 661).
|
||||||
|
//
|
||||||
|
// **The newest trunk build of the catalogue wins.** A build of the catalogue reads predefined.json at the commit
|
||||||
|
// it builds and carries it with the commit's generation (link.PredefinedList). Registration keeps it in the
|
||||||
|
// controller's store when that generation is larger than the one kept (inventory.KeepPredefined), so an older
|
||||||
|
// build's list heard later never replaces a newer one, and every manifest the controller reads again is judged
|
||||||
|
// against the list kept (catalogue.UsePredefined). mesh-config publishes it on the bus from there (design 57
|
||||||
|
// package K3); the controller keeps no config value, only the keys.
|
||||||
|
|
||||||
|
// fromTheCatalogue is whether a build is of the catalogue's repository, whose list alone the mesh publishes: by
|
||||||
|
// its name, in the URL form or the seat form.
|
||||||
|
func fromTheCatalogue(result link.BuildResult) bool {
|
||||||
|
for _, repository := range []string{result.Repository, sourceRepository(result)} {
|
||||||
|
name := strings.TrimSuffix(path.Base(strings.TrimRight(repository, "/")), ".git")
|
||||||
|
if name == catalogue.CatalogueRepository {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRepository(result link.BuildResult) string {
|
||||||
|
if result.Source == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return result.Source.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// carriedList is the list a build of the catalogue carried, read strictly; nil for a build of another
|
||||||
|
// repository, or one that carried none.
|
||||||
|
func carriedList(result link.BuildResult) (catalogue.PredefinedList, error) {
|
||||||
|
if result.Predefined == nil || !fromTheCatalogue(result) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
list, err := catalogue.ParsePredefined(result.Predefined.Keys)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s carried a list of predefined keys that is refused: %w", result.ID, err)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgedWith is the list a build's manifest is first judged against at registration: the catalogue's own at the
|
||||||
|
// commit built, which its build judged it against, where it carried one; the list kept otherwise.
|
||||||
|
func judgedWith(result link.BuildResult) (catalogue.PredefinedList, error) {
|
||||||
|
list, err := carriedList(result)
|
||||||
|
if err != nil || list != nil {
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
return catalogue.PublishedPredefined(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPredefined makes the list kept in the store the one this controller judges manifests against. A store
|
||||||
|
// keeping none leaves the list it has.
|
||||||
|
func loadPredefined(ctx context.Context, inv *inventory.Inventory) error {
|
||||||
|
kept, has, err := inv.PredefinedKept(ctx)
|
||||||
|
if err != nil || !has {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
catalogue.UsePredefined(kept.Keys)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishPredefined keeps a trunk build's list when it is newer than the one kept, says so, and loads what the
|
||||||
|
// store keeps then — this build's, or a newer one another controller process kept.
|
||||||
|
func publishPredefined(ctx context.Context, inv *inventory.Inventory, result link.BuildResult,
|
||||||
|
list catalogue.PredefinedList) error {
|
||||||
|
kept, err := inv.KeepPredefined(ctx, inventory.PredefinedRecord{Keys: list, Repository: result.Repository,
|
||||||
|
Commit: result.Commit, Generation: result.Predefined.Generation, Build: result.ID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if kept {
|
||||||
|
fmt.Printf("%s: the list of predefined keys at %s (generation %d) is published: %s\n", result.ID,
|
||||||
|
short(result.Commit), result.Predefined.Generation, strings.Join(list.Keys(), ", "))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: the list of predefined keys at %s (generation %d) is not newer than the one kept, which "+
|
||||||
|
"stands\n", result.ID, short(result.Commit), result.Predefined.Generation)
|
||||||
|
}
|
||||||
|
return loadPredefined(ctx, inv)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661): `module check` and the
|
||||||
|
// merge gate judge each manifest against the list at the root of its tree; registration publishes the newest
|
||||||
|
// trunk build's list of the catalogue and judges every module against the list the mesh publishes.
|
||||||
|
|
||||||
|
const theFirstKeys = `{
|
||||||
|
"font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72},
|
||||||
|
"agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// notifierFallingBackTo is the notifier in the config form, its font size falling back to the key named.
|
||||||
|
func notifierFallingBackTo(key, kind string) string {
|
||||||
|
def := "10"
|
||||||
|
if kind == "string" {
|
||||||
|
def = `"10"`
|
||||||
|
}
|
||||||
|
return `{"module":"notifier","version":"1",
|
||||||
|
"config":{"font-size":{"type":"` + kind + `","description":"the notification's font size, in points",
|
||||||
|
"default":` + def + `,"predefined":"` + key + `"}},
|
||||||
|
"resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644",
|
||||||
|
"content":"font = ${config:font-size}\n"}]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutAPublishedList judges the test against no list the mesh published, and puts the process's back after.
|
||||||
|
func withoutAPublishedList(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
was := catalogue.ReplacePublished(nil)
|
||||||
|
t.Cleanup(func() { catalogue.ReplacePublished(was) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// aRepositoryTree is a repository's checkout holding one module at modules/notifier, and the list when given.
|
||||||
|
func aRepositoryTree(t *testing.T, manifest string, list *string) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if list != nil {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, catalogue.PredefinedFile), []byte(*list), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aTemplatedModule(t, filepath.Join(root, "modules", "notifier"), manifest, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleCheckJudgesAManifestAgainstTheListAtItsRepositoryRoot(t *testing.T) {
|
||||||
|
withoutAPublishedList(t)
|
||||||
|
list := theFirstKeys
|
||||||
|
check := func(path string) (string, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := moduleCheck([]string{path}, &out)
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
if out, err := check(aRepositoryTree(t, notifierFallingBackTo("font-size", "number"), &list)); err != nil ||
|
||||||
|
!strings.Contains(out, "notifier: ok") {
|
||||||
|
t.Fatalf("a module naming font-size was refused: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
manifest string
|
||||||
|
list *string
|
||||||
|
says string
|
||||||
|
}{
|
||||||
|
{notifierFallingBackTo("font-scale", "number"), &list, `"font-scale", which is not on the list`},
|
||||||
|
{notifierFallingBackTo("font-size", "string"), &list, "which is a number, and this value is a string"},
|
||||||
|
{notifierFallingBackTo("font-size", "number"), nil, "is not present"},
|
||||||
|
{notifierFallingBackTo("font-size", "number"), ptr(`{"font-size": {"type": "number"}}`), "no description"},
|
||||||
|
} {
|
||||||
|
if out, err := check(aRepositoryTree(t, c.manifest, c.list)); err == nil || !strings.Contains(out, c.says) {
|
||||||
|
t.Errorf("wanted the check refused saying %q, got %v:\n%s", c.says, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr(s string) *string { return &s }
|
||||||
|
|
||||||
|
// writeTheList puts the list at the root of a tree the gate judges.
|
||||||
|
func writeTheList(t *testing.T, tree, list string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(tree, catalogue.PredefinedFile), []byte(list), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMergeGateJudgesEachManifestAgainstTheListOfTheTree(t *testing.T) {
|
||||||
|
withoutAPublishedList(t)
|
||||||
|
f, manifests := templatedMesh(t)
|
||||||
|
|
||||||
|
v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier",
|
||||||
|
notifierFallingBackTo("font-size", "number"))), theFirstKeys), "modules/notifier/module.json")
|
||||||
|
if v.Verdict == "fail" || v.Modules["notifier"] != "changed" {
|
||||||
|
t.Fatalf("a module naming font-size on the tree's list failed the gate (%v):\n%s", v.Modules, v.Report())
|
||||||
|
}
|
||||||
|
for _, c := range []struct{ manifest, list, says string }{
|
||||||
|
{notifierFallingBackTo("font-scale", "number"), theFirstKeys, `"font-scale", which is not on the list`},
|
||||||
|
{notifierFallingBackTo("font-size", "string"), theFirstKeys, "which is a number"},
|
||||||
|
{inlineNotifier, `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`, `unknown field "unit"`},
|
||||||
|
} {
|
||||||
|
v := gateJudged(t, f, writeTheList(t, aTree(t, withEdit(manifests, "notifier", c.manifest)), c.list),
|
||||||
|
"modules/notifier/module.json", catalogue.PredefinedFile)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), c.says) {
|
||||||
|
t.Errorf("wanted the gate to fail saying %q:\n%s", c.says, v.Report())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogueBuild is a trunk build of the catalogue carrying the list at a generation.
|
||||||
|
func catalogueBuild(id, manifest, list string, generation int) link.BuildResult {
|
||||||
|
r := link.BuildResult{ID: id, Repository: "http://forge.internal:20000/novox/mesh-catalog.git",
|
||||||
|
Path: "modules/notifier", Ref: "main", On: "anchor", Commit: strings.Repeat(id[len(id)-1:], 12),
|
||||||
|
Manifest: json.RawMessage(manifest), Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"}}
|
||||||
|
if list != "" {
|
||||||
|
r.Predefined = &link.PredefinedList{Keys: json.RawMessage(list), Generation: generation}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationPublishesTheNewestTrunkListAndJudgesEveryModuleAgainstIt(t *testing.T) {
|
||||||
|
withoutAPublishedList(t)
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
// A manifest naming font-size registers, and its build's list is published.
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory,
|
||||||
|
catalogueBuild("b-1", notifierFallingBackTo("font-size", "number"), theFirstKeys, 10))); err != nil {
|
||||||
|
t.Fatalf("a module naming font-size was not registered: %v", err)
|
||||||
|
}
|
||||||
|
kept, has, err := open.inventory.PredefinedKept(ctx)
|
||||||
|
if err != nil || !has || kept.Generation != 10 || kept.Build != "b-1" {
|
||||||
|
t.Fatalf("the list was not published: %+v %v %v", kept, has, err)
|
||||||
|
}
|
||||||
|
if got := catalogue.PublishedPredefined(); len(got) != 2 {
|
||||||
|
t.Fatalf("the controller judges against %v, not the list published", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One naming a key not on the list, or of another type, is refused and never enters the catalogue.
|
||||||
|
for i, c := range []struct{ manifest, says string }{
|
||||||
|
{notifierFallingBackTo("font-scale", "number"), "not on the list"},
|
||||||
|
{notifierFallingBackTo("font-size", "string"), "which is a number"},
|
||||||
|
} {
|
||||||
|
id := []string{"b-2", "b-3"}[i]
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, catalogueBuild(id, c.manifest, theFirstKeys, 10)); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("wanted the registration refused saying %q, got %v", c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src, err := open.inventory.SourceOf(ctx, "notifier")
|
||||||
|
if err != nil || src.BuiltFrom != strings.Repeat("1", 12) {
|
||||||
|
t.Fatalf("a refused build replaced the registered one: %+v %v", src, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newer build's list, which drops agent-managed-settings, replaces the published one.
|
||||||
|
smaller := `{"font-size": {"type": "number", "description": "points", "minimum": 6, "maximum": 72}}`
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, catalogueBuild("b-4",
|
||||||
|
notifierFallingBackTo("font-size", "number"), smaller, 12)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The older build's outcome heard later registers nothing new about the list: the newer stands.
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, catalogueBuild("b-5",
|
||||||
|
notifierFallingBackTo("font-size", "number"), theFirstKeys, 11)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
kept, _, _ = open.inventory.PredefinedKept(ctx)
|
||||||
|
if kept.Build != "b-4" || len(kept.Keys) != 1 || len(catalogue.PublishedPredefined()) != 1 {
|
||||||
|
t.Fatalf("an older build's list heard later replaced the newer one: %+v", kept)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A build of another repository publishes nothing, and its manifest is judged against the published list.
|
||||||
|
other := catalogueBuild("b-6", notifierFallingBackTo("font-size", "number"), theFirstKeys, 99)
|
||||||
|
other.Repository, other.Source.Repository = "http://forge.internal:20000/novox/notifier.git", "novox/notifier"
|
||||||
|
other.Manifest = json.RawMessage(strings.Replace(notifierFallingBackTo("font-size", "number"), `"notifier"`,
|
||||||
|
`"chimes"`, 1))
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, asTheOperator(t, open.inventory, other)); err != nil {
|
||||||
|
t.Fatalf("a module of another repository naming a published key: %v", err)
|
||||||
|
}
|
||||||
|
if kept, _, _ = open.inventory.PredefinedKept(ctx); kept.Build != "b-4" {
|
||||||
|
t.Fatalf("a build of another repository published its list: %+v", kept)
|
||||||
|
}
|
||||||
|
other.ID, other.Manifest = "b-7", json.RawMessage(strings.Replace(notifierFallingBackTo("agent-managed-settings",
|
||||||
|
"number"), `"notifier"`, `"chimes"`, 1))
|
||||||
|
if _, _, err := takeIn(ctx, open.inventory, other); err == nil || !strings.Contains(err.Error(), "not on the list") {
|
||||||
|
t.Fatalf("a key only another repository's list holds was registered: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
@@ -195,7 +194,7 @@ func serve(ctx context.Context) (err error) {
|
|||||||
// And what the catalogue decided a build meant. The builder's own result is already handled
|
// And what the catalogue decided a build meant. The builder's own result is already handled
|
||||||
// above; this is the other half — the control plane is the only one of the three that knows
|
// above; this is the other half — the control plane is the only one of the three that knows
|
||||||
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
||||||
if err := server.Follows(following{open: open}); err != nil {
|
if err := server.Follows(following{open}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// And the merges the bus announced and never handed over, read back on a timer and acted on late
|
// And the merges the bus announced and never handed over, read back on a timer and acted on late
|
||||||
@@ -203,7 +202,7 @@ func serve(ctx context.Context) (err error) {
|
|||||||
go catchingUpOnMerges(ctx, open, server)
|
go catchingUpOnMerges(ctx, open, server)
|
||||||
// And a catalogue that has just started, asking for what it missed. The same type answers
|
// And a catalogue that has just started, asking for what it missed. The same type answers
|
||||||
// both: what a build meant and what the builds were are two questions about one record.
|
// both: what a build meant and what the builds were are two questions about one record.
|
||||||
if err := server.Answers(following{open: open}); err != nil {
|
if err := server.Answers(following{open}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// And what providers say about consumers they keep failing, kept for `status` (novox/hq ADR
|
// And what providers say about consumers they keep failing, kept for `status` (novox/hq ADR
|
||||||
@@ -257,7 +256,7 @@ func serve(ctx context.Context) (err error) {
|
|||||||
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
|
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := server.Checks(following{open: open}); err != nil {
|
if err := server.Checks(following{open}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Composed now and kept current, before the verb that answers from it is served.
|
// Composed now and kept current, before the verb that answers from it is served.
|
||||||
@@ -265,6 +264,9 @@ func serve(ctx context.Context) (err error) {
|
|||||||
// The facts snapshot a merge check is fed (novox/hq to-be 45 §9): kept current while this
|
// The facts snapshot a merge check is fed (novox/hq to-be 45 §9): kept current while this
|
||||||
// controller holds the lease, read by the build seat from the artifact store.
|
// controller holds the lease, read by the build seat from the artifact store.
|
||||||
go exportingFacts(ctx, open, bus.Conn.ConnectedServerVersion)
|
go exportingFacts(ctx, open, bus.Conn.ConnectedServerVersion)
|
||||||
|
// The record mirrors (novox/hq ADR 0289, design 57 F3): the assignments and each registered module's
|
||||||
|
// declared config on the bus, written from the store only when a record changed, by the lease holder.
|
||||||
|
go mirroringRecords(ctx, inv, bus.Conn, bus)
|
||||||
// Every call carries the lease's epoch, and its record is written only under the lease (novox/hq
|
// Every call carries the lease's epoch, and its record is written only under the lease (novox/hq
|
||||||
// to-be 45 §6).
|
// to-be 45 §6).
|
||||||
link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) })
|
link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) })
|
||||||
@@ -1387,13 +1389,6 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, se
|
|||||||
if issued > 0 {
|
if issued > 0 {
|
||||||
fmt.Printf(" issued %d membership(s)\n", issued)
|
fmt.Printf(" issued %d membership(s)\n", issued)
|
||||||
}
|
}
|
||||||
// **And what every other machine hears of a module's stream** (novox/hq ADR 0363 §6): a grant the provider's
|
|
||||||
// setting changed reaches a consumer on a machine this send does not reach. Its membership is issued again
|
|
||||||
// as last issued, with only what it hears changed, so nothing else of it moves before its own send.
|
|
||||||
reheard := reissueHearing(ctx, bus, records, where, sent)
|
|
||||||
if reheard > 0 {
|
|
||||||
fmt.Printf(" issued %d membership(s) again for what they hear of a module's stream\n", reheard)
|
|
||||||
}
|
|
||||||
if len(refused) > 0 {
|
if len(refused) > 0 {
|
||||||
// Returned, never passed over (novox/hq issue 249): the declarations of the machines they are
|
// Returned, never passed over (novox/hq issue 249): the declarations of the machines they are
|
||||||
// for are not sent, and the rollout that asked is tried again rather than waiting for a push.
|
// for are not sent, and the rollout that asked is tried again rather than waiting for a push.
|
||||||
@@ -1752,78 +1747,3 @@ func schemaReach() (int, error) {
|
|||||||
}
|
}
|
||||||
return reach, nil
|
return reach, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reissueHearing issues again, on every machine not just sent, the membership of each assignment whose
|
|
||||||
// `consumes` names a module owning a stream, when what it hears changed: read as last issued from the bus,
|
|
||||||
// its `hears` replaced, and published. One that was never issued is left to its own send. Failures are said
|
|
||||||
// and passed over: the send that carries the grant is not the consumer's, and its own next send issues it.
|
|
||||||
func reissueHearing(ctx context.Context, bus link.OverNATS, records broker.Records, where broker.Placements,
|
|
||||||
sent []readyNode) int {
|
|
||||||
reached := map[string]bool{}
|
|
||||||
for _, s := range sent {
|
|
||||||
reached[s.node] = true
|
|
||||||
}
|
|
||||||
issued := 0
|
|
||||||
for node, declared := range records.Assigned {
|
|
||||||
if reached[node] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, d := range declared {
|
|
||||||
if !d.HearsAStream {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
subject := broker.MembershipSubject(node, d.Module)
|
|
||||||
last, err := bus.JS.GetLastMsg(broker.AssignmentsStream, subject)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, nats.ErrMsgNotFound) {
|
|
||||||
fmt.Printf(" %s on %s: its membership could not be read to issue what it hears again: %v\n",
|
|
||||||
d.Module, node, err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, changed, err := withHearing(last.Data, broker.HeardIssuedFor(node, d))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" %s on %s: its membership could not be read to issue what it hears again: %v\n",
|
|
||||||
d.Module, node, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := bus.PublishMembership(ctx, node, d.Module, body); err != nil {
|
|
||||||
fmt.Printf(" %s on %s: what it hears could not be issued again: %v\n", d.Module, node, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
issued++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return issued
|
|
||||||
}
|
|
||||||
|
|
||||||
// withHearing is a membership as issued with its `hears` replaced, and whether that changed it. Pure.
|
|
||||||
func withHearing(issued []byte, hears []broker.HeardIssued) ([]byte, bool, error) {
|
|
||||||
var fields map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(issued, &fields); err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
var had []broker.HeardIssued
|
|
||||||
if raw, ok := fields["hears"]; ok {
|
|
||||||
if err := json.Unmarshal(raw, &had); err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slices.Equal(had, hears) {
|
|
||||||
return issued, false, nil
|
|
||||||
}
|
|
||||||
if len(hears) == 0 {
|
|
||||||
delete(fields, "hears")
|
|
||||||
} else {
|
|
||||||
raw, err := json.Marshal(hears)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
fields["hears"] = raw
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(fields)
|
|
||||||
return body, true, err
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -340,10 +340,7 @@ func cancelAsk(ctx context.Context, js *broker.JetStream, open *stores, seat str
|
|||||||
return "", fmt.Errorf("%s is marked cancelled and could not be deleted from the queue (seq %d): %w — a "+
|
return "", fmt.Errorf("%s is marked cancelled and could not be deleted from the queue (seq %d): %w — a "+
|
||||||
"holder taking it ends it as cancelled", ask.ID, ask.Seq, err)
|
"holder taking it ends it as cancelled", ask.ID, ask.Seq, err)
|
||||||
}
|
}
|
||||||
// A check ended because its pull request has a newer head records nothing (novox/hq issue 726): open is nil.
|
recordCancelled(ctx, open, ask)
|
||||||
if open != nil {
|
|
||||||
recordCancelled(ctx, open, ask)
|
|
||||||
}
|
|
||||||
return cancelSaid(ask, seat, removed), nil
|
return cancelSaid(ask, seat, removed), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -616,7 +616,6 @@ func TestKillAndPauseAskTheHolderOnTheMachine(t *testing.T) {
|
|||||||
// A plan that stopped at its first machine is retried: the module sent to that machine again, the
|
// A plan that stopped at its first machine is retried: the module sent to that machine again, the
|
||||||
// send recorded as the first anew, and the plan goes on — unless a newer plan holds the module.
|
// send recorded as the first anew, and the plan goes on — unless a newer plan holds the module.
|
||||||
func TestAPlanStoppedAtItsFirstMachineIsRetried(t *testing.T) {
|
func TestAPlanStoppedAtItsFirstMachineIsRetried(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
asked := asksRecorded(t)
|
asked := asksRecorded(t)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
"github.com/novox/mesh-controller/internal/recordmirror"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The controller's record mirrors on the bus (novox/hq ADR 0289, design 54, design 57 F3): the assignments,
|
||||||
|
// and each registered module's declared config, which mesh-config resolves a module's config values from.
|
||||||
|
//
|
||||||
|
// **Built from the store, on a tick of their own, by the lease holder alone.** A record changes through a
|
||||||
|
// verb that runs as a command of its own, a reconcile, a merge's registration or a pending assignment made,
|
||||||
|
// so no one call site sees every change; what every change has in common is that it is committed to the
|
||||||
|
// store. So the serving controller reads the store every mirrorEvery and writes only what differs from what
|
||||||
|
// the bucket holds (ADR 0289 §1): a quiet mesh writes nothing, and a change rolled back is never read. A
|
||||||
|
// mirror is therefore at most mirrorEvery behind its store.
|
||||||
|
|
||||||
|
// mirrorEvery is how often the mirrors are compared with the store: two small reads of the store.
|
||||||
|
const mirrorEvery = 10 * time.Second
|
||||||
|
|
||||||
|
// mirroringRecords keeps the mirrors current for as long as this controller serves: ctx ends when it stops.
|
||||||
|
func mirroringRecords(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn, announce recordmirror.Announcer) {
|
||||||
|
api, err := jetstream.New(conn)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("the record mirrors are not kept: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holds := func() bool {
|
||||||
|
epoch, err := theLease.epoch(ctx)
|
||||||
|
return err == nil && epoch > 0
|
||||||
|
}
|
||||||
|
epoch := func() uint64 {
|
||||||
|
e, _ := theLease.epoch(ctx)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
sources := map[string]func(context.Context) (map[string][]byte, error){
|
||||||
|
recordmirror.Assignments.Name: inv.AssignmentsMirror,
|
||||||
|
recordmirror.DeclaredConfig.Name: inv.DeclaredConfigMirror,
|
||||||
|
}
|
||||||
|
writers := map[string]*recordmirror.Writer{}
|
||||||
|
failing := map[string]string{}
|
||||||
|
tick := time.NewTicker(mirrorEvery)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
if holds() {
|
||||||
|
for _, m := range recordmirror.Mirrors() {
|
||||||
|
why := mirrorOnce(ctx, api, m, writers, sources[m.Name], announce, epoch, holds)
|
||||||
|
if why != failing[m.Name] {
|
||||||
|
if why != "" {
|
||||||
|
fmt.Printf("the %s mirror cannot be kept: %s\n", m.Name, why)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("the %s mirror is kept again\n", m.Name)
|
||||||
|
}
|
||||||
|
failing[m.Name] = why
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrorOnce brings one mirror to what the store holds, and answers why it could not; empty when it could.
|
||||||
|
func mirrorOnce(ctx context.Context, api jetstream.JetStream, m recordmirror.Mirror, writers map[string]*recordmirror.Writer,
|
||||||
|
source func(context.Context) (map[string][]byte, error), announce recordmirror.Announcer, epoch func() uint64,
|
||||||
|
holds func() bool) string {
|
||||||
|
reading, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
w := writers[m.Name]
|
||||||
|
if w == nil {
|
||||||
|
kv, err := api.KeyValue(reading, m.Bucket)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("the bucket %s cannot be read: %v", m.Bucket, err)
|
||||||
|
}
|
||||||
|
w = &recordmirror.Writer{Mirror: m, KV: kv, Announce: announce, Epoch: epoch, Holds: holds}
|
||||||
|
writers[m.Name] = w
|
||||||
|
}
|
||||||
|
want, err := source(reading)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("the store cannot be read: %v", err)
|
||||||
|
}
|
||||||
|
lost := w.LostEvents
|
||||||
|
r, err := w.Sync(reading, want)
|
||||||
|
if r.Written+r.Removed > 0 {
|
||||||
|
fmt.Printf("the %s mirror moved: %d written, %d removed\n", m.Name, r.Written, r.Removed)
|
||||||
|
}
|
||||||
|
if w.LostEvents > lost {
|
||||||
|
fmt.Printf("the %s mirror lost %d event(s): the bucket holds the change, its notice was refused twice\n",
|
||||||
|
m.Name, w.LostEvents-lost)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A membership issued again for what it hears changes nothing else of it (novox/hq ADR 0363 §6).
|
|
||||||
func TestAMembershipIssuedAgainChangesOnlyWhatItHears(t *testing.T) {
|
|
||||||
issued := []byte(`{"node":"ace","module":"reader","receives":{"x":[1]},"emits":"mesh.mod.reader.event.{event}"}`)
|
|
||||||
hears := []broker.HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}
|
|
||||||
body, changed, err := withHearing(issued, hears)
|
|
||||||
if err != nil || !changed {
|
|
||||||
t.Fatalf("changed %v, %v", changed, err)
|
|
||||||
}
|
|
||||||
var got map[string]any
|
|
||||||
if err := json.Unmarshal(body, &got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got["receives"] == nil || got["emits"] != "mesh.mod.reader.event.{event}" || got["hears"] == nil {
|
|
||||||
t.Fatalf("issued again as %s", body)
|
|
||||||
}
|
|
||||||
if _, again, _ := withHearing(body, hears); again {
|
|
||||||
t.Fatal("the same hearing issued again counts as a change")
|
|
||||||
}
|
|
||||||
gone, changed, err := withHearing(body, nil)
|
|
||||||
if err != nil || !changed {
|
|
||||||
t.Fatalf("a withdrawal: %v, %v", changed, err)
|
|
||||||
}
|
|
||||||
got = nil
|
|
||||||
if err := json.Unmarshal(gone, &got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, still := got["hears"]; still {
|
|
||||||
t.Fatalf("a withdrawn hearing is still issued: %s", gone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -750,11 +750,6 @@ func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool,
|
|||||||
if r.Rest {
|
if r.Rest {
|
||||||
return advanceRest(ctx, open, p, now)
|
return advanceRest(ctx, open, p, now)
|
||||||
}
|
}
|
||||||
if r.Gate == nil && !gateFirstMachine {
|
|
||||||
// **No first machine** (novox/hq ADR 0360 §7): every machine heard from is sent at once, in one step.
|
|
||||||
r.Rest = true
|
|
||||||
return advanceRest(ctx, open, p, now)
|
|
||||||
}
|
|
||||||
if r.Gate == nil {
|
if r.Gate == nil {
|
||||||
heard, err := releaseHeard(ctx, open)
|
heard, err := releaseHeard(ctx, open)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1020,25 +1015,6 @@ func advanceRest(ctx context.Context, open *stores, p *inventory.Plan, now time.
|
|||||||
judging = append(judging, node+": not yet reported on its send")
|
judging = append(judging, node+": not yet reported on its send")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !gateFirstMachine {
|
|
||||||
// **Judged by its report alone** (novox/hq ADR 0360 §7): applied passes; failed or refused is named and
|
|
||||||
// fails the walk, with nothing put back — apply-failing says it.
|
|
||||||
switch rep := byNode[node]; rep.Outcome {
|
|
||||||
case inventory.OutcomeApplied:
|
|
||||||
decide(g, inventory.GatePassed, node+" reported it applied its send (novox/hq ADR 0360)", now)
|
|
||||||
passCarried(ctx, open, p, g, "")
|
|
||||||
r.Done = append(r.Done, node)
|
|
||||||
changed = true
|
|
||||||
case inventory.OutcomeFailed, inventory.OutcomeRefused:
|
|
||||||
decide(g, inventory.GateFailed, fmt.Sprintf("%s %s its send", node, rep.Outcome), now)
|
|
||||||
r.Failed = append(r.Failed, node)
|
|
||||||
changed = true
|
|
||||||
notes = append(notes, fmt.Sprintf("%s %s its send: nothing put back, apply-failing says it", node, rep.Outcome))
|
|
||||||
default:
|
|
||||||
judging = append(judging, node+": not yet reported on its send")
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
verdict, err := judgeMoves(ctx, open, g, nil, now)
|
verdict, err := judgeMoves(ctx, open, g, nil, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return changed, err
|
return changed, err
|
||||||
@@ -1123,9 +1099,6 @@ func advanceRest(ctx context.Context, open *stores, p *inventory.Plan, now time.
|
|||||||
// keptStepGrew says why a step's kept sends would now carry a build no gate passed that they were not kept with;
|
// keptStepGrew says why a step's kept sends would now carry a build no gate passed that they were not kept with;
|
||||||
// empty when none would.
|
// empty when none would.
|
||||||
func keptStepGrew(ctx context.Context, open *stores, r *inventory.PlanRelease, pending []string) (string, error) {
|
func keptStepGrew(ctx context.Context, open *stores, r *inventory.PlanRelease, pending []string) (string, error) {
|
||||||
if !gateFirstMachine {
|
|
||||||
return "", nil // every machine goes at once (novox/hq ADR 0360 §7): a send carries what waits when it is made
|
|
||||||
}
|
|
||||||
f, err := readMoveFacts(ctx, open.inventory)
|
f, err := readMoveFacts(ctx, open.inventory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1234,11 +1207,7 @@ func openRestStep(ctx context.Context, open *stores, p *inventory.Plan) (bool, s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", err
|
return false, "", err
|
||||||
}
|
}
|
||||||
// **Every machine at once** (novox/hq ADR 0360 §2, §7): no build waits for one machine to pass first.
|
step := restStep(nodes, waiting, f.gated, func(module string) bool { return oneAtATime(module, shelf[module]) })
|
||||||
step := nodes
|
|
||||||
if gateFirstMachine {
|
|
||||||
step = restStep(nodes, waiting, f.gated, func(module string) bool { return oneAtATime(module, shelf[module]) })
|
|
||||||
}
|
|
||||||
if len(step) == 0 {
|
if len(step) == 0 {
|
||||||
if len(walked) > 0 {
|
if len(walked) > 0 {
|
||||||
return false, "waiting before " + strings.Join(walked, "; "), nil
|
return false, "waiting before " + strings.Join(walked, "; "), nil
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A pull request into a release branch is judged by that branch** (novox/hq ADR 0358, issue 704): a base that is
|
|
||||||
// not its repository's trunk, where mesh-controller has a branch of that name, is a release branch; the check then
|
|
||||||
// reads every repository beside it at its branch of that name where it has one, else at its trunk. Any other base is
|
|
||||||
// checked exactly as before.
|
|
||||||
|
|
||||||
const aRelease = "feat/57-config-and-provisioning"
|
|
||||||
|
|
||||||
// theCore is the mesh's core as a check reads it beside: the controller, the node-engine, the tool runner and the
|
|
||||||
// catalogue's bus, on the git seat.
|
|
||||||
func theCore() []inventory.Entry {
|
|
||||||
onSeat := func(module, repository string) inventory.Entry {
|
|
||||||
e := fromRepo(module, repository, "")
|
|
||||||
e.Source.Seat = "git"
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
nats := onSeat("nats", "novox/mesh-catalog")
|
|
||||||
nats.Source.Path = "modules/nats"
|
|
||||||
return []inventory.Entry{onSeat("mesh-controller", "novox/mesh-controller"), onSeat("mesh-host", "novox/mesh-host"),
|
|
||||||
onSeat("node-tools", "novox/mesh-tools"), nats, onSeat("gitea", "novox/mesh-catalog")}
|
|
||||||
}
|
|
||||||
|
|
||||||
// aForgeWithBranches stands in for the forge: which branches each repository has, and how often it was asked.
|
|
||||||
func aForgeWithBranches(t *testing.T, branches map[string][]string, failing error) *int {
|
|
||||||
t.Helper()
|
|
||||||
asked := 0
|
|
||||||
was := forgeHasBranch
|
|
||||||
t.Cleanup(func() { forgeHasBranch = was })
|
|
||||||
forgeHasBranch = func(_ context.Context, owner, repo, branch string) (bool, error) {
|
|
||||||
asked++
|
|
||||||
if failing != nil {
|
|
||||||
return false, failing
|
|
||||||
}
|
|
||||||
for _, b := range branches[owner+"/"+repo] {
|
|
||||||
if b == branch {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return &asked
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestABaseIsAReleaseBranchWhenTheControllerHasABranchOfThatName(t *testing.T) {
|
|
||||||
entries := theCore()
|
|
||||||
scopeOf := func(p link.PullUpdated) checkScope { return pullScope(p, entries, nil, nil) }
|
|
||||||
pull := func(repo, base string) link.PullUpdated {
|
|
||||||
return link.PullUpdated{Owner: "novox", Repo: repo, Number: 286, Base: base, Commit: "abc",
|
|
||||||
Paths: []string{"modules/gitea/index.ts"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
asked := aForgeWithBranches(t, map[string][]string{"novox/mesh-controller": {"main", aRelease}}, nil)
|
|
||||||
for _, c := range []struct {
|
|
||||||
what string
|
|
||||||
p link.PullUpdated
|
|
||||||
release string
|
|
||||||
asks int
|
|
||||||
}{
|
|
||||||
{"a pull request into the trunk asks nothing and is no release", pull("mesh-catalog", "main"), "", 0},
|
|
||||||
{"a catalogue pull request into the release branch", pull("mesh-catalog", aRelease), aRelease, 1},
|
|
||||||
{"a controller pull request into the release branch", pull("mesh-controller", aRelease), aRelease, 1},
|
|
||||||
{"a base mesh-controller has no branch of is checked as today", pull("mesh-catalog", "feat/other"), "", 1},
|
|
||||||
{"no base: as today", pull("mesh-catalog", ""), "", 0},
|
|
||||||
} {
|
|
||||||
*asked = 0
|
|
||||||
got, err := releaseBranchOf(t.Context(), c.p, scopeOf(c.p), entries)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", c.what, err)
|
|
||||||
}
|
|
||||||
if got != c.release || *asked != c.asks {
|
|
||||||
t.Errorf("%s: release %q after %d question(s) to the forge, not %q after %d", c.what, got, *asked,
|
|
||||||
c.release, c.asks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The branch a module built from the repository follows is its trunk: never asked about.
|
|
||||||
photos := fromRepo("photos", "novox/photos", "")
|
|
||||||
photos.Source.Ref = "nox-mesh"
|
|
||||||
withPhotos := append(theCore(), photos)
|
|
||||||
p := link.PullUpdated{Owner: "novox", Repo: "photos", Base: "nox-mesh", Commit: "abc", Paths: []string{"x.js"}}
|
|
||||||
*asked = 0
|
|
||||||
if got, err := releaseBranchOf(t.Context(), p, pullScope(p, withPhotos, nil, nil), withPhotos); err != nil ||
|
|
||||||
got != "" || *asked != 0 {
|
|
||||||
t.Fatalf("a module's own trunk read as release %q (%v) after %d question(s)", got, err, *asked)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A forge that cannot answer is an error, never "no release branch".
|
|
||||||
aForgeWithBranches(t, nil, errors.New("nothing serves gitea.gitea_api"))
|
|
||||||
q := pull("mesh-catalog", aRelease)
|
|
||||||
if _, err := releaseBranchOf(t.Context(), q, scopeOf(q), entries); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "cannot be told") {
|
|
||||||
t.Fatalf("an unanswered forge was read as an answer: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBesideAReleaseBranchCheckEachRepositoryIsReadAtThatBranchElseItsTrunk(t *testing.T) {
|
|
||||||
entries := theCore()
|
|
||||||
clone := func(s inventory.Source) (string, error) { return "http://git/" + s.Repository + ".git", nil }
|
|
||||||
running := func(module string) string { return "running-" + module }
|
|
||||||
|
|
||||||
beside, err := besideOf(entries, running, clone, aRelease)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
at := func(repo string, ref, orElse string) link.CheckedOut {
|
|
||||||
return link.CheckedOut{Repository: "http://git/novox/" + repo + ".git", Ref: ref, Else: orElse}
|
|
||||||
}
|
|
||||||
want := map[string]link.CheckedOut{
|
|
||||||
// The judge: mesh-controller's release branch, which it has.
|
|
||||||
"mesh-controller": at("mesh-controller", aRelease, ""),
|
|
||||||
"mesh-controller-main": at("mesh-controller", "main", ""),
|
|
||||||
// A controller pull request into the release branch reads mesh-host at that branch where it has one.
|
|
||||||
"mesh-host": at("mesh-host", aRelease, "main"),
|
|
||||||
"mesh-tools": at("mesh-tools", aRelease, "main"),
|
|
||||||
"mesh-catalog": at("mesh-catalog", aRelease, "main"),
|
|
||||||
// mesh-lab at its trunk, always: its replays run with the container runtime's socket.
|
|
||||||
"mesh-lab": at("mesh-lab", "main", ""),
|
|
||||||
"mesh-sdk": at("mesh-sdk", aRelease, "main"),
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(beside, want) {
|
|
||||||
t.Fatalf("beside a release-branch check:\n got %v\nwant %v", beside, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any other base: exactly as before — what the mesh runs, the catalogue's and the lab's main.
|
|
||||||
beside, err = besideOf(entries, running, clone, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want = map[string]link.CheckedOut{
|
|
||||||
"mesh-controller": at("mesh-controller", "running-mesh-controller", ""),
|
|
||||||
"mesh-controller-main": at("mesh-controller", "main", ""),
|
|
||||||
"mesh-host": at("mesh-host", "running-mesh-host", ""),
|
|
||||||
"mesh-tools": at("mesh-tools", "running-node-tools", ""),
|
|
||||||
"mesh-catalog": at("mesh-catalog", "main", ""),
|
|
||||||
"mesh-lab": at("mesh-lab", "main", ""),
|
|
||||||
"mesh-sdk": at("mesh-sdk", sdkPinned(), ""),
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(beside, want) {
|
|
||||||
t.Fatalf("beside a trunk check:\n got %v\nwant %v", beside, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAReleaseBranchVerdictNamesTheBranchTheJudgeAndWhatItRead(t *testing.T) {
|
|
||||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: aRelease, Release: aRelease}
|
|
||||||
c := checkedOf(link.BuildResult{ID: "b", Ref: "aaaaaaaaaaaa0000", Checked: asked, Check: &link.CheckOutcome{
|
|
||||||
Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"},
|
|
||||||
RepoCheck: &link.CheckLayer{Verdict: "pass"}, Release: aRelease, JudgedBy: "fcdecb436117",
|
|
||||||
Beside: map[string]string{"mesh-host": "222222222222", "mesh-lab": "333333333333"},
|
|
||||||
BesideAt: map[string]string{"mesh-host": aRelease, "mesh-lab": "main"},
|
|
||||||
Validator: "mesh-host's validator of " + aRelease + " at 222222222222"}})
|
|
||||||
if c.Release != aRelease || c.JudgedBy != "fcdecb436117" {
|
|
||||||
t.Fatalf("the verdict names release %q judged by %q", c.Release, c.JudgedBy)
|
|
||||||
}
|
|
||||||
if c.BesideAt["mesh-host"] != aRelease || c.BesideAt["mesh-lab"] != "main" || c.Beside["mesh-host"] != "222222222222" ||
|
|
||||||
c.Validator != "mesh-host's validator of "+aRelease+" at 222222222222" {
|
|
||||||
t.Fatalf("the verdict names what it read beside it as %v at %v", c.Beside, c.BesideAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Clause 5** (ADR 0358 §5): a release branch's own pull request into its trunk is no release-branch check: the forge
|
|
||||||
// is asked nothing, the request names no release branch, and the repositories beside it are read as for any trunk
|
|
||||||
// pull request. Its delivery group's composed check is asked as for any other (askGroupCheck, unchanged).
|
|
||||||
func TestAReleaseBranchsPullRequestIntoItsTrunkIsCheckedAsAnyTrunkPullRequest(t *testing.T) {
|
|
||||||
entries := theCore()
|
|
||||||
asked := aForgeWithBranches(t, map[string][]string{"novox/mesh-controller": {"main", aRelease}}, nil)
|
|
||||||
for _, repo := range []string{"mesh-catalog", "mesh-controller", "mesh-host"} {
|
|
||||||
p := link.PullUpdated{Owner: "novox", Repo: repo, Number: 400, Head: aRelease, Base: "main", Commit: "abc",
|
|
||||||
Paths: []string{"modules/gitea/index.ts"}}
|
|
||||||
got, err := releaseBranchOf(t.Context(), p, pullScope(p, entries, nil, nil), entries)
|
|
||||||
if err != nil || got != "" || *asked != 0 {
|
|
||||||
t.Fatalf("%s's release branch into main read as release %q (%v) after %d question(s)", repo, got, err, *asked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clone := func(s inventory.Source) (string, error) { return "http://git/" + s.Repository + ".git", nil }
|
|
||||||
beside, err := besideOf(entries, func(module string) string { return "running-" + module }, clone, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for dir, b := range beside {
|
|
||||||
if b.Ref == aRelease || b.Else != "" {
|
|
||||||
t.Fatalf("beside a trunk pull request %s is read at %+v", dir, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A forge answer that names no branch is an error**, never "no release branch"; a 404 is "no such branch".
|
|
||||||
func TestTheForgesAnswerAboutABranchIsReadStrictly(t *testing.T) {
|
|
||||||
for _, c := range []struct {
|
|
||||||
what string
|
|
||||||
answer link.Answer
|
|
||||||
has bool
|
|
||||||
fails bool
|
|
||||||
}{
|
|
||||||
{"the branch named", link.Answer{Result: []byte(`{"result":{"name":"` + aRelease + `"}}`)}, true, false},
|
|
||||||
{"not found", link.Answer{Error: "Gitea API /repos/novox/mesh-controller/branches/x: 404 {\"message\":\"not found\"}"},
|
|
||||||
false, false},
|
|
||||||
{"an empty branch name", link.Answer{Result: []byte(`{"result":{"name":""}}`)}, false, true},
|
|
||||||
{"no result", link.Answer{Result: []byte(`{}`)}, false, true},
|
|
||||||
{"another branch named", link.Answer{Result: []byte(`{"result":{"name":"main"}}`)}, false, true},
|
|
||||||
{"unreadable", link.Answer{Result: []byte(`not json`)}, false, true},
|
|
||||||
{"another error", link.Answer{Error: "Gitea API: 500 internal"}, false, true},
|
|
||||||
} {
|
|
||||||
has, err := branchAnswered(c.answer, aRelease)
|
|
||||||
if has != c.has || (err != nil) != c.fails {
|
|
||||||
t.Errorf("%s: has %v, error %v", c.what, has, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **The trunk is the repository's own, not a name**: a repository whose trunk is not main, one that follows main, and
|
|
||||||
// a controller whose trunk is not main.
|
|
||||||
func TestATrunkIsTheBranchTheRepositoryFollows(t *testing.T) {
|
|
||||||
asked := aForgeWithBranches(t, map[string][]string{"novox/mesh-controller": {"main", aRelease, "master"}}, nil)
|
|
||||||
legacy := fromRepo("legacy", "novox/legacy", "")
|
|
||||||
legacy.Source.Ref = "master"
|
|
||||||
onMain := fromRepo("photos", "novox/photos", "")
|
|
||||||
onMain.Source.Ref = "main"
|
|
||||||
entries := append(theCore(), legacy, onMain)
|
|
||||||
for _, c := range []struct {
|
|
||||||
what, repo, base, release string
|
|
||||||
asks int
|
|
||||||
}{
|
|
||||||
{"a repository whose trunk is master, into master", "legacy", "master", "", 0},
|
|
||||||
{"a repository whose trunk is master, into the release branch", "legacy", aRelease, aRelease, 1},
|
|
||||||
{"a repository whose module follows main, into main", "photos", "main", "", 0},
|
|
||||||
{"a repository whose module follows main, into the release branch", "photos", aRelease, aRelease, 1},
|
|
||||||
} {
|
|
||||||
*asked = 0
|
|
||||||
p := link.PullUpdated{Owner: "novox", Repo: c.repo, Base: c.base, Commit: "abc", Paths: []string{"x"}}
|
|
||||||
got, err := releaseBranchOf(t.Context(), p, pullScope(p, entries, nil, nil), entries)
|
|
||||||
if err != nil || got != c.release || *asked != c.asks {
|
|
||||||
t.Errorf("%s: release %q (%v) after %d question(s), not %q after %d", c.what, got, err, *asked, c.release, c.asks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A controller whose trunk is not main: main may be a release branch there, and its own trunk never is.
|
|
||||||
core := theCore()
|
|
||||||
core[0].Source.Ref = "trunk"
|
|
||||||
for _, c := range []struct {
|
|
||||||
base, release string
|
|
||||||
asks int
|
|
||||||
}{{"trunk", "", 0}, {"main", "main", 1}} {
|
|
||||||
*asked = 0
|
|
||||||
p := link.PullUpdated{Owner: "novox", Repo: "hq", Base: c.base, Commit: "abc", Paths: []string{"x"}}
|
|
||||||
got, err := releaseBranchOf(t.Context(), p, pullScope(p, core, nil, nil), core)
|
|
||||||
if err != nil || got != c.release || *asked != c.asks {
|
|
||||||
t.Errorf("hq into %s, the controller following trunk: release %q (%v) after %d question(s)", c.base, got, err,
|
|
||||||
*asked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// trunkOf: the branch a source follows, and main when it records none or records a commit (issue 215's rule).
|
|
||||||
func TestATrunkIsMainWhenTheSourceRecordsNone(t *testing.T) {
|
|
||||||
for ref, want := range map[string]string{"": "main", "main": "main", "master": "master", "nox-mesh": "nox-mesh",
|
|
||||||
"0123456789abcdef0123456789abcdef01234567": "main", " ": "main"} {
|
|
||||||
if got := trunkOf(inventory.Source{Ref: ref}); got != want {
|
|
||||||
t.Errorf("a source recording %q follows %q, not %q", ref, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,13 +9,11 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/builder"
|
"github.com/novox/mesh-controller/internal/builder"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
)
|
)
|
||||||
@@ -28,10 +26,6 @@ import (
|
|||||||
// alone cannot (a tier waiting for machines to report); a controller replaced mid-plan finds the
|
// alone cannot (a tier waiting for machines to report); a controller replaced mid-plan finds the
|
||||||
// plan where it left it.
|
// plan where it left it.
|
||||||
|
|
||||||
// gateFirstMachine sends a module that rolls out to one machine first and judges it there (ADR 0236). Off since
|
|
||||||
// novox/hq ADR 0360: the gate's code is kept for the walks a controller before it started, and its tests turn it on.
|
|
||||||
var gateFirstMachine = false
|
|
||||||
|
|
||||||
// planWaitBound is how long a plan may wait on one thing before `status` names it red.
|
// planWaitBound is how long a plan may wait on one thing before `status` names it red.
|
||||||
const planWaitBound = 30 * time.Minute
|
const planWaitBound = 30 * time.Minute
|
||||||
|
|
||||||
@@ -48,15 +42,29 @@ func tiersOf(set []string, edges []inventory.Edge) [][]string {
|
|||||||
for _, m := range set {
|
for _, m := range set {
|
||||||
deps[m] = map[string]bool{}
|
deps[m] = map[string]bool{}
|
||||||
}
|
}
|
||||||
|
// The build seat's holders follow the controller that defines their worker (EdgeWorkerOf,
|
||||||
|
// novox/hq issue 206), so the built-by edge from that controller to such a holder yields: the
|
||||||
|
// controller is built by whichever build machine is running, as the runtime image always was.
|
||||||
|
worker := map[string]map[string]bool{}
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.Kind == inventory.EdgeWorkerOf && in[e.From] && in[e.To] {
|
||||||
|
if worker[e.To] == nil {
|
||||||
|
worker[e.To] = map[string]bool{}
|
||||||
|
}
|
||||||
|
worker[e.To][e.From] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
|
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
|
||||||
// build needs nothing of A's first. Stands-on and declared order after the base is built, and the
|
// build needs nothing of A's first. The other kinds order: stands-on and declared after
|
||||||
// build seat's holder follows the controller that defines its worker (EdgeWorkerOf, issue 206).
|
// the base is built, built-by after the build machine is built and running — except for
|
||||||
|
// what the build machine itself stands on, and for the controller whose worker the build
|
||||||
|
// machine binds. The runtime image is built by the builder and the builder is built on the
|
||||||
|
// runtime image; the image comes first, built by the builder that is running.
|
||||||
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
|
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// **Being built by the builder orders nothing** (novox/hq ADR 0360 §3): whichever builder runs builds it.
|
if e.Kind == inventory.EdgeBuiltBy && (isBaseOf(e.From, e.To, edges, in) || worker[e.From][e.To]) {
|
||||||
if e.Kind == inventory.EdgeBuiltBy {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
deps[e.From][e.To] = true
|
deps[e.From][e.To] = true
|
||||||
@@ -97,6 +105,29 @@ func tiersOf(set []string, edges []inventory.Edge) [][]string {
|
|||||||
return tiers
|
return tiers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBaseOf says whether `to` stands on `base`, directly or through other bases in the set, along
|
||||||
|
// the build edges alone.
|
||||||
|
func isBaseOf(base, to string, edges []inventory.Edge, in map[string]bool) bool {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var walk func(string) bool
|
||||||
|
walk = func(m string) bool {
|
||||||
|
if m == base {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if seen[m] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[m] = true
|
||||||
|
for _, e := range edges {
|
||||||
|
if e.From == m && in[e.To] && (e.Kind == inventory.EdgeStandsOn || e.Kind == inventory.EdgeDeclared) && walk(e.To) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return walk(to)
|
||||||
|
}
|
||||||
|
|
||||||
// reachableFrom is the moved modules plus everything that depends on them, through every layer:
|
// reachableFrom is the moved modules plus everything that depends on them, through every layer:
|
||||||
// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine
|
// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine
|
||||||
// is not changed by a new build machine, so a built-by edge orders and gates a plan and never
|
// is not changed by a new build machine, so a built-by edge orders and gates a plan and never
|
||||||
@@ -132,7 +163,7 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hasCycle says whether the tiers' last tier holds modules that still depend on each other. A packages
|
// hasCycle says whether the tiers' last tier holds modules that still depend on each other. A packages
|
||||||
// or built-by edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
|
// edge orders nothing (tiersOf), so a module and what packages its source share a tier by rule: that is
|
||||||
// no cycle, and saying one was is a false report in every such plan's log.
|
// no cycle, and saying one was is a false report in every such plan's log.
|
||||||
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
||||||
if len(tiers) == 0 {
|
if len(tiers) == 0 {
|
||||||
@@ -143,7 +174,7 @@ func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
|||||||
last[m] = true
|
last[m] = true
|
||||||
}
|
}
|
||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
if e.Kind == inventory.EdgePackages || e.Kind == inventory.EdgeBuiltBy {
|
if e.Kind == inventory.EdgePackages {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if last[e.From] && last[e.To] {
|
if last[e.From] && last[e.To] {
|
||||||
@@ -189,6 +220,12 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
inTier := map[string]bool{}
|
inTier := map[string]bool{}
|
||||||
|
all := map[string]bool{}
|
||||||
|
for _, tier := range p.Tiers {
|
||||||
|
for _, m := range tier {
|
||||||
|
all[m] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, m := range p.Tiers[p.Tier] {
|
for _, m := range p.Tiers[p.Tier] {
|
||||||
inTier[m] = true
|
inTier[m] = true
|
||||||
}
|
}
|
||||||
@@ -203,8 +240,8 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool)
|
|||||||
for _, e := range edges {
|
for _, e := range edges {
|
||||||
// A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the
|
// A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the
|
||||||
// member before is running on its machines before the member after is asked.
|
// member before is running on its machines before the member after is asked.
|
||||||
// A built-by edge waits for nothing since novox/hq ADR 0360 §3.
|
ordered := e.Kind == edgeGroupOrder ||
|
||||||
ordered := e.Kind == edgeGroupOrder
|
e.Kind == inventory.EdgeBuiltBy && !isBaseOf(e.From, e.To, edges, all)
|
||||||
if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) {
|
if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) {
|
||||||
seen[e.To] = true
|
seen[e.To] = true
|
||||||
out = append(out, e.To)
|
out = append(out, e.To)
|
||||||
@@ -349,73 +386,15 @@ var checkedBuildsOf = func(ctx context.Context, pulls []link.PullRef) ([]link.Ch
|
|||||||
return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept))
|
return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept))
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergedHeadsOf are the head commits the walk's merges merged, by pull request (novox/hq issue 703): a pull request
|
|
||||||
// merged twice in one walk, or whose head the announcement did not say, has none, and takes no check's build.
|
|
||||||
func mergedHeadsOf(p *inventory.Plan) map[link.PullRef]string {
|
|
||||||
heads := map[link.PullRef]string{}
|
|
||||||
if p.Delivery == nil {
|
|
||||||
return heads
|
|
||||||
}
|
|
||||||
seen := map[link.PullRef]bool{}
|
|
||||||
for _, m := range p.Delivery.Merges {
|
|
||||||
pull, ok := pullOf(m)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seen[pull] && heads[pull] != m.Head {
|
|
||||||
heads[pull] = ""
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[pull] = true
|
|
||||||
heads[pull] = m.Head
|
|
||||||
}
|
|
||||||
return heads
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkVerdicts are the checks' builds a walk read, by the walk's plan: read once per walk (novox/hq issue 649), not
|
|
||||||
// once per module it asks for. Kept a day; a walk lasts minutes.
|
|
||||||
var walkVerdicts = struct {
|
|
||||||
sync.Mutex
|
|
||||||
read map[string]verdictsRead
|
|
||||||
}{read: map[string]verdictsRead{}}
|
|
||||||
|
|
||||||
type verdictsRead struct {
|
|
||||||
at time.Time
|
|
||||||
builds []link.CheckedBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
// reuseWithVerdicts is a walk's leave with the builds its pull requests' verdicts name: the only builds the builder
|
// reuseWithVerdicts is a walk's leave with the builds its pull requests' verdicts name: the only builds the builder
|
||||||
// may take (ADR 0350 §2), and **only those of a check of the head each merge merged** (novox/hq issue 703): a build
|
// may take (ADR 0350 §2). When they cannot be read the leave names none, and the walk builds, saying why.
|
||||||
// a check made at an earlier head of the same pull request is never registered. Read once per walk. When they
|
func reuseWithVerdicts(ctx context.Context, r *link.ReuseAsk) *link.ReuseAsk {
|
||||||
// cannot be read the leave names none, and the walk builds, saying why.
|
builds, err := checkedBuildsOf(ctx, r.Pulls)
|
||||||
func reuseWithVerdicts(ctx context.Context, walk string, r *link.ReuseAsk, heads map[link.PullRef]string) *link.ReuseAsk {
|
if err != nil {
|
||||||
walkVerdicts.Lock()
|
fmt.Printf(" the checks' builds of %d pull request(s) could not be read, so the walk builds: %v\n", len(r.Pulls), err)
|
||||||
read, known := walkVerdicts.read[walk]
|
return r
|
||||||
walkVerdicts.Unlock()
|
|
||||||
if !known || walk == "" {
|
|
||||||
builds, err := checkedBuildsOf(ctx, r.Pulls)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" the checks' builds of %d pull request(s) could not be read, so the walk builds: %v\n", len(r.Pulls), err)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
read = verdictsRead{at: time.Now(), builds: builds}
|
|
||||||
if walk != "" {
|
|
||||||
walkVerdicts.Lock()
|
|
||||||
for id, w := range walkVerdicts.read {
|
|
||||||
if time.Since(w.at) > 24*time.Hour {
|
|
||||||
delete(walkVerdicts.read, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walkVerdicts.read[walk] = read
|
|
||||||
walkVerdicts.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.Builds = nil
|
|
||||||
for _, b := range read.builds {
|
|
||||||
if link.SameHead(b.Head, heads[b.Pull]) {
|
|
||||||
r.Builds = append(r.Builds, b)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
r.Builds = builds
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,8 +437,7 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
|||||||
}
|
}
|
||||||
// **The walk takes the check's build of the same key** (novox/hq ADR 0350 §2): its ask says so, with the pull
|
// **The walk takes the check's build of the same key** (novox/hq ADR 0350 §2): its ask says so, with the pull
|
||||||
// requests it answers, whose checks name the part of a differing key.
|
// requests it answers, whose checks name the part of a differing key.
|
||||||
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, p.ID, reuseAskOf(p), mergedHeadsOf(p))), source,
|
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, reuseAskOf(p))), source, e.Source.Path, ref)
|
||||||
e.Source.Path, ref)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
state.State = "failed"
|
state.State = "failed"
|
||||||
state.Why = err.Error()
|
state.Why = err.Error()
|
||||||
@@ -850,12 +828,6 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
// **Every machine at once** (novox/hq ADR 0360 §1, §2): a module that rolls out is sent to every machine
|
|
||||||
// running it in one step, with no first machine and no gate, whatever module it is. A module a controller
|
|
||||||
// before ADR 0360 already sent to its first machine finishes under its gate.
|
|
||||||
if !gateFirstMachine && state.FirstAt == nil {
|
|
||||||
policy.Together = true
|
|
||||||
}
|
|
||||||
var reports []inventory.Reported
|
var reports []inventory.Reported
|
||||||
if !policy.Together {
|
if !policy.Together {
|
||||||
// Read for the choice of the first machine as well as for its report.
|
// Read for the choice of the first machine as well as for its report.
|
||||||
@@ -1085,92 +1057,15 @@ func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|||||||
return changed, nil
|
return changed, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **A walk ends on its machines' reports** (novox/hq ADR 0360 §4, §5): after its last tier, every machine it sent
|
|
||||||
// has reported on that send, or is silent past ApplySilentAfter; never a health reading.
|
|
||||||
end := ""
|
|
||||||
if p.Tier == len(p.Tiers)-1 {
|
|
||||||
reports, err := inv.LastReports(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
waiting, failed, silent := walkReports(*p, reports, time.Now().UTC())
|
|
||||||
if len(waiting) > 0 {
|
|
||||||
note := "sent; waiting for " + strings.Join(waiting, ", ") + " to report on their send"
|
|
||||||
changed := p.State != inventory.PlanRolling || p.Note != note
|
|
||||||
p.State = inventory.PlanRolling
|
|
||||||
p.Note = note
|
|
||||||
return changed, nil
|
|
||||||
}
|
|
||||||
var said []string
|
|
||||||
if len(failed) > 0 {
|
|
||||||
said = append(said, "failed or refused its send (apply-failing says it): "+strings.Join(failed, ", "))
|
|
||||||
}
|
|
||||||
if len(silent) > 0 {
|
|
||||||
said = append(said, fmt.Sprintf("said nothing on its send within %s: %s", inventory.ApplySilentAfter,
|
|
||||||
strings.Join(silent, ", ")))
|
|
||||||
}
|
|
||||||
end = strings.Join(said, "; ")
|
|
||||||
// **The new controller's user list follows its walk** (ADR 0236 §34, read without the gate since ADR 0360):
|
|
||||||
// once a walk that moved the controller ends, the bus's machine is sent the list it composes, if behind.
|
|
||||||
if s := p.Modules[catalogue.ControllerSeatName]; s != nil && s.SentAt != nil && s.Gate == nil && !noMove(s) {
|
|
||||||
carryUserList(ctx, open, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.Tier++
|
p.Tier++
|
||||||
p.State = inventory.PlanBuilding
|
p.State = inventory.PlanBuilding
|
||||||
p.Note = end
|
p.Note = ""
|
||||||
if p.Tier < len(p.Tiers) {
|
if p.Tier < len(p.Tiers) {
|
||||||
fmt.Printf("%s: tier %d done; asking tier %d: %s\n", p.ID, p.Tier-1, p.Tier, strings.Join(p.Tiers[p.Tier], ", "))
|
fmt.Printf("%s: tier %d done; asking tier %d: %s\n", p.ID, p.Tier-1, p.Tier, strings.Join(p.Tiers[p.Tier], ", "))
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkReports is, for every machine a walk sent its rest, whether it reported on that send (novox/hq ADR 0360 §4):
|
|
||||||
// those still waiting, those that reported a failed or refused apply, and those silent past ApplySilentAfter,
|
|
||||||
// each sorted. Pure.
|
|
||||||
func walkReports(p inventory.Plan, reports []inventory.Reported, now time.Time) (waiting, failed, silent []string) {
|
|
||||||
byNode := map[string]inventory.Reported{}
|
|
||||||
for _, r := range reports {
|
|
||||||
byNode[r.Node] = r
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, s := range p.Modules {
|
|
||||||
if s == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for node, sent := range s.Rest {
|
|
||||||
if seen[node] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := byNode[node]
|
|
||||||
at := sent.At
|
|
||||||
if at.IsZero() && s.SentAt != nil {
|
|
||||||
at = *s.SentAt
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case r.At != nil && sent.ReportsOn(r):
|
|
||||||
seen[node] = true
|
|
||||||
if r.Outcome == inventory.OutcomeFailed || r.Outcome == inventory.OutcomeRefused {
|
|
||||||
failed = append(failed, node)
|
|
||||||
}
|
|
||||||
case !at.IsZero() && now.Sub(at) > inventory.ApplySilentAfter:
|
|
||||||
seen[node] = true
|
|
||||||
silent = append(silent, node)
|
|
||||||
default:
|
|
||||||
if !slices.Contains(waiting, node) {
|
|
||||||
waiting = append(waiting, node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A machine one module's send was reported on and another's not yet is the same send: reported.
|
|
||||||
waiting = slices.DeleteFunc(waiting, func(n string) bool { return seen[n] })
|
|
||||||
sort.Strings(waiting)
|
|
||||||
sort.Strings(failed)
|
|
||||||
sort.Strings(silent)
|
|
||||||
return waiting, failed, silent
|
|
||||||
}
|
|
||||||
|
|
||||||
// restOf is, for one module, every machine of its rest that the send reached and what it carried there.
|
// restOf is, for one module, every machine of its rest that the send reached and what it carried there.
|
||||||
func restOf(to, sent []string, what map[string]inventory.SentDeclaration) map[string]inventory.SentDeclaration {
|
func restOf(to, sent []string, what map[string]inventory.SentDeclaration) map[string]inventory.SentDeclaration {
|
||||||
out := map[string]inventory.SentDeclaration{}
|
out := map[string]inventory.SentDeclaration{}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -50,10 +49,8 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
|||||||
if pos["mesh-tools"] != 0 || pos["builder"] != 1 {
|
if pos["mesh-tools"] != 0 || pos["builder"] != 1 {
|
||||||
t.Fatalf("the runtime then the builder: %v", tiers)
|
t.Fatalf("the runtime then the builder: %v", tiers)
|
||||||
}
|
}
|
||||||
// Built-by orders nothing (novox/hq ADR 0360 §3): what the builder builds is beside it, after the runtime it
|
if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) {
|
||||||
// stands on.
|
t.Fatalf("what the builder builds comes after the builder: %v", tiers)
|
||||||
if pos["shop"] != 1 || pos["postgres"] != 1 {
|
|
||||||
t.Fatalf("what the builder builds waits for the builder: %v", tiers)
|
|
||||||
}
|
}
|
||||||
if pos["shop-plugin"] <= pos["shop"] {
|
if pos["shop-plugin"] <= pos["shop"] {
|
||||||
t.Fatalf("a plugin after what it is declared on: %v", tiers)
|
t.Fatalf("a plugin after what it is declared on: %v", tiers)
|
||||||
@@ -70,23 +67,24 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
|||||||
// A change to a package all three build from moves all three. The builder holds
|
// A change to a package all three build from moves all three. The builder holds
|
||||||
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
|
// the build seat, whose worker the controller defines, so it follows the controller (worker-of,
|
||||||
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
|
// novox/hq issue 206), and the controller's built-by edge to it yields: the controller is built by the
|
||||||
// build machine that is running. The proxy is built by whichever builder runs, so it goes beside the
|
// build machine that is running. The proxy is built by the new builder: the controller, the builder,
|
||||||
// controller (novox/hq ADR 0360 §3; it read "the controller, the builder, the proxy" before).
|
// the proxy — the live plan of every controller merge. (This read "the builder, then the controller
|
||||||
|
// and the proxy together" before issue 206, and the fixture had no worker-of edge.)
|
||||||
small := reachableFrom([]string{"mesh-controller", "builder", "route-proxy"}, edges)
|
small := reachableFrom([]string{"mesh-controller", "builder", "route-proxy"}, edges)
|
||||||
smallTiers := tiersOf(small, edges)
|
smallTiers := tiersOf(small, edges)
|
||||||
if got := tiered(smallTiers); got != "mesh-controller,route-proxy | builder" {
|
if got := tiered(smallTiers); got != "mesh-controller | builder | route-proxy" {
|
||||||
t.Fatalf("the controller and the proxy, then the builder: %v", smallTiers)
|
t.Fatalf("the controller, then the builder, then the proxy: %v", smallTiers)
|
||||||
}
|
}
|
||||||
// The builder alone moved: the builder, and nothing it builds.
|
// The builder alone moved: the builder, and nothing it builds.
|
||||||
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
|
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
|
||||||
t.Fatalf("a build machine change rebuilds the build machine alone: %v", only)
|
t.Fatalf("a build machine change rebuilds the build machine alone: %v", only)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing waits on deployment for a built-by edge (novox/hq ADR 0360 §3): the builder gates no tier after it.
|
// Only a runtime dependency gates on deployment, and only when the module rolls out.
|
||||||
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, []string{"mesh-tools"}, edges)
|
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, []string{"mesh-tools"}, edges)
|
||||||
p.Tier = pos["builder"]
|
p.Tier = pos["builder"]
|
||||||
rollsOut := func(m string) bool { return m == "builder" }
|
rollsOut := func(m string) bool { return m == "builder" }
|
||||||
if g := gates(p, edges, rollsOut); len(g) != 0 {
|
if g := gates(p, edges, rollsOut); len(g) != 1 || g[0] != "builder" {
|
||||||
t.Fatalf("the builder gates the tier after it: %v", g)
|
t.Fatalf("the builder gates the tier after it: %v", g)
|
||||||
}
|
}
|
||||||
p.Tier = 0
|
p.Tier = 0
|
||||||
@@ -255,10 +253,10 @@ func TestAChangeToTheBuildAgentRebuildsTheBuildAgentAlone(t *testing.T) {
|
|||||||
t.Fatalf("%v planned %v: the build agent alone", paths, p.Tiers)
|
t.Fatalf("%v planned %v: the build agent alone", paths, p.Tiers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// With what it builds moved beside it, both go in one tier (novox/hq ADR 0360 §3): built-by orders nothing.
|
// With what it builds moved beside it, the agent comes first and they follow: an order, not a widening.
|
||||||
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Commit: "abc"},
|
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Commit: "abc"},
|
||||||
[]string{"build-agent", "redis"}, edges)
|
[]string{"build-agent", "redis"}, edges)
|
||||||
if len(p.Tiers) != 1 || !slices.Equal(p.Tiers[0], []string{"build-agent", "redis"}) || len(p.Modules) != 2 {
|
if len(p.Tiers) != 2 || p.Tiers[0][0] != "build-agent" || p.Tiers[1][0] != "redis" || len(p.Modules) != 2 {
|
||||||
t.Fatalf("the agent and what moved beside it, in one tier: %v", p.Tiers)
|
t.Fatalf("the agent, then what moved beside it: %v", p.Tiers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,13 +111,8 @@ func aBacklog(t *testing.T) *backlogMesh {
|
|||||||
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
|
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// A broken machine reports its apply failed (novox/hq ADR 0360 §7: the report is the verdict).
|
|
||||||
outcome := inventory.OutcomeApplied
|
|
||||||
if b.broken[node] {
|
|
||||||
outcome = inventory.OutcomeFailed
|
|
||||||
}
|
|
||||||
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
|
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
|
||||||
Outcome: outcome, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
|
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +164,6 @@ func (b *backlogMesh) release(t *testing.T) inventory.Plan {
|
|||||||
// The backlog goes out one machine at a time: the first judged before the second is sent, each build's
|
// The backlog goes out one machine at a time: the first judged before the second is sent, each build's
|
||||||
// pass kept; a rebuild with the same bytes is no move at all; and a send outside a gate is refused.
|
// pass kept; a rebuild with the same bytes is no move at all; and a send outside a gate is refused.
|
||||||
func TestTheBacklogIsReleasedOneMachineAtATimeEachJudged(t *testing.T) {
|
func TestTheBacklogIsReleasedOneMachineAtATimeEachJudged(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -225,7 +219,6 @@ func TestTheBacklogIsReleasedOneMachineAtATimeEachJudged(t *testing.T) {
|
|||||||
// A release that fails on its first machine puts back what it carried there, goes no further, and the
|
// A release that fails on its first machine puts back what it carried there, goes no further, and the
|
||||||
// next one waits for a person, said as a condition; a person releases it with why.
|
// next one waits for a person, said as a condition; a person releases it with why.
|
||||||
func TestAFailedReleaseIsPutBackAndTheNextWaitsForAPerson(t *testing.T) {
|
func TestAFailedReleaseIsPutBackAndTheNextWaitsForAPerson(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := b.open.inventory
|
inv := b.open.inventory
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import (
|
|||||||
// nothing is put back, the walk goes on to the workstation, and openrazer's condition says, in one sentence
|
// nothing is put back, the walk goes on to the workstation, and openrazer's condition says, in one sentence
|
||||||
// for the operator, that a new login is needed on the laptop.
|
// for the operator, that a new login is needed on the laptop.
|
||||||
func TestReplay318(t *testing.T) {
|
func TestReplay318(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
// at its gate with the pass's own words, and put it back. Written with only what the controller had before
|
// at its gate with the pass's own words, and put it back. Written with only what the controller had before
|
||||||
// its fix, so it is laid over the commit before.
|
// its fix, so it is laid over the commit before.
|
||||||
func TestReplay335(t *testing.T) {
|
func TestReplay335(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
t.Run("the first machine's later reports", testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports)
|
t.Run("the first machine's later reports", testAPassedGateIsNotJudgedAgainFromTheFirstMachinesLaterReports)
|
||||||
t.Run("a walk stopped after the pass", testAWalkStoppedAfterItsGatePassedKeepsThePass)
|
t.Run("a walk stopped after the pass", testAWalkStoppedAfterItsGatePassedKeepsThePass)
|
||||||
}
|
}
|
||||||
@@ -78,7 +77,6 @@ func testAWalkStoppedAfterItsGatePassedKeepsThePass(t *testing.T) {
|
|||||||
// the first send is older than the wait for a first machine's report. Read before the pass is taken over, the
|
// the first send is older than the wait for a first machine's report. Read before the pass is taken over, the
|
||||||
// carried module's step said the first machine never applied it, and the passed build was put back.
|
// carried module's step said the first machine never applied it, and the passed build was put back.
|
||||||
func TestACarriedModuleTakesItsLeadsPassBeforeItsStepIsRead(t *testing.T) {
|
func TestACarriedModuleTakesItsLeadsPassBeforeItsStepIsRead(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
tm := aTierMesh(t, "m01", "m02")
|
tm := aTierMesh(t, "m01", "m02")
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
// had before its fix — the stores, register, assign, a plan saved and advanced, the watchdogs' gathering and
|
// had before its fix — the stores, register, assign, a plan saved and advanced, the watchdogs' gathering and
|
||||||
// seeing — so it is laid over the commit before.
|
// seeing — so it is laid over the commit before.
|
||||||
func TestReplay336(t *testing.T) {
|
func TestReplay336(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ func TestReplay273AConsumerBesideItsStoreStaysBoundToIt(t *testing.T) {
|
|||||||
// one, kept below. Heard as bytes, so an older controller reads them as it reads any report — this file is
|
// one, kept below. Heard as bytes, so an older controller reads them as it reads any report — this file is
|
||||||
// written only with what the controller had before the judging, for the prover to lay over that commit.
|
// written only with what the controller had before the judging, for the prover to lay over that commit.
|
||||||
func TestReplayCrashLoopFailsItsGateOnTheFirstMachine(t *testing.T) {
|
func TestReplayCrashLoopFailsItsGateOnTheFirstMachine(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate's own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// novox/hq ADR 0353, kept behind gateFirstMachine for walks a controller before ADR 0360 started: after its first machine passes, a build goes to every other machine at once, each judged by
|
// novox/hq ADR 0353: after its first machine passes, a build goes to every other machine at once, each judged by
|
||||||
// its own gate and a failing one put back by itself; and a gate's readings outlive the controller that took them —
|
// its own gate and a failing one put back by itself; and a gate's readings outlive the controller that took them —
|
||||||
// a machine already sent what a send is for is not sent again, and its judging goes on from what was read.
|
// a machine already sent what a send is for is not sent again, and its judging goes on from what was read.
|
||||||
//
|
//
|
||||||
@@ -62,7 +62,6 @@ func events(t *testing.T) *[]string {
|
|||||||
// Once the first machine passed, the rest are both sent before either is judged (ADR 0353 §1); before, the second
|
// Once the first machine passed, the rest are both sent before either is judged (ADR 0353 §1); before, the second
|
||||||
// was judged to its verdict before the third was sent.
|
// was judged to its verdict before the third was sent.
|
||||||
func TestAReleaseSendsTheRestAtOnceAfterTheFirstPasses(t *testing.T) {
|
func TestAReleaseSendsTheRestAtOnceAfterTheFirstPasses(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklogOnThree(t)
|
b := aBacklogOnThree(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -90,7 +89,6 @@ func TestAReleaseSendsTheRestAtOnceAfterTheFirstPasses(t *testing.T) {
|
|||||||
// A machine of the rest whose gate fails is put back at once, and the other machine of the step goes on to its own
|
// A machine of the rest whose gate fails is put back at once, and the other machine of the step goes on to its own
|
||||||
// verdict (ADR 0353 §3); before, the walk stopped at the failure and the machine after it was never sent.
|
// verdict (ADR 0353 §3); before, the walk stopped at the failure and the machine after it was never sent.
|
||||||
func TestARestMachineThatFailsIsPutBackAloneAndTheOthersAreJudgedOn(t *testing.T) {
|
func TestARestMachineThatFailsIsPutBackAloneAndTheOthersAreJudgedOn(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklogOnThree(t)
|
b := aBacklogOnThree(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -127,7 +125,6 @@ func TestARestMachineThatFailsIsPutBackAloneAndTheOthersAreJudgedOn(t *testing.T
|
|||||||
// A walk whose first machine already runs its build under another walk's gate sends nothing and goes on from that
|
// A walk whose first machine already runs its build under another walk's gate sends nothing and goes on from that
|
||||||
// gate's readings (ADR 0353 §7); before, it sent the machine again and judged from its first reading.
|
// gate's readings (ADR 0353 §7); before, it sent the machine again and judged from its first reading.
|
||||||
func TestAWalkTakesOverTheReadingsOfAGateJudgingTheSameBuildThere(t *testing.T) {
|
func TestAWalkTakesOverTheReadingsOfAGateJudgingTheSameBuildThere(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -166,7 +163,6 @@ func TestAWalkTakesOverTheReadingsOfAGateJudgingTheSameBuildThere(t *testing.T)
|
|||||||
// A walk's first send that was made and not kept — the controller replaced between the two — is not made again
|
// A walk's first send that was made and not kept — the controller replaced between the two — is not made again
|
||||||
// by the controller that takes over: the gate counts from that send (ADR 0353 §5, §6).
|
// by the controller that takes over: the gate counts from that send (ADR 0353 §5, §6).
|
||||||
func TestAFirstSendMadeAndNotKeptIsNotSentAgain(t *testing.T) {
|
func TestAFirstSendMadeAndNotKeptIsNotSentAgain(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
g := aGateMesh(t)
|
g := aGateMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -204,7 +200,6 @@ func TestAFirstSendMadeAndNotKeptIsNotSentAgain(t *testing.T) {
|
|||||||
// A release's send kept before it was made, and made, is judged by the controller that takes over without being
|
// A release's send kept before it was made, and made, is judged by the controller that takes over without being
|
||||||
// made again (ADR 0353 §8).
|
// made again (ADR 0353 §8).
|
||||||
func TestAReleaseSendKeptAndMadeIsNotMadeAgain(t *testing.T) {
|
func TestAReleaseSendKeptAndMadeIsNotMadeAgain(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -236,7 +231,6 @@ func TestAReleaseSendKeptAndMadeIsNotMadeAgain(t *testing.T) {
|
|||||||
// A build no gate has passed goes to one machine of a step, its first; a machine that would be its second waits
|
// A build no gate has passed goes to one machine of a step, its first; a machine that would be its second waits
|
||||||
// for the next step, and one whose every build passed goes now (ADR 0353 §2).
|
// for the next step, and one whose every build passed goes now (ADR 0353 §2).
|
||||||
func TestABuildThatPassedNoGateGoesToOneMachineOfAStep(t *testing.T) {
|
func TestABuildThatPassedNoGateGoesToOneMachineOfAStep(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
waiting := map[string][]inventory.CarriedMove{
|
waiting := map[string][]inventory.CarriedMove{
|
||||||
"desk": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
|
"desk": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
|
||||||
"laptop": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
|
"laptop": {{Module: "app", To: "c2"}, {Module: "new", To: "n1"}},
|
||||||
@@ -257,7 +251,6 @@ func TestABuildThatPassedNoGateGoesToOneMachineOfAStep(t *testing.T) {
|
|||||||
// The node-engine and the mesh's resolver go to the rest one machine at a time, even passed (ADR 0353 §9): a step
|
// The node-engine and the mesh's resolver go to the rest one machine at a time, even passed (ADR 0353 §9): a step
|
||||||
// carries either to one machine only, while what else passed goes everywhere at once.
|
// carries either to one machine only, while what else passed goes everywhere at once.
|
||||||
func TestTheNodeEngineAndTheResolverGoToTheRestOneAtATime(t *testing.T) {
|
func TestTheNodeEngineAndTheResolverGoToTheRestOneAtATime(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
waiting := map[string][]inventory.CarriedMove{
|
waiting := map[string][]inventory.CarriedMove{
|
||||||
"desk": {{Module: hostModule, To: "h2"}},
|
"desk": {{Module: hostModule, To: "h2"}},
|
||||||
"laptop": {{Module: hostModule, To: "h2"}, {Module: "app", To: "c2"}},
|
"laptop": {{Module: hostModule, To: "h2"}, {Module: "app", To: "c2"}},
|
||||||
@@ -284,7 +277,6 @@ func TestTheNodeEngineAndTheResolverGoToTheRestOneAtATime(t *testing.T) {
|
|||||||
|
|
||||||
// A first machine that fails still stops the walk (ADR 0236 §5, kept by ADR 0353 §3): the rest are never sent.
|
// A first machine that fails still stops the walk (ADR 0236 §5, kept by ADR 0353 §3): the rest are never sent.
|
||||||
func TestAFirstMachineThatFailsStillStopsTheWalk(t *testing.T) {
|
func TestAFirstMachineThatFailsStillStopsTheWalk(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
b := aBacklogOnThree(t)
|
b := aBacklogOnThree(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
gateEvery = 0
|
gateEvery = 0
|
||||||
@@ -306,7 +298,6 @@ func TestAFirstMachineThatFailsStillStopsTheWalk(t *testing.T) {
|
|||||||
// The control node is sent last in a step of the rest (ADR 0353 §1): the machine holding the controller is
|
// The control node is sent last in a step of the rest (ADR 0353 §1): the machine holding the controller is
|
||||||
// ordered after every other.
|
// ordered after every other.
|
||||||
func TestTheControlNodeIsSentLastInAStep(t *testing.T) {
|
func TestTheControlNodeIsSentLastInAStep(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklogOnThree(t)
|
b := aBacklogOnThree(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -331,7 +322,6 @@ func TestTheControlNodeIsSentLastInAStep(t *testing.T) {
|
|||||||
// A machine of the rest that says nothing on its send is left silent after fifteen minutes: not failed, not put
|
// A machine of the rest that says nothing on its send is left silent after fifteen minutes: not failed, not put
|
||||||
// back; a rest that went silent whole ends failed, said as a condition (ADR 0353 §4).
|
// back; a rest that went silent whole ends failed, said as a condition (ADR 0353 §4).
|
||||||
func TestARestMachineSilentPastItsBoundIsLeftAndSaid(t *testing.T) {
|
func TestARestMachineSilentPastItsBoundIsLeftAndSaid(t *testing.T) {
|
||||||
withTheGate(t)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
b := aBacklog(t)
|
b := aBacklog(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A module moves only when its program changes** (novox/hq issue 721). A merge check builds every module the
|
|
||||||
// delivery plan moves and compares each with the build the mesh holds of it; a walk is cut without a module whose
|
|
||||||
// check found its whole image the same as that build, while the mesh still holds it, and says so among the modules
|
|
||||||
// it left unchanged. A merge of one sentence in a package a program imports but never reaches moves no build of it.
|
|
||||||
|
|
||||||
// sameAsRunning are the modules of a walk its checks found the same as the build the mesh holds, each with what its
|
|
||||||
// check said. **Only when every pull request the walk answers was checked at the head it merged, and every one of
|
|
||||||
// those checks built the module and found it the same as the build the mesh still holds**: a merge the walk answers
|
|
||||||
// without a pull request, a check of another head, a check that built it differently, or a held build that moved
|
|
||||||
// since, leaves the module in the walk.
|
|
||||||
func sameAsRunning(plan *inventory.Plan, builds []link.CheckedBuild, held map[string]string) map[string]string {
|
|
||||||
if plan.Delivery == nil || len(plan.Delivery.Merges) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, m := range plan.Delivery.Merges {
|
|
||||||
if _, ok := pullOf(m); !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pulls := reuseAskOf(plan).Pulls
|
|
||||||
heads := mergedHeadsOf(plan)
|
|
||||||
out := map[string]string{}
|
|
||||||
for name := range plan.Modules {
|
|
||||||
said := ""
|
|
||||||
for _, pull := range pulls {
|
|
||||||
b, ok := checkedBuildOf(builds, pull, heads[pull], name)
|
|
||||||
if !ok || !stillHeld(b.Built, name, held) {
|
|
||||||
said = ""
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if said == "" {
|
|
||||||
said = b.Built.Same
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if said != "" {
|
|
||||||
out[name] = said
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkedBuildOf is the newest build a check of the pull request at head made of the module; false when none did.
|
|
||||||
func checkedBuildOf(builds []link.CheckedBuild, pull link.PullRef, head, module string) (link.CheckedBuild, bool) {
|
|
||||||
if head == "" {
|
|
||||||
return link.CheckedBuild{}, false
|
|
||||||
}
|
|
||||||
for _, b := range builds {
|
|
||||||
if b.Pull == pull && b.Built.Module == module && link.SameHead(b.Head, head) {
|
|
||||||
return b, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return link.CheckedBuild{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// stillHeld is whether a check found every artifact of the module the same as a build the mesh still holds.
|
|
||||||
func stillHeld(b link.CheckBuilt, module string, held map[string]string) bool {
|
|
||||||
if b.Same == "" || b.NotBuilt != "" || len(b.Made) == 0 || len(b.SameAs) != len(b.Made) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, m := range b.Made {
|
|
||||||
was := pinnedDigest(b.SameAs[m.Name])
|
|
||||||
if was == "" || was != pinnedDigest(held[module+"/"+m.Name]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// pinnedDigest is a reference's pinned digest, empty when it is not pinned.
|
|
||||||
func pinnedDigest(ref string) string {
|
|
||||||
if i := strings.LastIndex(ref, "@"); i >= 0 {
|
|
||||||
return ref[i+1:]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// leaveSameAsRunning takes from a walk being cut every module its checks found the same as the build the mesh holds
|
|
||||||
// (sameAsRunning), and every dependent in the walk only for one of those (withDependentsOf): what was taken, with why. **Anything not read leaves the walk as it was**, said.
|
|
||||||
func leaveSameAsRunning(ctx context.Context, inv *inventory.Inventory, plan *inventory.Plan, moved []string,
|
|
||||||
edges []inventory.Edge) map[string]string {
|
|
||||||
if plan.Delivery == nil || len(plan.Modules) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pulls := reuseAskOf(plan).Pulls
|
|
||||||
if len(pulls) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
builds, err := checkedBuildsOf(ctx, pulls)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" the checks' builds could not be read, so every module moves: %v\n", err)
|
|
||||||
// empty-on-error: said above; nothing left out of the walk is the safe answer, every module moves as before
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
held, err := inv.RegisteredHeld(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" what the mesh holds could not be read, so every module moves: %v\n", err)
|
|
||||||
// empty-on-error: said above; nothing left out of the walk is the safe answer, every module moves as before
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
same := withDependentsOf(sameAsRunning(plan, builds, held), plan, moved, edges)
|
|
||||||
if len(same) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(same))
|
|
||||||
for n := range same {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
withhold(plan, names, nil)
|
|
||||||
for _, n := range names {
|
|
||||||
fmt.Printf(" %s left out: %s\n", n, same[n])
|
|
||||||
}
|
|
||||||
return same
|
|
||||||
}
|
|
||||||
|
|
||||||
// withDependentsOf adds to the modules found the same every module of the walk that is in it only as a dependent of
|
|
||||||
// one of them (novox/hq ADR 0267 rule 3): **a dependent is left out when its base comes out the same**, since the
|
|
||||||
// base it stands on stays the build the mesh holds. A module a merge moves itself, or a dependent of a base that
|
|
||||||
// still moves, stays. moved are the modules the walk's merges move; edges the graph the walk was planned along.
|
|
||||||
func withDependentsOf(same map[string]string, plan *inventory.Plan, moved []string, edges []inventory.Edge) map[string]string {
|
|
||||||
if len(same) == 0 {
|
|
||||||
return same
|
|
||||||
}
|
|
||||||
var still []string
|
|
||||||
for _, n := range moved {
|
|
||||||
if same[n] == "" {
|
|
||||||
still = append(still, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keep := map[string]bool{}
|
|
||||||
for _, n := range reachableFrom(still, edges) {
|
|
||||||
keep[n] = true
|
|
||||||
}
|
|
||||||
// Until nothing more is found: a dependent of a dependent left out is left out too.
|
|
||||||
for grew := true; grew; {
|
|
||||||
grew = false
|
|
||||||
for n := range plan.Modules {
|
|
||||||
if keep[n] || same[n] != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var bases []string
|
|
||||||
for _, e := range edges {
|
|
||||||
ordering := e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf ||
|
|
||||||
e.Kind == inventory.EdgePackages || e.Kind == edgeGroupOrder
|
|
||||||
if !ordering && e.From == n && same[e.To] != "" && !slices.Contains(bases, e.To) {
|
|
||||||
bases = append(bases, e.To)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(bases)
|
|
||||||
if len(bases) == 0 {
|
|
||||||
// In the walk for no base found the same: whatever brought it in is not said here, so it moves.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
same[n] = "unchanged: it is in the walk only for " + strings.Join(bases, ", ") +
|
|
||||||
", which came out the same as the build the mesh holds (ADR 0267 rule 3)"
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return same
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAWalkLeavesOutAModuleWhoseProgramDidNotChange is novox/hq issue 721: mesh-controller #335 changed one sentence
|
|
||||||
// that mesh-builder never reaches; its check found build-agent the same as the build the mesh holds, so the walk moves
|
|
||||||
// mesh-controller alone and says build-agent is unchanged.
|
|
||||||
func TestAWalkLeavesOutAModuleWhoseProgramDidNotChange(t *testing.T) {
|
|
||||||
pull := link.PullRef{Owner: "novox", Repo: "mesh-controller", Number: 335}
|
|
||||||
head := "34050dc6aaaabbbbccccdddd"
|
|
||||||
aPlan := func() *inventory.Plan {
|
|
||||||
return &inventory.Plan{Tiers: [][]string{{"mesh-controller", "build-agent"}},
|
|
||||||
Modules: map[string]*inventory.PlanModule{"mesh-controller": {}, "build-agent": {}},
|
|
||||||
Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{{Repository: "novox/mesh-controller",
|
|
||||||
Commit: "c2ec0a60", Number: 335, Head: head}}}}
|
|
||||||
}
|
|
||||||
held := map[string]string{"build-agent/agent": "store/build-agent@sha256:aaaa",
|
|
||||||
"mesh-controller/controller": "store/mesh-controller@sha256:cccc"}
|
|
||||||
builds := []link.CheckedBuild{
|
|
||||||
{Pull: pull, Head: head[:12], Built: link.CheckBuilt{Module: "build-agent", Key: "k1",
|
|
||||||
Made: []link.MadeArtifact{{Name: "agent", Kind: "image", Reference: "scratch/build-agent@sha256:bbbb"}},
|
|
||||||
Same: "unchanged: its program is the same as running build agent aaaa",
|
|
||||||
SameAs: map[string]string{"agent": "10.0.0.1:5000/build-agent@sha256:aaaa"}}},
|
|
||||||
{Pull: pull, Head: head[:12], Built: link.CheckBuilt{Module: "mesh-controller", Key: "k2",
|
|
||||||
Made: []link.MadeArtifact{{Name: "controller", Kind: "image", Reference: "scratch/mesh-controller@sha256:dddd"}}}},
|
|
||||||
}
|
|
||||||
|
|
||||||
p := aPlan()
|
|
||||||
same := sameAsRunning(p, builds, held)
|
|
||||||
if len(same) != 1 || same["build-agent"] == "" {
|
|
||||||
t.Fatalf("the walk leaves out %v, want build-agent alone", same)
|
|
||||||
}
|
|
||||||
withhold(p, []string{"build-agent"}, nil)
|
|
||||||
if len(p.Tiers) != 1 || len(p.Tiers[0]) != 1 || p.Tiers[0][0] != "mesh-controller" {
|
|
||||||
t.Fatalf("the walk moves %v, want mesh-controller alone", p.Tiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// **The held build moved since the check**: what it was compared with no longer runs.
|
|
||||||
moved := map[string]string{"build-agent/agent": "store/build-agent@sha256:eeee"}
|
|
||||||
if same := sameAsRunning(aPlan(), builds, moved); len(same) != 0 {
|
|
||||||
t.Fatalf("a module compared with a build the mesh no longer holds is left out: %v", same)
|
|
||||||
}
|
|
||||||
// **A check of another head** says nothing of this merge.
|
|
||||||
other := aPlan()
|
|
||||||
other.Delivery.Merges[0].Head = "ffffffffffffffff"
|
|
||||||
if same := sameAsRunning(other, builds, held); len(same) != 0 {
|
|
||||||
t.Fatalf("a check of another head left out %v", same)
|
|
||||||
}
|
|
||||||
// **Another pull request the walk answers** that did not find it the same leaves it in.
|
|
||||||
two := aPlan()
|
|
||||||
two.Delivery.Merges = append(two.Delivery.Merges, inventory.PlanMerge{Repository: "novox/mesh-catalog",
|
|
||||||
Commit: "1234", Number: 9, Head: "abcdefabcdefabcdef"})
|
|
||||||
if same := sameAsRunning(two, builds, held); len(same) != 0 {
|
|
||||||
t.Fatalf("a module another merge moves is left out: %v", same)
|
|
||||||
}
|
|
||||||
// **A merge with no pull request** leaves the walk as it is.
|
|
||||||
direct := aPlan()
|
|
||||||
direct.Delivery.Merges[0].Number = 0
|
|
||||||
if same := sameAsRunning(direct, builds, held); len(same) != 0 {
|
|
||||||
t.Fatalf("a walk answering a merge without a pull request left out %v", same)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestADependentOfABaseThatCameOutTheSameIsLeftOut is ADR 0267 rule 3 (novox/hq issue 607): a dependent is in a walk
|
|
||||||
// for its base; when the base comes out the same as the build the mesh holds, the dependent stands on that build
|
|
||||||
// still, and leaves the walk with it — and so does a dependent of that dependent. One a merge moves itself stays.
|
|
||||||
func TestADependentOfABaseThatCameOutTheSameIsLeftOut(t *testing.T) {
|
|
||||||
edges := []inventory.Edge{{From: "app", To: "base", Kind: inventory.EdgeDeclared},
|
|
||||||
{From: "app2", To: "app", Kind: inventory.EdgeDeclared}, {From: "own", To: "base", Kind: inventory.EdgeDeclared}}
|
|
||||||
plan := &inventory.Plan{Modules: map[string]*inventory.PlanModule{"base": {}, "app": {}, "app2": {}, "own": {}}}
|
|
||||||
same := withDependentsOf(map[string]string{"base": "unchanged: its program is the same"}, plan,
|
|
||||||
[]string{"base", "own"}, edges)
|
|
||||||
if !strings.Contains(same["app"], "only for base") || !strings.Contains(same["app2"], "only for app") {
|
|
||||||
t.Fatalf("the dependents of a base that came out the same are not left out: %v", same)
|
|
||||||
}
|
|
||||||
if _, left := same["own"]; left {
|
|
||||||
t.Fatalf("a module its merge moves itself is left out for its base: %v", same)
|
|
||||||
}
|
|
||||||
// A dependent of a base that still moves stays.
|
|
||||||
edges = append(edges, inventory.Edge{From: "app", To: "own", Kind: inventory.EdgeDeclared})
|
|
||||||
same = withDependentsOf(map[string]string{"base": "unchanged"}, plan, []string{"base", "own"}, edges)
|
|
||||||
if _, left := same["app"]; left {
|
|
||||||
t.Fatalf("a dependent of a base that still moves is left out: %v", same)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Against the store, the path a real merge takes (novox/hq issues 721 and 611): a merge whose check built its one
|
|
||||||
// module the same as the build the mesh holds is cut into a walk done with nothing to walk, whose record names the
|
|
||||||
// module unchanged and why — the walk mesh-delivery reads as delivered, never stranded.
|
|
||||||
func TestAWalkWhoseEveryBuildCameOutTheSameIsDone(t *testing.T) {
|
|
||||||
open := windowed(t)
|
|
||||||
endsItsLine(t)
|
|
||||||
asksRecorded(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
inv := open.inventory
|
|
||||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-one", Repository: "novox/one", Ref: "main",
|
|
||||||
Module: "one", Commit: "c0", On: "builder", Asked: t0.Add(-time.Hour),
|
|
||||||
Made: []inventory.Artifact{{Name: "one", Kind: "image", Reference: "store/one@sha256:aaaa"}},
|
|
||||||
Sources: []inventory.BuildSource{{Paths: []string{"main.go", "go.mod"}}}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// What runs: the module registered with that build's image (RegisteredHeld reads the module table).
|
|
||||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
|
|
||||||
{"id": "app", "type": "container", "name": "one", "image": "store/one@sha256:aaaa"}}},
|
|
||||||
inventory.Source{Repository: "novox/one", Ref: "main", BuiltFrom: "c0", Asked: time.Now()}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
pull := link.PullRef{Owner: "novox", Repo: "one", Number: 7}
|
|
||||||
was := checkedBuildsOf
|
|
||||||
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
|
|
||||||
return []link.CheckedBuild{{Pull: pull, Head: "headaaaaaaaa", Built: link.CheckBuilt{Module: "one", Key: "k",
|
|
||||||
Made: []link.MadeArtifact{{Name: "one", Kind: "image", Reference: "scratch/one@sha256:bbbb"}},
|
|
||||||
Same: "unchanged: its program is the same as running build one aaaa",
|
|
||||||
SameAs: map[string]string{"one": "10.0.0.1:5000/one@sha256:aaaa"}}}}, nil
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { checkedBuildsOf = was })
|
|
||||||
code := repoMerge("one", "one1aaaaaaaa", t0)
|
|
||||||
code.Number, code.HeadSHA = 7, "headaaaaaaaa1111"
|
|
||||||
hear(t, open, code, t0.Add(time.Second))
|
|
||||||
cutAt(t, open, t0.Add(2*time.Minute))
|
|
||||||
ws, _ := walks(t, open)
|
|
||||||
if len(ws) != 1 {
|
|
||||||
t.Fatalf("walks: %+v", ws)
|
|
||||||
}
|
|
||||||
w := ws[0]
|
|
||||||
if w.State != inventory.PlanDone || len(w.Tiers) != 0 || len(w.Modules) != 0 {
|
|
||||||
t.Fatalf("the walk reads %s, tiers %v, modules %v; want done with nothing to walk", w.State, w.Tiers, w.Modules)
|
|
||||||
}
|
|
||||||
if w.Delivery == nil || !strings.Contains(w.Delivery.Unchanged["one"], "its program is the same as running build") {
|
|
||||||
t.Fatalf("the walk does not say one matched what runs: %+v", w.Delivery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -153,15 +153,11 @@ var signalsTable = []signalRow{
|
|||||||
return newestOf(f.standings, func(c conditions.Condition) time.Time { return c.LastObserved })
|
return newestOf(f.standings, func(c conditions.Condition) time.Time { return c.LastObserved })
|
||||||
}},
|
}},
|
||||||
{Row: "S9", Signal: "bus advisories: maximum deliveries, consumer deleted; the controller's own slow " +
|
{Row: "S9", Signal: "bus advisories: maximum deliveries, consumer deleted; the controller's own slow " +
|
||||||
"consumer and refused subjects; every user's connection the server closed as a slow consumer or for " +
|
"consumer and refused subjects", Emitter: "bus server's advisory subjects; the controller's connection",
|
||||||
"breaking the bus's rules (ADR 0357)",
|
|
||||||
Emitter: "bus server's advisory subjects; the controller's connection; the bus module's watch of its " +
|
|
||||||
"server (bus.slow-consumer, bus.connection-closed)",
|
|
||||||
Trigger: "any", Bound: "any occurrence; clears after an hour without another, a deleted consumer " +
|
Trigger: "any", Bound: "any occurrence; clears after an hour without another, a deleted consumer " +
|
||||||
"once it exists again or the mesh no longer expects it, and a message given up on once DEAD_LETTERS " +
|
"once it exists again or the mesh no longer expects it, and a message given up on once DEAD_LETTERS " +
|
||||||
"no longer holds it (novox/hq issue 330)",
|
"no longer holds it (novox/hq issue 330)",
|
||||||
Kind: "slow-consumer, connection-closed, max-deliveries, refused, consumer-lost", Severity: conditions.Warning,
|
Kind: "slow-consumer, max-deliveries, refused, consumer-lost", Severity: conditions.Warning, Phase: 1,
|
||||||
Phase: 1,
|
|
||||||
needs: func(f *signalFacts) error { return f.advisoriesErr }, watch: watchAdvisories,
|
needs: func(f *signalFacts) error { return f.advisoriesErr }, watch: watchAdvisories,
|
||||||
newest: func(f *signalFacts) time.Time {
|
newest: func(f *signalFacts) time.Time {
|
||||||
return newestOf(f.advisories, func(a link.Advisory) time.Time { return a.Last })
|
return newestOf(f.advisories, func(a link.Advisory) time.Time { return a.Last })
|
||||||
@@ -573,9 +569,6 @@ func watchAdvisories(f *signalFacts) []conditions.Observation {
|
|||||||
// The advisory is remembered and read again on every look for an hour: the condition counts
|
// The advisory is remembered and read again on every look for an hour: the condition counts
|
||||||
// what the bus said and when, not the looks (novox/hq issue 402).
|
// what the bus said and when, not the looks (novox/hq issue 402).
|
||||||
Happened: a.Last, Times: a.Count}
|
Happened: a.Last, Times: a.Count}
|
||||||
if a.User != "" && a.Reason != "" {
|
|
||||||
busUserClosed(&o, a, f.host)
|
|
||||||
}
|
|
||||||
if a.Kind == link.AdvisoryMaxDeliveries && a.Token == link.AdvisoryNotKept {
|
if a.Kind == link.AdvisoryMaxDeliveries && a.Token == link.AdvisoryNotKept {
|
||||||
o.Machine = consumerMachine(a.Stream, a.Consumer)
|
o.Machine = consumerMachine(a.Stream, a.Consumer)
|
||||||
o.Headline = clip(conditions.Capital(fmt.Sprintf("%s gave up on a message, not kept",
|
o.Headline = clip(conditions.Capital(fmt.Sprintf("%s gave up on a message, not kept",
|
||||||
@@ -586,58 +579,9 @@ func watchAdvisories(f *signalFacts) []conditions.Observation {
|
|||||||
}
|
}
|
||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
}
|
}
|
||||||
if f.busFaultsErr != nil {
|
|
||||||
out = append(out, busFaultsUnread(f.busFaultsErr))
|
|
||||||
}
|
|
||||||
return append(out, watchDeadLetters(f)...)
|
return append(out, watchDeadLetters(f)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// kindBusFaultsUnread is the bus module's events not read back (novox/hq ADR 0357): every other user's
|
|
||||||
// slow consumer is unheard while it lasts, and the controller's own signals are still said.
|
|
||||||
const kindBusFaultsUnread = "bus-faults-unread"
|
|
||||||
|
|
||||||
func busFaultsUnread(err error) conditions.Observation {
|
|
||||||
return conditions.Observation{Scope: conditions.ScopeBus, ID: "bus-module", Kind: kindBusFaultsUnread,
|
|
||||||
Severity: conditions.Warning, Confirm: true,
|
|
||||||
Summary: "what the bus module said of its users' connections could not be read back, so a slow consumer " +
|
|
||||||
"or a connection the bus closed is not said until it can",
|
|
||||||
Said: firstLine(err.Error())}
|
|
||||||
}
|
|
||||||
|
|
||||||
// busUserClosed words an advisory about a bus user's connection the server closed (novox/hq ADR 0357): the
|
|
||||||
// user as the operator says it and the machine it is on, the dotted user name kept in the evidence only.
|
|
||||||
func busUserClosed(o *conditions.Observation, a link.Advisory, host string) {
|
|
||||||
who, machine := busUserWords(a.User)
|
|
||||||
if machine == "" && a.User == "controller" {
|
|
||||||
machine = host
|
|
||||||
}
|
|
||||||
o.Machine = machine
|
|
||||||
times, count := "once", "once"
|
|
||||||
if a.Count > 1 {
|
|
||||||
times = fmt.Sprintf("%d times since %s", a.Count, a.First.UTC().Format("15:04 MST"))
|
|
||||||
count = fmt.Sprintf("%d times", a.Count)
|
|
||||||
}
|
|
||||||
switch a.Kind {
|
|
||||||
case link.AdvisorySlowConsumer:
|
|
||||||
o.Summary = fmt.Sprintf("the bus closed the connection of %s as a slow consumer %s in the last hour, "+
|
|
||||||
"newest at %s: it could not take what it was sent fast enough, and what was on its way to it was lost",
|
|
||||||
who, times, a.Last.UTC().Format("15:04 MST"))
|
|
||||||
o.Headline = clip(conditions.Capital(who)+" was too slow for the bus", 60)
|
|
||||||
o.Explanation = conditions.Capital(fmt.Sprintf("%s could not keep up with what the bus sent it, so the bus "+
|
|
||||||
"closed its connection (%s in the last hour) and what was on its way was lost. It connects again "+
|
|
||||||
"by itself; this clears an hour after the last time.", who, count))
|
|
||||||
o.Resolved = "Resolved: " + who + " kept up for an hour"
|
|
||||||
default:
|
|
||||||
o.Summary = fmt.Sprintf("the bus closed the connection of %s for breaking its rules %s in the last hour, "+
|
|
||||||
"newest at %s: %s", who, times, a.Last.UTC().Format("15:04 MST"), strings.ToLower(a.Reason))
|
|
||||||
o.Headline = clip(conditions.Capital(who)+" was cut off by the bus", 60)
|
|
||||||
o.Explanation = conditions.Capital(fmt.Sprintf("The bus closed the connection of %s because it broke "+
|
|
||||||
"the bus's rules (%s, %s in the last hour), so what it was sending was lost. This clears an hour "+
|
|
||||||
"after the last time.", who, strings.ToLower(a.Reason), count))
|
|
||||||
o.Resolved = "Resolved: " + who + " kept to the bus's rules for an hour"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchDeadLetters says each consumer that DEAD_LETTERS holds a message for (novox/hq issue 330): open
|
// watchDeadLetters says each consumer that DEAD_LETTERS holds a message for (novox/hq issue 330): open
|
||||||
// while it holds any, so it clears when they are delivered again or dropped, never because the server
|
// while it holds any, so it clears when they are delivered again or dropped, never because the server
|
||||||
// stopped saying it.
|
// stopped saying it.
|
||||||
@@ -862,7 +806,7 @@ func watchLease(f *signalFacts) []conditions.Observation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(lost) > 0 {
|
if len(lost) > 0 {
|
||||||
how := "lost it: another holder wrote its key, or no renewal was made within the key's age"
|
how := "lost it: its renewal was refused or could not be made"
|
||||||
if newest.How == inventory.EpochExpired {
|
if newest.How == inventory.EpochExpired {
|
||||||
how = "stopped renewing it without giving it back, and was taken over"
|
how = "stopped renewing it without giving it back, and was taken over"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -124,6 +125,11 @@ func openInventory(ctx context.Context) (*inventory.Inventory, error) {
|
|||||||
if aliases, err := inv.Aliases(ctx); err == nil {
|
if aliases, err := inv.Aliases(ctx); err == nil {
|
||||||
catalogue.UseAliases(aliases)
|
catalogue.UseAliases(aliases)
|
||||||
}
|
}
|
||||||
|
// And the list of predefined keys the mesh publishes (novox/hq ADR 0348 §7), which every manifest read again
|
||||||
|
// is judged against. A store a migration has not reached yet keeps none, and nothing names a key then.
|
||||||
|
if err := loadPredefined(ctx, inv); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "the list of predefined keys kept cannot be read: %v\n", err)
|
||||||
|
}
|
||||||
return inv, nil
|
return inv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A new push to a pull request ends the check of its old head (novox/hq issue 726).
|
|
||||||
//
|
|
||||||
// A check judges one head; once the pull request has another, its verdict answers nobody and its run holds a build
|
|
||||||
// machine for minutes the new head waits behind. So when a check is asked for pull request N of a repository at a
|
|
||||||
// head, **every check of that pull request at another head ends**: a waiting one is dropped from the queue as
|
|
||||||
// `cancel` drops it, one in flight is ended on its machine as `kill` ends it. Each is said in the log.
|
|
||||||
//
|
|
||||||
// **Never anything else**: a try (`try`, asked for no pull request), a walk's build (no check), a delivery group's
|
|
||||||
// composed check (it answers several pull requests), another pull request's check, a dead ask (left for `clear
|
|
||||||
// --dead`), and a check of the same head — a recheck of that head is not another head.
|
|
||||||
|
|
||||||
// **Only for the pull request's current head.** Announcements of a pull request are not ordered, one is heard again
|
|
||||||
// after a restart, and a recheck asks for the head it recorded: a check asked for a head the pull request no longer
|
|
||||||
// has must never end the check of the head it has. So the forge is asked first which head the pull request has; only
|
|
||||||
// when it names this one does anything end, and when it cannot be asked, or names another, nothing ends and that is
|
|
||||||
// said.
|
|
||||||
|
|
||||||
// forgePullHead asks the forge, through its module's tools on the bus, which commit a pull request's head is. A
|
|
||||||
// variable so a test needs no forge.
|
|
||||||
var forgePullHead = func(ctx context.Context, js *broker.JetStream, owner, repo string, number int) (string, error) {
|
|
||||||
raw, _ := json.Marshal(map[string]any{"path": fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, number)})
|
|
||||||
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if answer.Error != "" {
|
|
||||||
return "", errors.New(answer.Error)
|
|
||||||
}
|
|
||||||
var pull struct {
|
|
||||||
Result struct {
|
|
||||||
Head struct {
|
|
||||||
SHA string `json:"sha"`
|
|
||||||
} `json:"head"`
|
|
||||||
} `json:"result"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(answer.Result, &pull); err != nil {
|
|
||||||
return "", fmt.Errorf("the forge's answer about the pull request cannot be read: %w", err)
|
|
||||||
}
|
|
||||||
if pull.Result.Head.SHA == "" {
|
|
||||||
return "", errors.New("the forge named no head for the pull request")
|
|
||||||
}
|
|
||||||
return pull.Result.Head.SHA, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// supersededChecks are the checks in the queue that a check of owner/repo#number at head ends: of the same pull
|
|
||||||
// request, at another head, waiting or in flight, and not the ask keep.
|
|
||||||
func supersededChecks(q link.Queue, owner, repo string, number int, head, keep string) []link.QueuedAsk {
|
|
||||||
if number <= 0 || head == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []link.QueuedAsk
|
|
||||||
for _, a := range q.Asks {
|
|
||||||
c := a.Request.Check
|
|
||||||
switch {
|
|
||||||
case c == nil, link.IsTry(a.Request), c.Group != "", c.Number != number, a.ID == keep,
|
|
||||||
!strings.EqualFold(c.Owner, owner), !strings.EqualFold(c.Repo, repo),
|
|
||||||
a.State != link.AskWaiting && a.State != link.AskInFlight:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ref := a.Request.Ref
|
|
||||||
if ref == "" {
|
|
||||||
ref = a.Ref
|
|
||||||
}
|
|
||||||
if ref == "" || ref == head || link.SameHead(ref, head) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, a)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// endSupersededChecks ends the checks a check of p at its head supersedes, keep being the new check's own ask, and
|
|
||||||
// says each in the log. A waiting one is cancelled — nothing recorded: a check builds nothing, and the forge's status
|
|
||||||
// of the old head is no longer the pull request's — and one a machine builds is killed there.
|
|
||||||
func endSupersededChecks(ctx context.Context, js *broker.JetStream, seat string, p link.PullUpdated, keep string) []string {
|
|
||||||
if p.Number <= 0 || p.Commit == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
current, err := forgePullHead(ctx, js, p.Owner, p.Repo, p.Number)
|
|
||||||
if err != nil {
|
|
||||||
return []string{fmt.Sprintf("%s/%s#%d (%.8s): nothing ended — the forge could not say the pull request's "+
|
|
||||||
"current head, so checks of its other heads keep running: %v", p.Owner, p.Repo, p.Number, p.Commit, err)}
|
|
||||||
}
|
|
||||||
if current != p.Commit && !link.SameHead(current, p.Commit) {
|
|
||||||
return []string{fmt.Sprintf("%s/%s#%d (%.8s): nothing ended — the pull request's head is %.12s now, not "+
|
|
||||||
"this one", p.Owner, p.Repo, p.Number, p.Commit, current)}
|
|
||||||
}
|
|
||||||
q, err := link.ReadQueue(ctx, js, seat)
|
|
||||||
if err != nil {
|
|
||||||
return []string{fmt.Sprintf("%s/%s#%d (%.8s): could not read the %s queue for checks of its earlier heads: %v",
|
|
||||||
p.Owner, p.Repo, p.Number, p.Commit, seat, err)}
|
|
||||||
}
|
|
||||||
var said []string
|
|
||||||
for _, a := range supersededChecks(q, p.Owner, p.Repo, p.Number, p.Commit, keep) {
|
|
||||||
old := a.Request.Ref
|
|
||||||
if old == "" {
|
|
||||||
old = a.Ref
|
|
||||||
}
|
|
||||||
what := fmt.Sprintf("check of %s/%s#%d at %.12s", p.Owner, p.Repo, p.Number, old)
|
|
||||||
var err error
|
|
||||||
if a.State == link.AskInFlight && a.On != "" {
|
|
||||||
var answer link.Answer
|
|
||||||
answer, err = link.AskSeatTool(ctx, js.Conn(), seat, "kill", a.On, map[string]string{"id": a.ID}, killAnswer)
|
|
||||||
if err == nil && answer.Error != "" {
|
|
||||||
err = fmt.Errorf("%s refused: %s", a.On, answer.Error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_, err = cancelAsk(ctx, js, nil, seat, a)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
said = append(said, fmt.Sprintf("not ended: %s (%s) — superseded by %.12s, but: %v", what, a.ID, p.Commit, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
said = append(said, fmt.Sprintf("ended: %s — superseded by %.12s (%s, %s)", what, p.Commit, a.ID, a.State))
|
|
||||||
}
|
|
||||||
return said
|
|
||||||
}
|
|
||||||
|
|
||||||
// endingSupersededChecks ends them beside the announcement, so a kill waiting on its machine never holds the
|
|
||||||
// handler: on its own connection to the bus, within twice a kill's wait. A one-shot process (oneShot) ends them
|
|
||||||
// within the call instead, as its exit would cut them off.
|
|
||||||
func endingSupersededChecks(ctx context.Context, seat string, p link.PullUpdated, keep string, oneShot bool) {
|
|
||||||
end := func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*killAnswer)
|
|
||||||
defer cancel()
|
|
||||||
js, err := dialTheBus()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s/%s#%d (%.8s): could not reach the bus to end checks of its earlier heads: %v\n",
|
|
||||||
p.Owner, p.Repo, p.Number, p.Commit, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer js.Close()
|
|
||||||
for _, line := range endSupersededChecks(ctx, js, seat, p, keep) {
|
|
||||||
fmt.Println(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if oneShot {
|
|
||||||
end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go end()
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A new push to a pull request ends the check of its old head (novox/hq issue 726): the waiting one is dropped from
|
|
||||||
// the queue, the one in flight is killed on its machine, and each is said. A try, a walk's build, another pull
|
|
||||||
// request's check, a group's check and a recheck of the new head are left alone. On the bus alone: no store.
|
|
||||||
func TestANewHeadEndsTheChecksOfTheOldOnes(t *testing.T) {
|
|
||||||
js, _ := aBuildQueue(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
const (
|
|
||||||
oldHead = "1111111111111111111111111111111111111111"
|
|
||||||
olderHead = "2222222222222222222222222222222222222222"
|
|
||||||
newHead = "3333333333333333333333333333333333333333"
|
|
||||||
)
|
|
||||||
publish := func(r link.BuildRequest) {
|
|
||||||
t.Helper()
|
|
||||||
body, _ := json.Marshal(r)
|
|
||||||
if _, err := js.Context().Publish(link.BuildWork(), body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check := func(id string, number int, head string) link.BuildRequest {
|
|
||||||
return link.BuildRequest{ID: id, Repository: "https://forge.example/novox/a.git", Ref: head,
|
|
||||||
Check: &link.CheckRequest{Owner: "novox", Repo: "a", Number: number}}
|
|
||||||
}
|
|
||||||
// In flight first: taken by a holder, started on ace.
|
|
||||||
publish(check("check-running", 7, olderHead))
|
|
||||||
worker, _ := broker.HolderConsumerFor("", "", broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"}})
|
|
||||||
sub, err := js.Context().PullSubscribe(worker.Filters[0], worker.Name, nats.Bind(worker.Stream, worker.Name), nats.ManualAck())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer func() { _ = sub.Unsubscribe() }()
|
|
||||||
if _, err := sub.Fetch(1, nats.MaxWait(3*time.Second)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
started, _ := json.Marshal(link.BuildStart{ID: "check-running", On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)})
|
|
||||||
if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
publish(check("check-old", 7, oldHead))
|
|
||||||
publish(check("check-other-pull", 8, oldHead))
|
|
||||||
publish(check("check-recheck", 7, newHead))
|
|
||||||
publish(check("check-new", 7, newHead))
|
|
||||||
try := check("try-old", 7, oldHead)
|
|
||||||
try.Check.Number, try.Check.Try = 0, true
|
|
||||||
publish(try)
|
|
||||||
group := check("group-old", 7, oldHead)
|
|
||||||
group.Check.Group = "g1"
|
|
||||||
publish(group)
|
|
||||||
publish(link.BuildRequest{ID: "walk-build", Repository: "https://forge.example/novox/a.git", Ref: oldHead})
|
|
||||||
|
|
||||||
var mu sync.Mutex
|
|
||||||
var killed []string
|
|
||||||
stop, err := link.OverNATS{Conn: js.Conn()}.ServeNodeSeatTools(link.TheBuildMachine, "ace", map[string]link.ToolHandler{
|
|
||||||
"kill": func(_ context.Context, raw json.RawMessage) (any, error) {
|
|
||||||
var args struct{ ID string }
|
|
||||||
_ = json.Unmarshal(raw, &args)
|
|
||||||
mu.Lock()
|
|
||||||
killed = append(killed, args.ID)
|
|
||||||
mu.Unlock()
|
|
||||||
return map[string]any{"said": "killed " + args.ID}, nil
|
|
||||||
}}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
forgeSays(t, newHead, nil)
|
|
||||||
said := endSupersededChecks(ctx, js, link.TheBuildMachine,
|
|
||||||
link.PullUpdated{Owner: "novox", Repo: "a", Number: 7, Commit: newHead}, "check-new")
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
if len(killed) != 1 || killed[0] != "check-running" {
|
|
||||||
t.Errorf("the holder was asked to kill %v, want only the in-flight check of the older head", killed)
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, found := q.Find("check-old"); found {
|
|
||||||
t.Error("the waiting check of the old head is still in the queue")
|
|
||||||
}
|
|
||||||
for _, id := range []string{"check-other-pull", "check-recheck", "check-new", "try-old", "group-old", "walk-build"} {
|
|
||||||
if _, found := q.Find(id); !found {
|
|
||||||
t.Errorf("%s was ended; only checks of the same pull request at another head end", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
text := strings.Join(said, "\n")
|
|
||||||
for _, want := range []string{
|
|
||||||
"ended: check of novox/a#7 at " + oldHead[:12] + " — superseded by " + newHead[:12],
|
|
||||||
"ended: check of novox/a#7 at " + olderHead[:12] + " — superseded by " + newHead[:12],
|
|
||||||
} {
|
|
||||||
if !strings.Contains(text, want) {
|
|
||||||
t.Errorf("the log does not say %q:\n%s", want, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(said) != 2 {
|
|
||||||
t.Errorf("said %d lines, want 2:\n%s", len(said), text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which asks a check supersedes, read from the queue alone.
|
|
||||||
func TestSupersededChecksAreOnlyThePullRequestsOtherHeads(t *testing.T) {
|
|
||||||
ask := func(id, state, ref string, c *link.CheckRequest) link.QueuedAsk {
|
|
||||||
return link.QueuedAsk{ID: id, State: state, Ref: ref, Request: link.BuildRequest{ID: id, Ref: ref, Check: c}}
|
|
||||||
}
|
|
||||||
pr := func(n int) *link.CheckRequest { return &link.CheckRequest{Owner: "Novox", Repo: "A", Number: n} }
|
|
||||||
head := "abcdefabcdefabcdef0123456789abcdef012345"
|
|
||||||
q := link.Queue{Asks: []link.QueuedAsk{
|
|
||||||
ask("old", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", pr(7)),
|
|
||||||
ask("short-same", link.AskWaiting, head[:12], pr(7)),
|
|
||||||
ask("dead", link.AskDead, "0123456789ab0123456789ab0123456789ab0123", pr(7)),
|
|
||||||
ask("other", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", pr(8)),
|
|
||||||
ask("build", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", nil),
|
|
||||||
ask("keep", link.AskWaiting, "0123456789ab0123456789ab0123456789ab0123", pr(7)),
|
|
||||||
}}
|
|
||||||
got := supersededChecks(q, "novox", "a", 7, head, "keep")
|
|
||||||
if len(got) != 1 || got[0].ID != "old" {
|
|
||||||
t.Fatalf("superseded %+v, want only old", got)
|
|
||||||
}
|
|
||||||
if got := supersededChecks(q, "novox", "a", 0, head, ""); got != nil {
|
|
||||||
t.Errorf("a check of no pull request superseded %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// forgeSays makes the forge name head as every pull request's current head, or fail with err.
|
|
||||||
func forgeSays(t *testing.T, head string, err error) {
|
|
||||||
t.Helper()
|
|
||||||
was := forgePullHead
|
|
||||||
forgePullHead = func(context.Context, *broker.JetStream, string, string, int) (string, error) { return head, err }
|
|
||||||
t.Cleanup(func() { forgePullHead = was })
|
|
||||||
}
|
|
||||||
|
|
||||||
// An older head heard after a newer one — an announcement out of order, one heard again after a restart, a recheck
|
|
||||||
// of a recorded head — ends nothing: the forge says the pull request's head is the newer one. Nor does a check whose
|
|
||||||
// pull request's head the forge cannot say.
|
|
||||||
func TestAnOlderHeadHeardLateEndsNothing(t *testing.T) {
|
|
||||||
js, _ := aBuildQueue(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
const (
|
|
||||||
oldHead = "1111111111111111111111111111111111111111"
|
|
||||||
newHead = "3333333333333333333333333333333333333333"
|
|
||||||
)
|
|
||||||
for _, id := range []string{"check-new", "check-old-late"} {
|
|
||||||
head := newHead
|
|
||||||
if id == "check-old-late" {
|
|
||||||
head = oldHead
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(link.BuildRequest{ID: id, Repository: "https://forge.example/novox/a.git", Ref: head,
|
|
||||||
Check: &link.CheckRequest{Owner: "novox", Repo: "a", Number: 7}})
|
|
||||||
if _, err := js.Context().Publish(link.BuildWork(), body); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
late := link.PullUpdated{Owner: "novox", Repo: "a", Number: 7, Commit: oldHead}
|
|
||||||
forgeSays(t, newHead, nil)
|
|
||||||
said := endSupersededChecks(ctx, js, link.TheBuildMachine, late, "check-old-late")
|
|
||||||
if len(said) != 1 || !strings.Contains(said[0], "nothing ended") {
|
|
||||||
t.Errorf("an older head heard late said %q", said)
|
|
||||||
}
|
|
||||||
forgeSays(t, "", errors.New("nothing serves gitea.gitea_api"))
|
|
||||||
said = endSupersededChecks(ctx, js, link.TheBuildMachine,
|
|
||||||
link.PullUpdated{Owner: "novox", Repo: "a", Number: 7, Commit: newHead}, "check-new")
|
|
||||||
if len(said) != 1 || !strings.Contains(said[0], "nothing ended") {
|
|
||||||
t.Errorf("a head the forge could not confirm said %q", said)
|
|
||||||
}
|
|
||||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, id := range []string{"check-new", "check-old-late"} {
|
|
||||||
if _, found := q.Find(id); !found {
|
|
||||||
t.Errorf("%s was ended", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
snapshot "github.com/novox/mesh-controller/internal/facts"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1,
|
||||||
|
// issue 654): `module check` and the merge gate read it from the tree beside the manifest, so a change to a
|
||||||
|
// template alone is judged as a change to its module.
|
||||||
|
|
||||||
|
const templatedNotifier = `{"module":"notifier","version":"1",
|
||||||
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||||
|
"resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","template":"files/notifier.conf"}]}`
|
||||||
|
|
||||||
|
// inlineNotifier is the same module as the mesh holds it: built, its text inline.
|
||||||
|
const inlineNotifier = `{"module":"notifier","version":"1",
|
||||||
|
"resources":[{"id":"conf","type":"file","path":"/etc/notifier/notifier.conf","mode":"0644","content":"volume = 3\n"}]}`
|
||||||
|
|
||||||
|
// aTemplatedModule writes a module's directory: its manifest and its files/.
|
||||||
|
func aTemplatedModule(t *testing.T, dir, manifest string, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, "module.json")
|
||||||
|
if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, text := range files {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleCheckReadsTemplatesFromTheTreeBesideTheManifest(t *testing.T) {
|
||||||
|
good := aTemplatedModule(t, t.TempDir(), templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"})
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := moduleCheck([]string{good}, &out); err != nil || !strings.Contains(out.String(), "notifier: ok") {
|
||||||
|
t.Fatalf("a module with its text in files/ was refused: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A change to the template alone, to text the controller refuses inline, is refused the same.
|
||||||
|
bad := aTemplatedModule(t, t.TempDir(), templatedNotifier,
|
||||||
|
map[string]string{"files/notifier.conf": "volume = 3\nstore = ${dir:nowhere}\n"})
|
||||||
|
out.Reset()
|
||||||
|
if err := moduleCheck([]string{bad}, &out); err == nil || !strings.Contains(out.String(), "nowhere") {
|
||||||
|
t.Fatalf("a template whose text is refused inline passed:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlink under files/ that no template names: the build's packing refuses it, so the check does too.
|
||||||
|
linked := t.TempDir()
|
||||||
|
aTemplatedModule(t, linked, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"})
|
||||||
|
if err := os.Symlink("/etc/hostname", filepath.Join(linked, "files", "elsewhere")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
if err := moduleCheck([]string{filepath.Join(linked, "module.json")}, &out); err == nil ||
|
||||||
|
!strings.Contains(out.String(), "files/elsewhere is a symlink") {
|
||||||
|
t.Fatalf("a symlink under files/ passed the check:\n%s", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a template the tree lacks, by name.
|
||||||
|
missing := aTemplatedModule(t, t.TempDir(), templatedNotifier, nil)
|
||||||
|
out.Reset()
|
||||||
|
if err := moduleCheck([]string{missing}, &out); err == nil || !strings.Contains(out.String(), "files/notifier.conf") {
|
||||||
|
t.Fatalf("a missing template was not refused by name:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// templatedMesh is catalogueMesh with the notifier on the laptop, registered as built: its text inline.
|
||||||
|
func templatedMesh(t *testing.T) (snapshot.Facts, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
manifests := map[string]string{
|
||||||
|
"lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"],
|
||||||
|
"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`,
|
||||||
|
"notifier": inlineNotifier,
|
||||||
|
}
|
||||||
|
for name, raw := range manifests {
|
||||||
|
m, err := catalogue.ParseManifest([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
|
||||||
|
Path: "modules/" + name, BuiltFrom: "c0ffee"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := assign(ctx, open, "laptop", name); err != nil {
|
||||||
|
t.Fatalf("assign %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f, manifests
|
||||||
|
}
|
||||||
|
|
||||||
|
// aTemplatedTree is the catalogue's checkout with the notifier's text in files/.
|
||||||
|
func aTemplatedTree(t *testing.T, manifests map[string]string, text string, has bool) string {
|
||||||
|
t.Helper()
|
||||||
|
tree := aTree(t, withEdit(manifests, "notifier", ""))
|
||||||
|
files := map[string]string{}
|
||||||
|
if has {
|
||||||
|
files["files/notifier.conf"] = text
|
||||||
|
}
|
||||||
|
aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, files)
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifierTemplate = "modules/notifier/files/notifier.conf"
|
||||||
|
|
||||||
|
func TestTheMergeGateJudgesAChangeToATemplateAlone(t *testing.T) {
|
||||||
|
f, manifests := templatedMesh(t)
|
||||||
|
|
||||||
|
// The module moved to its template, the same text: nothing changes on any machine.
|
||||||
|
v := gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 3\n", true), "modules/notifier/module.json",
|
||||||
|
notifierTemplate)
|
||||||
|
if v.Verdict == "fail" || v.Modules["notifier"] != "" {
|
||||||
|
t.Fatalf("the module moved to its template with the same text reads as a change (%v):\n%s", v.Modules, v.Report())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The template alone changed: its module changed, and composes.
|
||||||
|
v = gateJudged(t, f, aTemplatedTree(t, manifests, "volume = 4\n", true), notifierTemplate)
|
||||||
|
if v.Verdict == "fail" || v.Modules["notifier"] != "changed" {
|
||||||
|
t.Fatalf("a change to the template alone is not a change to its module (%v):\n%s", v.Modules, v.Report())
|
||||||
|
}
|
||||||
|
if v.Width == nil || strings.Join(v.Width.Modules, ",") != "notifier" {
|
||||||
|
t.Fatalf("a change to the template rebuilds %+v, not its module", v.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The template alone changed to text the controller refuses: the pull request fails, naming the module.
|
||||||
|
v = gateJudged(t, f, aTemplatedTree(t, manifests, "store = ${dir:nowhere}\n", true), notifierTemplate)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "notifier") {
|
||||||
|
t.Fatalf("a template the controller refuses passed the gate:\n%s", v.Report())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlinked files/ fails: nothing is read from outside the tree.
|
||||||
|
tree := aTree(t, withEdit(manifests, "notifier", ""))
|
||||||
|
elsewhere := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(elsewhere, "notifier.conf"), []byte("volume = 3\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
aTemplatedModule(t, filepath.Join(tree, "modules", "notifier"), templatedNotifier, nil)
|
||||||
|
if err := os.Symlink(elsewhere, filepath.Join(tree, "modules", "notifier", "files")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v = gateJudged(t, f, tree, notifierTemplate)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/ is a symlink") {
|
||||||
|
t.Fatalf("a symlinked files/ passed the gate:\n%s", v.Report())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the template removed from the tree fails, naming it.
|
||||||
|
v = gateJudged(t, f, aTemplatedTree(t, manifests, "", false), notifierTemplate)
|
||||||
|
if v.Verdict != "fail" || !strings.Contains(v.Report(), "files/notifier.conf") {
|
||||||
|
t.Fatalf("a missing template passed the gate:\n%s", v.Report())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the gate composes for the module in its template form is what the mesh composes for it inline.
|
||||||
|
func TestTheMergeGateResolvesATemplatedModuleToItsInlineTwin(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := aTemplatedModule(t, dir, templatedNotifier, map[string]string{"files/notifier.conf": "volume = 3\n"})
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err := catalogue.ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, err = catalogue.WithTemplatesFrom(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resolved, err := standInBuild(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inline, err := catalogue.ParseManifest([]byte(inlineNotifier))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if builtAlike(resolved) != builtAlike(inline) {
|
||||||
|
got, _ := json.Marshal(resolved)
|
||||||
|
t.Fatalf("the templated module resolves to %s, not its inline twin", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "found.server",
|
||||||
|
"image": "registry.invalid:5000/library@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"networks": [
|
||||||
|
"predecessor_default"
|
||||||
|
],
|
||||||
|
"type": "container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.access-storage/downloads",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/storage/downloads",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.access-storage/media/series",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/storage/media/series",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.config",
|
||||||
|
"mode": "0750",
|
||||||
|
"owner": "1001:2000",
|
||||||
|
"path": "/var/lib/library/config",
|
||||||
|
"type": "directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "{\n \"accesses\": {\n \"downloads\": \"/storage/downloads\",\n \"series\": \"/storage/media/series\"\n },\n \"log\": \"warn\",\n \"places\": {\n \"config\": {\n \"owner\": \"1001:2000\",\n \"path\": \"/var/lib/library/config\"\n }\n },\n \"workers\": 4\n}\n",
|
||||||
|
"id": "library.runtime-config",
|
||||||
|
"mode": "0640",
|
||||||
|
"path": "/var/lib/library/config/runtime.json",
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.server",
|
||||||
|
"image": "registry.invalid:5000/library@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"type": "container",
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/library/config:/config",
|
||||||
|
"/storage/media/series:/series",
|
||||||
|
"/storage/downloads:/downloads:ro"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "library.access-home/media/series",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/home/media/series",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.access-storage/downloads",
|
||||||
|
"mode": "read",
|
||||||
|
"path": "/storage/downloads",
|
||||||
|
"type": "access"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.config",
|
||||||
|
"mode": "0750",
|
||||||
|
"path": "/var/lib/library/config",
|
||||||
|
"type": "directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": "{\n \"accesses\": {\n \"downloads\": \"/storage/downloads\",\n \"series\": \"/home/media/series\"\n },\n \"log\": \"warn\"\n}\n",
|
||||||
|
"id": "library.runtime-config",
|
||||||
|
"mode": "0640",
|
||||||
|
"path": "/var/lib/library/config/runtime.json",
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "library.server",
|
||||||
|
"image": "registry.invalid:5000/library@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"type": "container",
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/library/config:/config",
|
||||||
|
"/home/media/series:/series",
|
||||||
|
"/storage/downloads:/downloads:ro"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -171,7 +171,6 @@ func (tm *tierMesh) raise(machine, kind, summary string) {
|
|||||||
// The incident's tier, small: a dozen modules whose first machine is the same are sent there in one
|
// The incident's tier, small: a dozen modules whose first machine is the same are sent there in one
|
||||||
// send, judged by one gate, and the rest of their machines are sent once — two sends, not two dozen.
|
// send, judged by one gate, and the rest of their machines are sent once — two sends, not two dozen.
|
||||||
func TestATierOfManyModulesIsSentToEachMachineOnce(t *testing.T) {
|
func TestATierOfManyModulesIsSentToEachMachineOnce(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
var modules []string
|
var modules []string
|
||||||
for i := 1; i <= 12; i++ {
|
for i := 1; i <= 12; i++ {
|
||||||
@@ -224,7 +223,6 @@ func TestATierOfManyModulesIsSentToEachMachineOnce(t *testing.T) {
|
|||||||
// settling, is the node tools' — inside their settle window, the gate's bound — and no failure of the
|
// settling, is the node tools' — inside their settle window, the gate's bound — and no failure of the
|
||||||
// modules sent with them: at the bound only the node tools are put back.
|
// modules sent with them: at the bound only the node tools are put back.
|
||||||
func TestACoreBehindWhileTheNodeToolsSettleFailsNoOtherModule(t *testing.T) {
|
func TestACoreBehindWhileTheNodeToolsSettleFailsNoOtherModule(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
tm := aTierMesh(t, "app1", "app2", broker.RuntimeModule)
|
tm := aTierMesh(t, "app1", "app2", broker.RuntimeModule)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
inv := tm.open.inventory
|
inv := tm.open.inventory
|
||||||
@@ -278,7 +276,6 @@ func TestACoreBehindWhileTheNodeToolsSettleFailsNoOtherModule(t *testing.T) {
|
|||||||
// A machine-level condition about anything else holds the machine back as a whole: everything the send
|
// A machine-level condition about anything else holds the machine back as a whole: everything the send
|
||||||
// moved there fails together, once, with that reason — not one module for it.
|
// moved there fails together, once, with that reason — not one module for it.
|
||||||
func TestAMachineUnhealthyAsAWholeFailsTheWholeSendTogether(t *testing.T) {
|
func TestAMachineUnhealthyAsAWholeFailsTheWholeSendTogether(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
tm := aTierMesh(t, "app1", "app2", "app3")
|
tm := aTierMesh(t, "app1", "app2", "app3")
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -310,7 +307,6 @@ func TestAMachineUnhealthyAsAWholeFailsTheWholeSendTogether(t *testing.T) {
|
|||||||
// The machine-level conditions, sorted: one naming a module is that module's; the core behind is the
|
// The machine-level conditions, sorted: one naming a module is that module's; the core behind is the
|
||||||
// core's when the send moved it and nobody's when it did not; anything else is the machine's as a whole.
|
// core's when the send moved it and nobody's when it did not; anything else is the machine's as a whole.
|
||||||
func TestTheConditionsAboutAMachineItself(t *testing.T) {
|
func TestTheConditionsAboutAMachineItself(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
since := time.Now().Add(-time.Minute)
|
since := time.Now().Add(-time.Minute)
|
||||||
about := func(kind, id string) conditions.Condition {
|
about := func(kind, id string) conditions.Condition {
|
||||||
return conditions.Condition{Key: "machine." + id + "." + kind, Kind: kind, Raised: time.Now(),
|
return conditions.Condition{Key: "machine." + id + "." + kind, Kind: kind, Raised: time.Now(),
|
||||||
@@ -343,7 +339,6 @@ func TestTheConditionsAboutAMachineItself(t *testing.T) {
|
|||||||
// rollback that failed; and `plans retry` asks it again rather than refusing a build that was never
|
// rollback that failed; and `plans retry` asks it again rather than refusing a build that was never
|
||||||
// judged (the incident's "NOT put back: no build kept").
|
// judged (the incident's "NOT put back: no build kept").
|
||||||
func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) {
|
func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
g := aGateMesh(t)
|
g := aGateMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -399,7 +394,6 @@ func TestAModuleItsSendChangedNothingOfIsLeftAsItWasAndRetried(t *testing.T) {
|
|||||||
// them, they are registered back and marked in the judging that finds them broken, the plan goes on judging
|
// them, they are registered back and marked in the judging that finds them broken, the plan goes on judging
|
||||||
// the other module to its own pass, and only then fails.
|
// the other module to its own pass, and only then fails.
|
||||||
func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
|
func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
judgeLeavesAsTheCore(t)
|
judgeLeavesAsTheCore(t)
|
||||||
for _, order := range [][]string{{broker.RuntimeModule, "app1"}, {"app1", broker.RuntimeModule}} {
|
for _, order := range [][]string{{broker.RuntimeModule, "app1"}, {"app1", broker.RuntimeModule}} {
|
||||||
t.Run("gate kept on "+order[0], func(t *testing.T) {
|
t.Run("gate kept on "+order[0], func(t *testing.T) {
|
||||||
@@ -460,7 +454,6 @@ func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
|
|||||||
// decision 4): the leaf beside the node tools has its one reading at once, but the send is judged by the tool
|
// decision 4): the leaf beside the node tools has its one reading at once, but the send is judged by the tool
|
||||||
// runner's rule: one reading, not before the node-engine's witness window for it has ended.
|
// runner's rule: one reading, not before the node-engine's witness window for it has ended.
|
||||||
func TestASendCarryingTheToolRunnerWaitsForItsWitnessWindow(t *testing.T) {
|
func TestASendCarryingTheToolRunnerWaitsForItsWitnessWindow(t *testing.T) {
|
||||||
withTheGate(t) // the first-machine gate\'s own path (novox/hq ADR 0360 keeps it for earlier walks)
|
|
||||||
tm := aTierMesh(t, broker.RuntimeModule, "app1")
|
tm := aTierMesh(t, broker.RuntimeModule, "app1")
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
// One reading counted per advance, as every test with a gate left open does: a gate judged with no time
|
// One reading counted per advance, as every test with a gate left open does: a gate judged with no time
|
||||||
|
|||||||
@@ -23,12 +23,7 @@ import (
|
|||||||
// **It holds the stores, not a copy of the decision.** What to do about an upgrade is read when
|
// **It holds the stores, not a copy of the decision.** What to do about an upgrade is read when
|
||||||
// one arrives, so changing it takes effect on the next upgrade rather than on the next restart of
|
// one arrives, so changing it takes effect on the next upgrade rather than on the next restart of
|
||||||
// the control plane.
|
// the control plane.
|
||||||
type following struct {
|
type following struct{ open *stores }
|
||||||
open *stores
|
|
||||||
// oneShot is a process that ends after the call (a recheck through `delivery check`): what it would do beside
|
|
||||||
// the call it does within it, or the process's exit would cut it off (novox/hq issue 726).
|
|
||||||
oneShot bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgraded sends the machines running a module the version the catalogue now considers current —
|
// Upgraded sends the machines running a module the version the catalogue now considers current —
|
||||||
// or records that they are behind, which is the default and needs no record.
|
// or records that they are behind, which is the default and needs no record.
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/conditions"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module may declare its unhealthy urgent (novox/hq ADR 0355): its module-unhealthy condition is then raised
|
|
||||||
// urgent; without the field it stays a warning; a wait for a new login stays a warning whatever the module says.
|
|
||||||
|
|
||||||
// judgedTwice judges one machine's statement twice, over a catalogue in which mail declares its unhealthy
|
|
||||||
// urgent and notes says nothing, and returns what is open.
|
|
||||||
func judgedTwice(t *testing.T, unhealthy map[string][]inventory.ResourceHealth) []conditions.Condition {
|
|
||||||
t.Helper()
|
|
||||||
ctx := t.Context()
|
|
||||||
k, _ := withConditionsInMemory(t)
|
|
||||||
was := readHoldingFor
|
|
||||||
t.Cleanup(func() { readHoldingFor = was })
|
|
||||||
readHoldingFor = func(_ context.Context, _ *inventory.Inventory, open []conditions.Condition) (*holding, error) {
|
|
||||||
return &holding{ctx: context.Background(), open: open, providers: map[string]providerLookup{},
|
|
||||||
shelf: map[string]catalogue.Manifest{
|
|
||||||
"mail": {Module: "mail", Version: "1", Unhealthy: &catalogue.UnhealthyPolicy{
|
|
||||||
Severity: catalogue.UnhealthyUrgent, Why: "mail is refused or stuck for everyone with a mailbox"}},
|
|
||||||
"notes": {Module: "notes", Version: "1"},
|
|
||||||
}}, nil
|
|
||||||
}
|
|
||||||
streaks := map[string]int{}
|
|
||||||
for look := 1; look <= 2; look++ {
|
|
||||||
for m := range unhealthy {
|
|
||||||
streaks[m] = look
|
|
||||||
}
|
|
||||||
if err := judgeModuleHealth(ctx, nil, k, "anchor", unhealthy, streaks, time.Now()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
open, err := k.Open(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return open
|
|
||||||
}
|
|
||||||
|
|
||||||
func containerDown(module string) inventory.ResourceHealth {
|
|
||||||
return inventory.ResourceHealth{Module: module, Resource: module + ".web", Kind: "container", Target: module,
|
|
||||||
State: link.StateUnhealthy, Reason: "down"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAModuleThatDeclaresItsUnhealthyUrgentIsRaisedUrgent(t *testing.T) {
|
|
||||||
open := judgedTwice(t, map[string][]inventory.ResourceHealth{"mail": {containerDown("mail")}})
|
|
||||||
c := conditionOf(open, "module.mail.anchor.unhealthy")
|
|
||||||
if c == nil {
|
|
||||||
t.Fatalf("mail's unhealthy condition was not raised: %+v", open)
|
|
||||||
}
|
|
||||||
if c.Severity != conditions.Urgent {
|
|
||||||
t.Errorf("mail declares its unhealthy urgent, and it was raised %s", c.Severity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAModuleThatSaysNothingOfItsUnhealthyStaysAWarning(t *testing.T) {
|
|
||||||
open := judgedTwice(t, map[string][]inventory.ResourceHealth{"notes": {containerDown("notes")}})
|
|
||||||
c := conditionOf(open, "module.notes.anchor.unhealthy")
|
|
||||||
if c == nil {
|
|
||||||
t.Fatalf("notes' unhealthy condition was not raised: %+v", open)
|
|
||||||
}
|
|
||||||
if c.Severity != conditions.Warning {
|
|
||||||
t.Errorf("notes says nothing of its unhealthy, and it was raised %s", c.Severity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the module-unhealthy condition changes (ADR 0355 §4): a wait for a new login (ADR 0254) of a module that
|
|
||||||
// declares its unhealthy urgent stays a warning.
|
|
||||||
func TestAWaitForANewLoginStaysAWarningWhenTheModuleDeclaresItsUnhealthyUrgent(t *testing.T) {
|
|
||||||
open := judgedTwice(t, map[string][]inventory.ResourceHealth{"mail": {relogin("mail", "operator"),
|
|
||||||
userUnit("mail", "operator")}})
|
|
||||||
c := conditionOf(open, "module.mail.anchor.relogin-needed")
|
|
||||||
if c == nil {
|
|
||||||
t.Fatalf("mail's relogin-needed condition was not raised: %+v", open)
|
|
||||||
}
|
|
||||||
if c.Severity != conditions.Warning {
|
|
||||||
t.Errorf("a wait for a new login was raised %s; want a warning", c.Severity)
|
|
||||||
}
|
|
||||||
if conditionOf(open, "module.mail.anchor.unhealthy") != nil {
|
|
||||||
t.Errorf("a wait for a new login raised mail's unhealthy condition beside it: %+v", open)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -92,8 +92,6 @@ type signalFacts struct {
|
|||||||
// consumers: the condition counts the messages given up on, not the looks (novox/hq issue 440).
|
// consumers: the condition counts the messages given up on, not the looks (novox/hq issue 440).
|
||||||
deadLettersNewest map[string]time.Time
|
deadLettersNewest map[string]time.Time
|
||||||
advisoriesErr error
|
advisoriesErr error
|
||||||
// busFaultsErr is why the bus module's events could not be read this tick (novox/hq ADR 0357).
|
|
||||||
busFaultsErr error
|
|
||||||
|
|
||||||
selfCheck selfCheckFacts
|
selfCheck selfCheckFacts
|
||||||
|
|
||||||
@@ -360,15 +358,6 @@ func (w *watchdogs) gather(ctx context.Context) *signalFacts {
|
|||||||
}
|
}
|
||||||
f.advisories = link.Advisories.Since(now.Add(-advisoryQuiet))
|
f.advisories = link.Advisories.Since(now.Add(-advisoryQuiet))
|
||||||
f.lostConsumers, f.advisoriesErr = w.lostConsumers(ctx, f.advisories)
|
f.lostConsumers, f.advisoriesErr = w.lostConsumers(ctx, f.advisories)
|
||||||
// And what the bus did to every other user's connection, as the bus module said it (novox/hq ADR 0357):
|
|
||||||
// read back for the same hour, and one advisory with the controller's own where both are its.
|
|
||||||
// A read that fails is its own condition, never S9 blind: the controller's own signals are still said.
|
|
||||||
if w.server != nil && w.js != nil {
|
|
||||||
var faults []link.Advisory
|
|
||||||
if faults, f.busFaultsErr = w.server.BusFaults(ctx, now.Add(-advisoryQuiet)); f.busFaultsErr == nil {
|
|
||||||
f.advisories = link.MergeAdvisories(f.advisories, faults)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if f.advisoriesErr == nil && w.js != nil {
|
if f.advisoriesErr == nil && w.js != nil {
|
||||||
f.deadLetters, f.advisoriesErr = link.HeldDeadLetters(w.js.Context())
|
f.deadLetters, f.advisoriesErr = link.HeldDeadLetters(w.js.Context())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ func TestTheBuildSeatsHolderFollowsTheControllerThatDefinesItsWorker(t *testing.
|
|||||||
if pos["build-agent"] <= pos["mesh-controller"] {
|
if pos["build-agent"] <= pos["mesh-controller"] {
|
||||||
t.Fatalf("the build machine after the controller that defines its worker: %v", tiers)
|
t.Fatalf("the build machine after the controller that defines its worker: %v", tiers)
|
||||||
}
|
}
|
||||||
// Built-by orders nothing (novox/hq ADR 0360 §3): whichever builder runs builds the proxy, beside the controller.
|
if pos["route-proxy"] <= pos["build-agent"] {
|
||||||
if pos["route-proxy"] != 0 {
|
t.Fatalf("what the build machine builds comes after it: %v", tiers)
|
||||||
t.Fatalf("what the builder builds waits for a new one: %v", tiers)
|
|
||||||
}
|
}
|
||||||
if hasCycle(tiers, edges) {
|
if hasCycle(tiers, edges) {
|
||||||
t.Fatalf("no cycle here: %v", tiers)
|
t.Fatalf("no cycle here: %v", tiers)
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **The bus module's watch speaks through the runtime that carries it** (novox/hq ADR 0357): the node's
|
|
||||||
// runtime is granted the events the bus module declares, under the module's name, while the bus's own
|
|
||||||
// account keeps the snapshot API and nothing else (ADR 0235).
|
|
||||||
func TestTheRuntimeCarryingTheBusModuleMayPublishItsEventsAndTheBussAccountMayNot(t *testing.T) {
|
|
||||||
events := []string{"bus.slow-consumer", "bus.connection-closed"}
|
|
||||||
runtime := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
|
||||||
Carries: []Declared{{Module: "nats", Emits: events}}}
|
|
||||||
p, err := PermissionsFor(runtime)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, e := range events {
|
|
||||||
if !slices.Contains(p.Publish, "mesh.mod.nats.event."+e) {
|
|
||||||
t.Errorf("the runtime carrying the bus module may not publish %s: %v", e, p.Publish)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
account := Principal{Kind: KindModule, Node: "anchor", Module: "nats", Emits: events, SnapshotsTheBus: true}
|
|
||||||
a, err := PermissionsFor(account)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, s := range a.Publish {
|
|
||||||
if s == "mesh.mod.nats.event.bus.slow-consumer" || s == "mesh.mod.nats.event.bus.connection-closed" {
|
|
||||||
t.Errorf("the bus's own account is granted %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -40,6 +40,39 @@ var (
|
|||||||
AskedBucket = BucketName(ControllerSeat, "asked")
|
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
|
||||||
|
// assignment's value names its module and node; a module's declared config is what its manifest, which is
|
||||||
|
// public, declares.
|
||||||
|
//
|
||||||
|
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
|
||||||
|
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
|
||||||
|
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4).
|
||||||
|
var (
|
||||||
|
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
|
||||||
|
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||||
|
const (
|
||||||
|
MirrorAssignments = "assignments"
|
||||||
|
MirrorDeclaredConfig = "declared-config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ControllerMirrors are the local names of the controller's record mirrors, which a module may read.
|
||||||
|
func ControllerMirrors() []string { return []string{MirrorAssignments, MirrorDeclaredConfig} }
|
||||||
|
|
||||||
|
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
|
||||||
|
const (
|
||||||
|
MirrorValueBytes = 64 << 10
|
||||||
|
MirrorBytes = 64 << 20
|
||||||
|
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
|
||||||
|
// per-message age for it, and the writer gives each marker a day.
|
||||||
|
MirrorMarkerTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||||
const AskedKeptFor = 30 * 24 * time.Hour
|
const AskedKeptFor = 30 * 24 * time.Hour
|
||||||
|
|
||||||
@@ -66,12 +99,14 @@ const (
|
|||||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||||
func IsControllerBucket(bucket string) bool {
|
func IsControllerBucket(bucket string) bool {
|
||||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket
|
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
|
||||||
|
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||||
func ControllerBuckets() []string {
|
func ControllerBuckets() []string {
|
||||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket}
|
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||||
|
AssignmentsBucket, DeclaredConfigBucket}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||||
@@ -168,6 +203,27 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err)
|
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, 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 }{
|
||||||
|
{AssignmentsBucket, "which module is assigned to which node, keyed <node>.<module> (novox/hq ADR 0289, " +
|
||||||
|
"design 54): written by the controller alone from its store when an assignment changes, read through `reads`"},
|
||||||
|
{DeclaredConfigBucket, "each registered module's config declarations as its manifest holds them, keyed by " +
|
||||||
|
"the module (novox/hq ADR 0348, design 57 F3): written by the controller alone from its store when a " +
|
||||||
|
"registration changes them, read through `reads`"},
|
||||||
|
} {
|
||||||
|
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||||
|
Bucket: m.bucket,
|
||||||
|
Description: m.why,
|
||||||
|
History: 1,
|
||||||
|
MaxValueSize: MirrorValueBytes,
|
||||||
|
MaxBytes: MirrorBytes,
|
||||||
|
Storage: jetstream.FileStorage,
|
||||||
|
LimitMarkerTTL: MirrorMarkerTTL,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("asserting bucket %s: %w", m.bucket, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>"}
|
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>",
|
||||||
|
"$KV." + AssignmentsBucket + ".>", "$KV." + DeclaredConfigBucket + ".>"}
|
||||||
for _, seat := range seatsTheControllerAsks {
|
for _, seat := range seatsTheControllerAsks {
|
||||||
if hasCancelledSet(seat) {
|
if hasCancelledSet(seat) {
|
||||||
want = append(want, "$KV."+CancelledSetName(seat)+".>")
|
want = append(want, "$KV."+CancelledSetName(seat)+".>")
|
||||||
@@ -37,6 +38,34 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A module reads a record mirror through `reads`, and never writes it** (ADR 0201 §1, design 54 §5,
|
||||||
|
// design 57 F3): granted the direct reads and watches of the bucket, and refused at composition a grant to
|
||||||
|
// write it, whose writer is the controller.
|
||||||
|
func TestAModuleReadsTheControllersMirrorsAndWritesNone(t *testing.T) {
|
||||||
|
reader := Principal{Kind: KindModule, Node: "one", Module: "mesh-config", PasswordHash: "x",
|
||||||
|
Reads: []string{"mesh-controller." + MirrorAssignments, "mesh-controller." + MirrorDeclaredConfig}}
|
||||||
|
p, err := PermissionsFor(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, bucket := range []string{AssignmentsBucket, DeclaredConfigBucket} {
|
||||||
|
for _, subject := range []string{"$JS.API.STREAM.INFO.KV_" + bucket, "$JS.API.DIRECT.GET.KV_" + bucket + ".>",
|
||||||
|
"$JS.API.CONSUMER.CREATE.KV_" + bucket + ".>"} {
|
||||||
|
if !slices.Contains(p.Publish, subject) {
|
||||||
|
t.Errorf("a module reading %s may not publish %s", bucket, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, subject := range p.Publish {
|
||||||
|
if SubjectsOverlap(subject, "$KV."+bucket+".>") {
|
||||||
|
t.Errorf("a module reading %s may write it through %s", bucket, subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := CheckWriters(reader, []string{"$KV." + bucket + ".>"}); err == nil {
|
||||||
|
t.Errorf("a module granted a write of %s would compose", bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// **A machine's node tools may say they are there, as that machine and no other** (novox/hq to-be 45
|
// **A machine's node tools may say they are there, as that machine and no other** (novox/hq to-be 45
|
||||||
// S11), and the controller may hear the bus's advisories and ask who answers — read-only, named.
|
// S11), and the controller may hear the bus's advisories and ask who answers — read-only, named.
|
||||||
func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
||||||
@@ -96,3 +125,56 @@ func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
|||||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A listed mirror is no undeclared bucket** (ADR 0356 §3, ADR 0201 §16): the controller's list of its mirrors
|
||||||
|
// stands for their declaration, so the report of state nothing declares never names one.
|
||||||
|
func TestAListedMirrorIsNotReportedAsUndeclared(t *testing.T) {
|
||||||
|
names := []string{"gone_state"}
|
||||||
|
for _, m := range ControllerMirrors() {
|
||||||
|
names = append(names, BucketName(ControllerSeat, m))
|
||||||
|
}
|
||||||
|
undeclared, err := RaiseBuckets(fakeBuckets{names: names}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(undeclared, []string{"gone_state"}) {
|
||||||
|
t.Fatalf("the report of undeclared state names %v", undeclared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The mirrors' list and the writers table agree** (ADR 0356 §3, to-be 45 §1): every listed mirror is a row of
|
||||||
|
// the table whose one writer is the controller, on its bucket alone; and every row that writes a bucket of the
|
||||||
|
// controller's named as a mirror is on the list.
|
||||||
|
func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) {
|
||||||
|
controller := Principal{Kind: KindController}
|
||||||
|
module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"}
|
||||||
|
rowed := map[string]bool{}
|
||||||
|
for _, row := range WritersTable {
|
||||||
|
if !strings.HasSuffix(row.State, " mirror") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, subject := range row.Subjects {
|
||||||
|
bucket := strings.TrimSuffix(strings.TrimPrefix(subject, "$KV."), ".>")
|
||||||
|
rowed[bucket] = true
|
||||||
|
if row.Writes == nil || !row.Writes(controller, subject) || row.Writes(module, subject) {
|
||||||
|
t.Errorf("%q is not written by the controller alone", row.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listed := map[string]bool{}
|
||||||
|
for _, m := range ControllerMirrors() {
|
||||||
|
bucket := BucketName(ControllerSeat, m)
|
||||||
|
listed[bucket] = true
|
||||||
|
if !rowed[bucket] {
|
||||||
|
t.Errorf("the mirror %s is listed and no row of the writers table writes it", bucket)
|
||||||
|
}
|
||||||
|
if !IsControllerBucket(bucket) || !slices.Contains(ControllerBuckets(), bucket) {
|
||||||
|
t.Errorf("the mirror %s is listed and not asserted as the controller's own", bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for bucket := range rowed {
|
||||||
|
if !listed[bucket] {
|
||||||
|
t.Errorf("the writers table writes the mirror %s, which the list does not name", bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,13 +128,7 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
|||||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||||
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||||
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||||
hearsAStream := false
|
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||||
for _, h := range p.Hears {
|
|
||||||
if len(h.Filters) > 0 {
|
|
||||||
hearsAStream = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
|
|
||||||
return Consumer{}, false
|
return Consumer{}, false
|
||||||
}
|
}
|
||||||
perms, err := PermissionsFor(p)
|
perms, err := PermissionsFor(p)
|
||||||
@@ -146,14 +140,11 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
|||||||
// business, which is why this is a filter and not the whole list.
|
// business, which is why this is a filter and not the whole list.
|
||||||
var filters []string
|
var filters []string
|
||||||
for _, s := range perms.Subscribe {
|
for _, s := range perms.Subscribe {
|
||||||
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
|
if strings.Contains(s, ".event.") {
|
||||||
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
|
|
||||||
filters = append(filters, s)
|
filters = append(filters, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
|
if len(filters) == 0 {
|
||||||
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
|
|
||||||
if len(filters) == 0 && !hearsAStream {
|
|
||||||
return Consumer{}, false
|
return Consumer{}, false
|
||||||
}
|
}
|
||||||
sort.Strings(filters)
|
sort.Strings(filters)
|
||||||
@@ -190,13 +181,6 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
|||||||
}
|
}
|
||||||
seen[p.Node+"/"+p.Module] = true
|
seen[p.Node+"/"+p.Module] = true
|
||||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
|
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
|
||||||
// And one on each module stream it hears (novox/hq ADR 0363 §5).
|
|
||||||
for _, h := range p.Hears {
|
|
||||||
if len(h.Filters) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, p := range users {
|
for _, p := range users {
|
||||||
if p.Kind == KindModule {
|
if p.Kind == KindModule {
|
||||||
@@ -209,8 +193,7 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
|||||||
}
|
}
|
||||||
for _, d := range p.Carries {
|
for _, d := range p.Carries {
|
||||||
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches})
|
||||||
Streams: d.Streams, Hears: d.Hears})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -486,25 +486,3 @@ func (j *JetStream) LogStreams() ([]string, error) {
|
|||||||
}
|
}
|
||||||
return out, lister.Err()
|
return out, lister.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsumerNames is every durable consumer on a stream; none for a stream that does not exist yet.
|
|
||||||
func (j *JetStream) ConsumerNames(stream string) ([]string, error) {
|
|
||||||
if _, err := j.js.StreamInfo(stream); errors.Is(err, nats.ErrStreamNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for name := range j.js.ConsumerNames(stream) {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveConsumer deletes one durable consumer (novox/hq ADR 0363 §6): one the composition no longer asks for.
|
|
||||||
func (j *JetStream) RemoveConsumer(stream, name string) error {
|
|
||||||
if err := j.js.DeleteConsumer(stream, name); err != nil && !errors.Is(err, nats.ErrConsumerNotFound) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ func TestRaisingLogsReportsWhatNothingDeclares(t *testing.T) {
|
|||||||
// **No path of the mesh deletes or purges a log or a bucket, or recreates one** (novox/hq ADR 0297 §2,
|
// **No path of the mesh deletes or purges a log or a bucket, or recreates one** (novox/hq ADR 0297 §2,
|
||||||
// ADR 0201 §15–16): no code of this repository outside its tests calls the client's stream or bucket
|
// ADR 0201 §15–16): no code of this repository outside its tests calls the client's stream or bucket
|
||||||
// delete, or purges a stream, but for the controller lease's own stream, which purges its own history
|
// delete, or purges a stream, but for the controller lease's own stream, which purges its own history
|
||||||
// below a sequence (internal/lease). A new call is a decision, not a refactor.
|
// below a sequence (internal/lease), and the controller's record mirrors, which purge the key of a record
|
||||||
|
// the store no longer holds (internal/recordmirror; novox/hq ADR 0289, design 54 §2). A new call is a decision,
|
||||||
|
// not a refactor.
|
||||||
func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
||||||
removers := map[string]bool{"DeleteStream": true, "DeleteKeyValue": true, "PurgeStream": true,
|
removers := map[string]bool{"DeleteStream": true, "DeleteKeyValue": true, "PurgeStream": true,
|
||||||
"DeleteObjectStore": true}
|
"DeleteObjectStore": true}
|
||||||
@@ -197,7 +199,9 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
name := sel.Sel.Name
|
name := sel.Sel.Name
|
||||||
if removers[name] || (name == "Purge" && !strings.Contains(filepath.ToSlash(path), "internal/lease/")) {
|
decided := strings.Contains(filepath.ToSlash(path), "internal/lease/") ||
|
||||||
|
strings.Contains(filepath.ToSlash(path), "internal/recordmirror/")
|
||||||
|
if removers[name] || (name == "Purge" && !decided) {
|
||||||
found = append(found, path+": "+name)
|
found = append(found, path+": "+name)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -61,11 +61,6 @@ type Membership struct {
|
|||||||
// over every module on the machine, so one module's code reaching another's name or kind through
|
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||||
// the runtime is the runtime's to refuse.
|
// the runtime is the runtime's to refuse.
|
||||||
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||||
// Streams are this module's own streams (novox/hq ADR 0363 §2): an event one of them carries is published
|
|
||||||
// on that stream's subject, never on Emits. Hears are the module streams it hears, each through a durable
|
|
||||||
// consumer on that stream the runtime binds beside the one on EVENTS (§8).
|
|
||||||
Streams []StreamIssued `json:"streams,omitempty"`
|
|
||||||
Hears []HeardIssued `json:"hears,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
// Served is one address a tool is answered on.
|
||||||
@@ -131,8 +126,6 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
|||||||
}
|
}
|
||||||
m.State = stateIssuedFor(d, node)
|
m.State = stateIssuedFor(d, node)
|
||||||
m.Logs = logsIssuedFor(d)
|
m.Logs = logsIssuedFor(d)
|
||||||
m.Streams = streamsIssuedFor(d)
|
|
||||||
m.Hears = HeardIssuedFor(node, d)
|
|
||||||
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||||
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||||
if !s.Kinded {
|
if !s.Kinded {
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module's own stream on the bus (novox/hq ADR 0363).
|
|
||||||
//
|
|
||||||
// A module declares a stream as it declares a log: named locally, created by the controller from the
|
|
||||||
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
|
|
||||||
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
|
|
||||||
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
|
|
||||||
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
|
|
||||||
// EVENTS. Pure; the controller's raise asserts what is composed here.
|
|
||||||
|
|
||||||
// moduleStreamPrefix starts every subject a module's own stream takes.
|
|
||||||
const moduleStreamPrefix = "mesh.stream."
|
|
||||||
|
|
||||||
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
|
|
||||||
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
|
|
||||||
func ModuleStreamSubject(module, event string) string {
|
|
||||||
return moduleStreamPrefix + module + ".event." + event
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnAModuleStream says a subject is one a module's own stream takes.
|
|
||||||
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
|
|
||||||
|
|
||||||
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
|
|
||||||
func WirePattern(local string) string {
|
|
||||||
parts := strings.Split(local, ".")
|
|
||||||
for i, p := range parts {
|
|
||||||
if p == "**" {
|
|
||||||
parts[i] = ">"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A ModuleStream is one module's declared stream as the bus holds it.
|
|
||||||
type ModuleStream struct {
|
|
||||||
Module string
|
|
||||||
// Name is its name on the bus, `MAIL`.
|
|
||||||
Name string
|
|
||||||
// Events are the events it carries, as the bus spells them (WirePattern).
|
|
||||||
Events []string
|
|
||||||
MaxAgeDays int
|
|
||||||
MaxMiB int
|
|
||||||
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
|
|
||||||
Restricted bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subjects are the subjects the stream takes.
|
|
||||||
func (s ModuleStream) Subjects() []string {
|
|
||||||
var out []string
|
|
||||||
for _, e := range s.Events {
|
|
||||||
out = append(out, ModuleStreamSubject(s.Module, e))
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
|
|
||||||
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
|
|
||||||
// answers for anything older.
|
|
||||||
func (s ModuleStream) Stream() Stream {
|
|
||||||
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
|
|
||||||
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
|
|
||||||
if s.Restricted {
|
|
||||||
why += "; heard only by the consumers its trusted config value grants"
|
|
||||||
}
|
|
||||||
return Stream{
|
|
||||||
Name: s.Name,
|
|
||||||
Subjects: s.Subjects(),
|
|
||||||
Retention: RetentionLimits,
|
|
||||||
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
|
|
||||||
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
|
|
||||||
Why: why,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
|
|
||||||
func ModuleStreams(declared []ModuleStream) []Stream {
|
|
||||||
sorted := append([]ModuleStream(nil), declared...)
|
|
||||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
|
||||||
out := make([]Stream, 0, len(sorted))
|
|
||||||
for _, s := range sorted {
|
|
||||||
out = append(out, s.Stream())
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
|
|
||||||
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
|
|
||||||
// (ADR 0363 §5). Composed only with at least one subject.
|
|
||||||
type Heard struct {
|
|
||||||
Stream string
|
|
||||||
Emitter string
|
|
||||||
Filters []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
|
|
||||||
// on EVENTS, on that stream, with the subjects it was granted as its filters.
|
|
||||||
func heardConsumer(p Principal, h Heard) Consumer {
|
|
||||||
filters := append([]string(nil), h.Filters...)
|
|
||||||
sort.Strings(filters)
|
|
||||||
return Consumer{
|
|
||||||
Name: consumerDurable(p),
|
|
||||||
Stream: h.Stream,
|
|
||||||
Filters: filters,
|
|
||||||
AckWaitSeconds: 30,
|
|
||||||
MaxDeliver: 5,
|
|
||||||
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
|
|
||||||
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
|
|
||||||
"DEAD_LETTERS until a person delivers it again or drops it",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
|
|
||||||
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
|
|
||||||
func heardGrants(p Principal, hears []Heard) []string {
|
|
||||||
var out []string
|
|
||||||
durable := consumerDurable(p)
|
|
||||||
for _, h := range hears {
|
|
||||||
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out,
|
|
||||||
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
|
|
||||||
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
|
|
||||||
"$JS.ACK."+h.Stream+"."+durable+".>")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
|
|
||||||
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
|
||||||
var out []string
|
|
||||||
for _, s := range streams {
|
|
||||||
if s.Module != module {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, s.Subjects()...)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamIssued is one of a module's own streams as its membership names it (ADR 0363 §2): the events it
|
|
||||||
// carries, as the bus spells them, and the subject an event of them is published on, `{event}` the event.
|
|
||||||
type StreamIssued struct {
|
|
||||||
Stream string `json:"stream"`
|
|
||||||
Events []string `json:"events"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeardIssued is one module stream an assignment hears, as its membership names it (ADR 0363 §8): the
|
|
||||||
// stream and the durable consumer on it the runtime binds.
|
|
||||||
type HeardIssued struct {
|
|
||||||
Stream string `json:"stream"`
|
|
||||||
Consumer string `json:"consumer"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// streamsIssuedFor is every stream of its own a module's membership names.
|
|
||||||
func streamsIssuedFor(d Declared) []StreamIssued {
|
|
||||||
var out []StreamIssued
|
|
||||||
for _, s := range d.Streams {
|
|
||||||
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
|
|
||||||
Subject: ModuleStreamSubject(d.Module, "{event}")})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
|
|
||||||
func HeardIssuedFor(node string, d Declared) []HeardIssued {
|
|
||||||
var out []HeardIssued
|
|
||||||
for _, h := range d.Hears {
|
|
||||||
if len(h.Filters) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
|
|
||||||
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
|
|
||||||
type Pruner interface {
|
|
||||||
ConsumerNames(stream string) ([]string, error)
|
|
||||||
RemoveConsumer(stream, name string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
|
|
||||||
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
|
|
||||||
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
|
|
||||||
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
|
|
||||||
keep := map[string]bool{}
|
|
||||||
for _, c := range wanted {
|
|
||||||
keep[c.Stream+"/"+c.Name] = true
|
|
||||||
}
|
|
||||||
var removed []string
|
|
||||||
for _, s := range streams {
|
|
||||||
names, err := p.ConsumerNames(s.Name)
|
|
||||||
if err != nil {
|
|
||||||
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
if keep[s.Name+"/"+n] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := p.RemoveConsumer(s.Name, n); err != nil {
|
|
||||||
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
|
|
||||||
}
|
|
||||||
removed = append(removed, s.Name+"/"+n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer
|
|
||||||
// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with
|
|
||||||
// its wider filters after a narrowing was refused would keep handing over what the grant no longer names,
|
|
||||||
// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is
|
|
||||||
// returned either way, saying what was done, so the send says it and raises it.
|
|
||||||
func EnsureHeard(r Raiser, c Consumer, restricted bool) error {
|
|
||||||
err := r.EnsureConsumer(c)
|
|
||||||
if err == nil || !restricted {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p, prunes := r.(Pruner)
|
|
||||||
if !prunes {
|
|
||||||
return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err)
|
|
||||||
}
|
|
||||||
if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil {
|
|
||||||
return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+
|
|
||||||
"again with its granted filters (novox/hq ADR 0363 §6)", err)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/testbus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// On a real bus: a module's stream is made beside EVENTS without a clash, keeps what the module publishes on
|
|
||||||
// its own subjects, a granted consumer hears only its part, a narrowed grant narrows it, and a withdrawn one
|
|
||||||
// is removed (novox/hq ADR 0363).
|
|
||||||
//
|
|
||||||
// go test ./internal/broker/ -run TestAModuleStreamOnARealBus
|
|
||||||
func TestAModuleStreamOnARealBus(t *testing.T) {
|
|
||||||
js, err := Dial(testbus.URL(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer js.Close()
|
|
||||||
for _, s := range []Stream{MeshStreams()[3], mailStream.Stream()} {
|
|
||||||
_ = js.js.DeleteStream(s.Name)
|
|
||||||
if err := js.EnsureStream(s); err != nil {
|
|
||||||
t.Fatalf("%s: %v", s.Name, err)
|
|
||||||
}
|
|
||||||
defer func(name string) { _ = js.js.DeleteStream(name) }(s.Name)
|
|
||||||
}
|
|
||||||
for _, account := range []string{"mesh", "work"} {
|
|
||||||
if _, err := js.js.Publish(ModuleStreamSubject("mesh-mail", "mail."+account+".message.added"), []byte("{}")); err != nil {
|
|
||||||
t.Fatalf("publishing %s's mail: %v", account, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if info, _ := js.js.StreamInfo(EventsStream); info.State.Msgs != 0 {
|
|
||||||
t.Fatalf("EVENTS kept %d of the module stream's events", info.State.Msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
|
||||||
c := heardConsumer(reader, mailHeard)
|
|
||||||
if err := js.EnsureConsumer(c); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 1 {
|
|
||||||
t.Fatalf("the reader granted `mesh` has %d pending, want 1", info.NumPending)
|
|
||||||
}
|
|
||||||
narrowed := c
|
|
||||||
narrowed.Filters = []string{"mesh.stream.mesh-mail.event.mail.mesh.message.changed"}
|
|
||||||
if err := EnsureHeard(js, narrowed, true); err != nil {
|
|
||||||
t.Fatalf("narrowing the grant: %v", err)
|
|
||||||
}
|
|
||||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.Config.FilterSubject != narrowed.Filters[0] || len(info.Config.FilterSubjects) != 0 {
|
|
||||||
t.Fatalf("narrowed to %q %v, want only %s", info.Config.FilterSubject, info.Config.FilterSubjects, narrowed.Filters[0])
|
|
||||||
}
|
|
||||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 0 {
|
|
||||||
t.Fatalf("after narrowing, %d of the reader's former grant are still pending", info.NumPending)
|
|
||||||
}
|
|
||||||
removed, err := PruneHeard(js, []ModuleStream{mailStream}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !slices.Equal(removed, []string{"MAIL/ace_reader"}) {
|
|
||||||
t.Fatalf("removed %v", removed)
|
|
||||||
}
|
|
||||||
if names, _ := js.ConsumerNames("MAIL"); len(names) != 0 {
|
|
||||||
t.Fatalf("still on MAIL: %v", names)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mailStream is mesh-mail's own stream as the bus holds it (novox/hq ADR 0363).
|
|
||||||
var mailStream = ModuleStream{Module: "mesh-mail", Name: "MAIL",
|
|
||||||
Events: []string{"mail.*.message.added", "mail.*.message.changed", "mail.*.message.removed"},
|
|
||||||
MaxAgeDays: 7, MaxMiB: 1024, Restricted: true}
|
|
||||||
|
|
||||||
// mailHeard is what a reader granted the account `mesh` hears of it.
|
|
||||||
var mailHeard = Heard{Stream: "MAIL", Emitter: "mesh-mail", Filters: []string{
|
|
||||||
"mesh.stream.mesh-mail.event.mail.mesh.message.added", "mesh.stream.mesh-mail.event.mail.mesh.message.changed"}}
|
|
||||||
|
|
||||||
// A module's stream is its own: subjects EVENTS does not claim, its age and size, kept by limits (ADR 0363 §1, §2).
|
|
||||||
func TestAModuleStreamTakesSubjectsEventsDoesNot(t *testing.T) {
|
|
||||||
s := mailStream.Stream()
|
|
||||||
if s.Name != "MAIL" || s.Retention != RetentionLimits || s.MaxAge != 7*24*60*60 || s.MaxBytes != 1024<<20 || s.DiscardNew {
|
|
||||||
t.Fatalf("composed as %+v", s)
|
|
||||||
}
|
|
||||||
want := []string{"mesh.stream.mesh-mail.event.mail.*.message.added", "mesh.stream.mesh-mail.event.mail.*.message.changed",
|
|
||||||
"mesh.stream.mesh-mail.event.mail.*.message.removed"}
|
|
||||||
if !reflect.DeepEqual(s.Subjects, want) {
|
|
||||||
t.Fatalf("subjects %v", s.Subjects)
|
|
||||||
}
|
|
||||||
for _, own := range MeshStreams() {
|
|
||||||
for _, a := range own.Subjects {
|
|
||||||
for _, b := range s.Subjects {
|
|
||||||
if SubjectsOverlap(a, b) {
|
|
||||||
t.Fatalf("%s claims %s, which overlaps %s of %s: the bus would refuse the stream", s.Name, b, a, own.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if WirePattern("mail.**") != "mail.>" {
|
|
||||||
t.Fatal("`**` is spelled `>` on the wire")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The provider publishes its stream's events on their subjects and nowhere on EVENTS for them; a consumer
|
|
||||||
// granted a part hears it through its own consumer on the stream, and one not granted has nothing there
|
|
||||||
// (ADR 0363 §2, §5).
|
|
||||||
func TestTheProviderPublishesItsStreamAndOnlyAGrantedConsumerHearsIt(t *testing.T) {
|
|
||||||
provider, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-mail",
|
|
||||||
Streams: []ModuleStream{mailStream}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !MayPublish(provider, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
|
||||||
t.Fatal("the provider may not publish its own stream's event")
|
|
||||||
}
|
|
||||||
if MayPublish(provider, "mesh.mod.mesh-mail.event.mail.work.message.added") {
|
|
||||||
t.Fatal("the provider may publish its stream's event on EVENTS")
|
|
||||||
}
|
|
||||||
if MayPublish(provider, "mesh.stream.other.event.mail.work.message.added") {
|
|
||||||
t.Fatal("the provider may publish on another module's stream")
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
|
||||||
perms, err := PermissionsFor(reader)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, s := range []string{"$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader", "$JS.API.CONSUMER.INFO.MAIL.ace_reader",
|
|
||||||
"$JS.ACK.MAIL.ace_reader.1"} {
|
|
||||||
if !MayPublish(perms, s) {
|
|
||||||
t.Errorf("the granted reader may not publish %s", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.mesh.message.added") ||
|
|
||||||
MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
|
||||||
t.Fatal("the reader's subscription is not what it was granted")
|
|
||||||
}
|
|
||||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_other") {
|
|
||||||
t.Fatal("the reader may pull another's consumer")
|
|
||||||
}
|
|
||||||
|
|
||||||
stranger, err := PermissionsFor(Principal{Kind: KindModule, Node: "ace", Module: "stranger",
|
|
||||||
Consumes: []string{"billing.paid"}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if MayPublish(stranger, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") ||
|
|
||||||
MaySubscribe(stranger, "mesh.stream.mesh-mail.event.mail.mesh.message.added") {
|
|
||||||
t.Fatal("a module not granted reaches the stream")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A granted consumer has a durable consumer on the stream, with its granted subjects as filters, and an EVENTS
|
|
||||||
// consumer that hears what it gives up on delivered again (ADR 0363 §5, §7); the runtime carrying it may read both.
|
|
||||||
func TestAGrantedConsumerHasAConsumerOnTheStream(t *testing.T) {
|
|
||||||
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
|
|
||||||
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}},
|
|
||||||
"ace": {{Module: RuntimeModule}, {Module: "reader", Hears: []Heard{mailHeard}, HearsAStream: true}, {Module: "stranger", HearsAStream: true}},
|
|
||||||
}}
|
|
||||||
users, err := Users(records)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var onMail, onEvents []Consumer
|
|
||||||
for _, c := range ConsumersOf(users) {
|
|
||||||
switch c.Consumer.Stream {
|
|
||||||
case "MAIL":
|
|
||||||
onMail = append(onMail, c.Consumer)
|
|
||||||
case EventsStream:
|
|
||||||
onEvents = append(onEvents, c.Consumer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(onMail) != 1 || onMail[0].Name != "ace_reader" || !slices.Equal(onMail[0].Filters, mailHeard.Filters) {
|
|
||||||
t.Fatalf("on MAIL: %+v", onMail)
|
|
||||||
}
|
|
||||||
if len(onEvents) != 1 || onEvents[0].Name != "ace_reader" || !slices.Equal(onEvents[0].Filters, []string{AgainFilter("ace_reader")}) {
|
|
||||||
t.Fatalf("on EVENTS: %+v", onEvents)
|
|
||||||
}
|
|
||||||
for _, u := range users {
|
|
||||||
if u.Kind != KindNodeTools || u.Node != "ace" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
perms, err := PermissionsFor(u)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader") || !MayPublish(perms, "$JS.ACK.MAIL.ace_reader.1") {
|
|
||||||
t.Fatal("the runtime carrying the reader may not read its consumer on MAIL")
|
|
||||||
}
|
|
||||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") {
|
|
||||||
t.Fatal("the runtime may read a consumer no module it carries is granted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The membership says where a module's stream events go and which consumers on module streams to bind
|
|
||||||
// (ADR 0363 §2, §8).
|
|
||||||
func TestTheMembershipNamesTheStreamAndWhatIsHeard(t *testing.T) {
|
|
||||||
where := Placements{Nodes: map[string][]string{}}
|
|
||||||
provider := MembershipFor("novox", Declared{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, where)
|
|
||||||
if len(provider.Streams) != 1 || provider.Streams[0].Stream != "MAIL" ||
|
|
||||||
provider.Streams[0].Subject != "mesh.stream.mesh-mail.event.{event}" ||
|
|
||||||
!slices.Equal(provider.Streams[0].Events, mailStream.Events) {
|
|
||||||
t.Fatalf("the provider's membership: %+v", provider.Streams)
|
|
||||||
}
|
|
||||||
reader := MembershipFor("ace", Declared{Module: "reader", Hears: []Heard{mailHeard}}, where)
|
|
||||||
if !reflect.DeepEqual(reader.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}) {
|
|
||||||
t.Fatalf("the reader's membership: %+v", reader.Hears)
|
|
||||||
}
|
|
||||||
if none := MembershipFor("ace", Declared{Module: "stranger", HearsAStream: true}, where); none.Hears != nil {
|
|
||||||
t.Fatalf("a module not granted is told to bind %+v", none.Hears)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakePruner struct {
|
|
||||||
on map[string][]string
|
|
||||||
removed []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakePruner) ConsumerNames(stream string) ([]string, error) { return f.on[stream], nil }
|
|
||||||
func (f *fakePruner) RemoveConsumer(stream, name string) error {
|
|
||||||
f.removed = append(f.removed, stream+"/"+name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// A consumer that lost its grant has its consumer on the stream removed by the next assertion (ADR 0363 §6).
|
|
||||||
func TestAConsumerThatLostItsGrantIsRemoved(t *testing.T) {
|
|
||||||
f := &fakePruner{on: map[string][]string{"MAIL": {"ace_reader", "ace_former"}}}
|
|
||||||
wanted := []Consumer{{Name: "ace_reader", Stream: "MAIL"}, {Name: "ace_former", Stream: EventsStream}}
|
|
||||||
removed, err := PruneHeard(f, []ModuleStream{mailStream}, wanted)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !slices.Equal(removed, []string{"MAIL/ace_former"}) || !slices.Equal(f.removed, removed) {
|
|
||||||
t.Fatalf("removed %v", removed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type refusingRaiser struct {
|
|
||||||
fakePruner
|
|
||||||
refuse bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *refusingRaiser) EnsureStream(Stream) error { return nil }
|
|
||||||
func (r *refusingRaiser) EnsureConsumer(Consumer) error {
|
|
||||||
if r.refuse {
|
|
||||||
return errors.New("consumer update refused")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// A narrowing the bus refuses on a restricted stream removes the consumer, so its wider filters deliver nothing
|
|
||||||
// more; on an unrestricted stream the refusal is only said (review of PR 338, ADR 0363 §6).
|
|
||||||
func TestARefusedNarrowingOnARestrictedStreamRemovesTheConsumer(t *testing.T) {
|
|
||||||
c := Consumer{Name: "ace_reader", Stream: "MAIL", Filters: mailHeard.Filters[:1]}
|
|
||||||
r := &refusingRaiser{refuse: true}
|
|
||||||
err := EnsureHeard(r, c, true)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "REMOVED") || !slices.Equal(r.removed, []string{"MAIL/ace_reader"}) {
|
|
||||||
t.Fatalf("restricted: %v, removed %v", err, r.removed)
|
|
||||||
}
|
|
||||||
r = &refusingRaiser{refuse: true}
|
|
||||||
if err := EnsureHeard(r, c, false); err == nil || len(r.removed) != 0 {
|
|
||||||
t.Fatalf("unrestricted: %v, removed %v", err, r.removed)
|
|
||||||
}
|
|
||||||
r = &refusingRaiser{}
|
|
||||||
if err := EnsureHeard(r, c, true); err != nil || len(r.removed) != 0 {
|
|
||||||
t.Fatalf("accepted: %v, removed %v", err, r.removed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-22
@@ -141,10 +141,6 @@ type Principal struct {
|
|||||||
KeyedReads []KeyedRead
|
KeyedReads []KeyedRead
|
||||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||||
Logs []string
|
Logs []string
|
||||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
|
||||||
// subjects it was granted (novox/hq ADR 0363).
|
|
||||||
Streams []ModuleStream
|
|
||||||
Hears []Heard
|
|
||||||
|
|
||||||
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
|
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
|
||||||
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||||
@@ -641,8 +637,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, e := range p.Emits {
|
for _, e := range p.Emits {
|
||||||
pub = append(pub, own+".event."+e)
|
pub = append(pub, own+".event."+e)
|
||||||
}
|
}
|
||||||
// And its own streams' events, on their own subjects (novox/hq ADR 0363 §2).
|
|
||||||
pub = append(pub, ownStreamGrants(p.Module, p.Streams)...)
|
|
||||||
// Every tool under its own name, not a list: the tools a module serves are what its code
|
// Every tool under its own name, not a list: the tools a module serves are what its code
|
||||||
// answers, and a second copy of that list in the manifest would be a second source of
|
// answers, and a second copy of that list in the manifest would be a second source of
|
||||||
// truth for the mesh to keep in step (2026-09-28: every module that served a tool was
|
// truth for the mesh to keep in step (2026-09-28: every module that served a tool was
|
||||||
@@ -684,16 +678,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
sub = append(sub, subject)
|
sub = append(sub, subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2a. What it hears of a module's own stream, as it was granted (novox/hq ADR 0363 §5): the subjects,
|
|
||||||
// and its own consumer on that stream, asked about, pulled from and acknowledged.
|
|
||||||
for _, h := range p.Hears {
|
|
||||||
if len(h.Filters) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sub = append(sub, h.Filters...)
|
|
||||||
}
|
|
||||||
pub = append(pub, heardGrants(p, p.Hears)...)
|
|
||||||
|
|
||||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||||
// role is hearing what it announced, not taking part in it.
|
// role is hearing what it announced, not taking part in it.
|
||||||
for _, w := range p.Watches {
|
for _, w := range p.Watches {
|
||||||
@@ -816,8 +800,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
for _, e := range d.Emits {
|
for _, e := range d.Emits {
|
||||||
pub = append(pub, own+".event."+e)
|
pub = append(pub, own+".event."+e)
|
||||||
}
|
}
|
||||||
// And its own streams' events (novox/hq ADR 0363 §2).
|
|
||||||
pub = append(pub, ownStreamGrants(d.Module, d.Streams)...)
|
|
||||||
for _, s := range d.Holds {
|
for _, s := range d.Holds {
|
||||||
if servedOnlyByTheController(s) {
|
if servedOnlyByTheController(s) {
|
||||||
continue
|
continue
|
||||||
@@ -856,10 +838,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// consumer is still the controller's to make, from the module's own principal.
|
// consumer is still the controller's to make, from the module's own principal.
|
||||||
for _, d := range p.Carries {
|
for _, d := range p.Carries {
|
||||||
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
|
||||||
Streams: d.Streams, Hears: d.Hears}
|
|
||||||
// And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8).
|
|
||||||
pub = append(pub, heardGrants(own, d.Hears)...)
|
|
||||||
if _, consumes := ConsumerFor(own); !consumes {
|
if _, consumes := ConsumerFor(own); !consumes {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
|||||||
states = append(states, link.KeyChecked)
|
states = append(states, link.KeyChecked)
|
||||||
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||||
states = append(states, link.KeyPlanMoved)
|
states = append(states, link.KeyPlanMoved)
|
||||||
|
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||||
|
states = append(states, link.KeyAssignmentChanged, link.KeyDeclaredConfigChanged)
|
||||||
for _, event := range states {
|
for _, event := range states {
|
||||||
if !slices.Contains(broker.ControllerStates, event) {
|
if !slices.Contains(broker.ControllerStates, event) {
|
||||||
t.Errorf("the mesh states %q and its account may not publish it", event)
|
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||||
@@ -42,6 +44,11 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
|||||||
if len(broker.ControllerStates) != len(states) {
|
if len(broker.ControllerStates) != len(states) {
|
||||||
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
||||||
}
|
}
|
||||||
|
// The record mirrors a module may read are the ones the bus holds (design 54 §5, design 57 F3).
|
||||||
|
if !slices.Equal(catalogue.ControllerMirrors, broker.ControllerMirrors()) {
|
||||||
|
t.Errorf("the catalogue lets a module read the controller's mirrors %v and the bus holds %v",
|
||||||
|
catalogue.ControllerMirrors, broker.ControllerMirrors())
|
||||||
|
}
|
||||||
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
||||||
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
||||||
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The catalogue's list of the mesh's own stream names, which a module's stream may not take, is the broker's
|
|
||||||
// own list (novox/hq ADR 0363 §1): read from MeshStreams, so a stream added there cannot be forgotten there.
|
|
||||||
func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) {
|
|
||||||
var own []string
|
|
||||||
for _, s := range MeshStreams() {
|
|
||||||
own = append(own, s.Name)
|
|
||||||
}
|
|
||||||
reserved := append([]string(nil), catalogue.MeshStreamNames...)
|
|
||||||
slices.Sort(own)
|
|
||||||
slices.Sort(reserved)
|
|
||||||
if !slices.Equal(own, reserved) {
|
|
||||||
t.Fatalf("the broker's own streams are %v, the catalogue reserves %v", own, reserved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -313,7 +313,10 @@ var ControllerStates = []string{"applied", "refused", "built-before",
|
|||||||
// the pull request's status, and anybody else may read.
|
// the pull request's status, and anybody else may read.
|
||||||
"checked",
|
"checked",
|
||||||
// And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from.
|
// And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from.
|
||||||
"plan-moved"}
|
"plan-moved",
|
||||||
|
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and
|
||||||
|
// each registered module's declared config (design 57 F3).
|
||||||
|
"assignment-changed", "declared-config-changed"}
|
||||||
|
|
||||||
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
||||||
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$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_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$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.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.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "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.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$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_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$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.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "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.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||||
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"] }
|
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" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
|
|||||||
@@ -40,13 +40,6 @@ type Declared struct {
|
|||||||
State []Bucket
|
State []Bucket
|
||||||
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
||||||
Logs []Log
|
Logs []Log
|
||||||
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
|
|
||||||
Streams []ModuleStream
|
|
||||||
// Hears are the module streams it hears on this machine, each with the subjects it was granted
|
|
||||||
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
|
|
||||||
// or not, so its membership is issued again when a grant changes.
|
|
||||||
Hears []Heard
|
|
||||||
HearsAStream bool
|
|
||||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||||
Reads []string
|
Reads []string
|
||||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||||
@@ -136,7 +129,7 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||||
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: d.Hears,
|
Logs: logNames(d.Logs),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if runtimeHere {
|
if runtimeHere {
|
||||||
|
|||||||
@@ -158,6 +158,15 @@ var WritersTable = []WriterRow{
|
|||||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
||||||
|
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||||
|
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||||
|
// module's declared config (design 57 F3).
|
||||||
|
{State: "the assignments mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||||
|
KeptIn: "key-value " + AssignmentsBucket, Others: "read through `reads` as mesh-controller." + MirrorAssignments,
|
||||||
|
Subjects: kvOf(AssignmentsBucket), Writes: isController},
|
||||||
|
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||||
|
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
|
||||||
|
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ var designRows = []string{
|
|||||||
"the operator-channel's open messages",
|
"the operator-channel's open messages",
|
||||||
"the facts snapshot",
|
"the facts snapshot",
|
||||||
"a message a consumer gave up on",
|
"a message a consumer gave up on",
|
||||||
|
// The record mirrors, rows of to-be 45 §1's table by design 54 §2 (ADR 0289 §9).
|
||||||
|
"the assignments mirror",
|
||||||
|
"the declared-config mirror",
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheWritersTableIsTheDesigns(t *testing.T) {
|
func TestTheWritersTableIsTheDesigns(t *testing.T) {
|
||||||
|
|||||||
+65
-54
@@ -108,6 +108,17 @@ type Result struct {
|
|||||||
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
// recipe and the package it compiles; for any other, nothing is said and the whole of what the build
|
||||||
// sees stays its source, as before.
|
// sees stays its source, as before.
|
||||||
Sources []BuildSource
|
Sources []BuildSource
|
||||||
|
|
||||||
|
// Predefined is the list of predefined keys at the root of the repository at the commit built (novox/hq ADR
|
||||||
|
// 0348 §7), which the manifest was judged against; nil for a repository that holds none.
|
||||||
|
Predefined *Predefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined is the list of predefined keys a build read: its text, canonical (catalogue.PredefinedList), and
|
||||||
|
// the generation of the commit built — how many commits it stands on, which orders two builds of one trunk.
|
||||||
|
type Predefined struct {
|
||||||
|
Keys []byte
|
||||||
|
Generation int
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
// BuildSource is the build source a build read in one repository (novox/hq ADR 0267).
|
||||||
@@ -162,10 +173,6 @@ type Reuse struct {
|
|||||||
// keyFirst is a check's build (§1): its key is taken before anything is built, and a module with none is not
|
// keyFirst is a check's build (§1): its key is taken before anything is built, and a module with none is not
|
||||||
// built at all — nothing could ever take it.
|
// built at all — nothing could ever take it.
|
||||||
keyFirst bool
|
keyFirst bool
|
||||||
// pull and head are the check's own pull request and the head it judged (novox/hq issue 703): it keeps only a
|
|
||||||
// build an earlier check of the same pull request at the same head made.
|
|
||||||
pull PullRef
|
|
||||||
head string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// errNoKey is a check's build of a module whose source does not pin it: not built (ADR 0350 §1).
|
// errNoKey is a check's build of a module whose source does not pin it: not built (ADR 0350 §1).
|
||||||
@@ -265,14 +272,38 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
"%s has no %s at %s, so there is nothing saying what it is: %w",
|
"%s has no %s at %s, so there is nothing saying what it is: %w",
|
||||||
repository, ManifestName, describe(path), err)
|
repository, ManifestName, describe(path), err)
|
||||||
}
|
}
|
||||||
manifest, err := catalogue.ParseManifest(raw)
|
// **The list of predefined keys at the commit built** (novox/hq ADR 0348 §7): predefined.json at the
|
||||||
|
// repository's root, read strictly; a list that is wrong fails the build, and the manifest is judged against
|
||||||
|
// the list of its own tree, never against one the mesh published from another commit.
|
||||||
|
predefined, err := catalogue.ReadPredefined(tree)
|
||||||
|
if err != nil {
|
||||||
|
say("predefined", "INVALID: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
manifest, err := catalogue.ParseManifestWith(raw, predefined)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("manifest", "INVALID: %v", err)
|
say("manifest", "INVALID: %v", err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
var listed *Predefined
|
||||||
|
if predefined != nil {
|
||||||
|
text, err := predefined.Canonical()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
listed = &Predefined{Keys: text, Generation: generationOf(ctx, run, tree, commit)}
|
||||||
|
say("predefined", "%d key(s) at %s, generation %d", len(predefined), short(commit), listed.Generation)
|
||||||
|
}
|
||||||
// What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is
|
// What is judged only where a definition is written (novox/hq ADR 0326 §3, issue 574): a build is
|
||||||
// where a changed definition is first read, so it is refused here before anything is made of it.
|
// where a changed definition is first read, so it is refused here before anything is made of it.
|
||||||
if problems := catalogue.WrittenSettingsProblems(manifest); len(problems) > 0 {
|
// Judged with each template's text in its file, as written in the tree (novox/hq ADR 0349 §2): a template the
|
||||||
|
// module's directory lacks is refused here, before anything is built.
|
||||||
|
judged, err := catalogue.WithTemplatesFrom(manifest, within)
|
||||||
|
if err != nil {
|
||||||
|
say("manifest", "INVALID: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
if problems := catalogue.WrittenSettingsProblems(judged); len(problems) > 0 {
|
||||||
err := fmt.Errorf("%s", strings.Join(problems, "; "))
|
err := fmt.Errorf("%s", strings.Join(problems, "; "))
|
||||||
say("manifest", "INVALID: %v", err)
|
say("manifest", "INVALID: %v", err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -288,6 +319,11 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
|
||||||
src.notPinned("its tree could not be named: " + err.Error())
|
src.notPinned("its tree could not be named: " + err.Error())
|
||||||
}
|
}
|
||||||
|
// A module falling back to a predefined key was judged against the list, so a change to the list is a change
|
||||||
|
// to it: the planner rebuilds it, and its build publishes the list (ADR 0348 §7).
|
||||||
|
if predefined != nil && manifest.NamesPredefined() {
|
||||||
|
src.rootHas(catalogue.PredefinedFile)
|
||||||
|
}
|
||||||
|
|
||||||
// A build-time credential, written into the build context as .npmrc, but ONLY for a module that
|
// A build-time credential, written into the build context as .npmrc, but ONLY for a module that
|
||||||
// asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc.
|
// asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc.
|
||||||
@@ -378,7 +414,7 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
// only when asked — a check, a dry run, `build --ref` and `rebuild` never are. Its key is taken by the same
|
// only when asked — a check, a dry run, `build --ref` and `rebuild` never are. Its key is taken by the same
|
||||||
// steps a build takes, stopped before anything is built; what it takes is copied by digest.
|
// steps a build takes, stopped before anything is built; what it takes is copied by digest.
|
||||||
if reuse != nil && reuse.keyFirst {
|
if reuse != nil && reuse.keyFirst {
|
||||||
keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
|
_, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
|
||||||
held, npmrcPath, npmrc, seatBases, src, say)
|
held, npmrcPath, npmrc, seatBases, src, say)
|
||||||
if err == nil && key.Key == "" {
|
if err == nil && key.Key == "" {
|
||||||
err = fmt.Errorf("%w: %s", errNoKey, key.Why)
|
err = fmt.Errorf("%w: %s", errNoKey, key.Why)
|
||||||
@@ -387,11 +423,6 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
say("reuse", "not built for the merge: %v", err)
|
say("reuse", "not built for the merge: %v", err)
|
||||||
return Result{Commit: commit, NoKey: err.Error()}, err
|
return Result{Commit: commit, NoKey: err.Error()}, err
|
||||||
}
|
}
|
||||||
// **A check keeps a build of the same key a verdict names** (novox/hq issue 703): asked again when only
|
|
||||||
// the trunk moved, it builds nothing the earlier check built of an unchanged source.
|
|
||||||
if kept := keptCheckBuild(ctx, *reuse, manifest.Module, key.Key, len(artifacts), say); kept != nil {
|
|
||||||
built, src, reused = kept.Made, keyed, kept
|
|
||||||
}
|
|
||||||
} else if reuse != nil && reuse.Scratch != nil {
|
} else if reuse != nil && reuse.Scratch != nil {
|
||||||
taken, keyed, why := takeACheckBuild(ctx, run, publish, *reuse, manifest, within, workspace, commit,
|
taken, keyed, why := takeACheckBuild(ctx, run, publish, *reuse, manifest, within, workspace, commit,
|
||||||
credentials, artifacts, args, held, npmrcPath, npmrc, seatBases, src, onTrunk, say)
|
credentials, artifacts, args, held, npmrcPath, npmrc, seatBases, src, onTrunk, say)
|
||||||
@@ -419,6 +450,11 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each template's text, read back from the archive the build made or took (novox/hq ADR 0349 §2).
|
||||||
|
if built, err = withTemplateTexts(manifest, within, built); err != nil {
|
||||||
|
say("templates", "FAILED: %v", err)
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
resolved, err := manifest.Resolve(built)
|
resolved, err := manifest.Resolve(built)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("resolve", "FAILED: %v", err)
|
say("resolve", "FAILED: %v", err)
|
||||||
@@ -448,7 +484,23 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
return Result{Manifest: resolved, Commit: commit, Built: built,
|
return Result{Manifest: resolved, Commit: commit, Built: built,
|
||||||
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
|
||||||
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources,
|
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources,
|
||||||
ReuseKey: key.Key, ReuseParts: key.Parts, NoKey: key.Why, Reused: reused, Why: builtWhy}, nil
|
ReuseKey: key.Key, ReuseParts: key.Parts, NoKey: key.Why, Reused: reused, Why: builtWhy,
|
||||||
|
Predefined: listed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generationOf is how many commits the commit built stands on, itself included: of two commits on one trunk the
|
||||||
|
// later has more, so the controller keeps the list of the newer one whatever order it hears them in (ADR 0348
|
||||||
|
// §7). 0 when git could not say.
|
||||||
|
func generationOf(ctx context.Context, run Runner, tree, commit string) int {
|
||||||
|
out, err := run(ctx, tree, "git", "rev-list", "--count", commit)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(out))
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeACheckBuild is the walk's look for a check build of its key (novox/hq ADR 0350 §2, §3): the key taken by
|
// takeACheckBuild is the walk's look for a check build of its key (novox/hq ADR 0350 §2, §3): the key taken by
|
||||||
@@ -501,47 +553,6 @@ func takeACheckBuild(ctx context.Context, run Runner, publish Publisher, reuse R
|
|||||||
return taken, keyed, ""
|
return taken, keyed, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// keptCheckBuild is the build a check keeps instead of building (novox/hq issue 703): the one kept under the
|
|
||||||
// module's key by an earlier check of the same pull request at the same head — only the trunk moved — when a verdict
|
|
||||||
// of the build seat names its key and every digest — the record alone is never trusted (ADR 0350 §2) — and it holds
|
|
||||||
// every artifact the module declares. Another pull request's build, or an earlier head's, is never kept. Nil, and
|
|
||||||
// the check builds, otherwise.
|
|
||||||
func keptCheckBuild(ctx context.Context, reuse Reuse, module, key string, artifacts int,
|
|
||||||
say func(step, format string, args ...any)) *Reused {
|
|
||||||
if reuse.Scratch == nil || reuse.head == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var same []NamedBuild
|
|
||||||
for _, n := range reuse.Named {
|
|
||||||
if n.Pull == reuse.pull {
|
|
||||||
same = append(same, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(same) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
found, ok, err := reuse.Scratch.Find(ctx, module, key)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
say("reuse", "the scratch namespace could not be read, so the check builds: %v", err)
|
|
||||||
return nil
|
|
||||||
case !ok:
|
|
||||||
return nil
|
|
||||||
case found.Pull != reuse.pull || found.Head == "" || twelve(found.Head) != twelve(reuse.head):
|
|
||||||
say("reuse", "the build kept under %s is %s, not this pull request's at this head: built", twelve(key[3:]),
|
|
||||||
found.Said())
|
|
||||||
return nil
|
|
||||||
case unnamed(same, module, key, found) != "":
|
|
||||||
say("reuse", "a build is kept under %s and no verdict names it with its digests: built", twelve(key[3:]))
|
|
||||||
return nil
|
|
||||||
case len(found.Made) != artifacts:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
kept := &Reused{From: found, Made: found.Made}
|
|
||||||
say("reuse", "kept, not built: %s", strings.Replace(kept.Words(), "reused from", "the build of", 1))
|
|
||||||
return kept
|
|
||||||
}
|
|
||||||
|
|
||||||
// keyFor takes a build's reuse key by the steps a build takes, each artifact stopped before anything is built or
|
// keyFor takes a build's reuse key by the steps a build takes, each artifact stopped before anything is built or
|
||||||
// published (ADR 0350 §2): its own copy of the source inputs, so the build's are untouched.
|
// published (ADR 0350 §2): its own copy of the source inputs, so the build's are untouched.
|
||||||
func keyFor(ctx context.Context, run Runner, publish Publisher, manifest catalogue.Manifest,
|
func keyFor(ctx context.Context, run Runner, publish Publisher, manifest catalogue.Manifest,
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ func (r *recorded) run(_ context.Context, dir, name string, args ...string) (str
|
|||||||
return r.tree + "\n", nil
|
return r.tree + "\n", nil
|
||||||
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
case name == "git" && len(args) > 0 && args[0] == "rev-parse":
|
||||||
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil
|
||||||
|
case name == "git" && len(args) > 1 && args[0] == "rev-list" && args[1] == "--count":
|
||||||
|
return "42\n", nil
|
||||||
}
|
}
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-616
@@ -13,9 +13,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/artifacts"
|
"github.com/novox/mesh-controller/internal/artifacts"
|
||||||
@@ -89,19 +87,6 @@ type CheckSpec struct {
|
|||||||
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
|
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
|
||||||
// the running one with the change's validator.
|
// the running one with the change's validator.
|
||||||
Judge string
|
Judge string
|
||||||
// Release is the release branch the pull request merges into (novox/hq ADR 0358), empty for any other base. The
|
|
||||||
// gate is then judged by the controller beside it as mesh-controller — mesh-controller's release branch, never
|
|
||||||
// the controller the mesh runs nor its main — with mesh-host's validator from beside it when mesh-host was read at
|
|
||||||
// the release branch too; a change to the controller still judges itself and a change to the node-engine brings
|
|
||||||
// its own validator. The verdict names the branch, the judge's commit and the branch each beside was read at.
|
|
||||||
Release string
|
|
||||||
// JudgeCache is the judge cache (novox/hq ADR 0358 §6): a directory of the build agent's own, outside the
|
|
||||||
// workspace, that only judge builds mount — never a check's containers, so no pull request's script can write it.
|
|
||||||
// It holds the Go build cache every judge is built with (judges stay warm), the release-branch judges kept by the
|
|
||||||
// commits they are built from (judges/, one per release-branch head), and each judge build's fresh home (homes/,
|
|
||||||
// removed after the build). Empty: each judge is built cold under the check's own root, which every check empties
|
|
||||||
// before it clones, and nothing is kept.
|
|
||||||
JudgeCache string
|
|
||||||
// Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it.
|
// Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it.
|
||||||
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
|
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
|
||||||
Toolchain string
|
Toolchain string
|
||||||
@@ -168,12 +153,10 @@ func ToolchainsOf(held map[string]string) map[string]string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beside is one repository cloned next to the one checked: at Ref, or at Else when Else is said and the repository
|
// Beside is one repository cloned next to the one checked.
|
||||||
// has no branch Ref (novox/hq ADR 0358 §3).
|
|
||||||
type Beside struct {
|
type Beside struct {
|
||||||
Repository string
|
Repository string
|
||||||
Ref string
|
Ref string
|
||||||
Else string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckVerdict is what came of one check. Verdict and Summary are the gate's; Gate and Repo each layer.
|
// CheckVerdict is what came of one check. Verdict and Summary are the gate's; Gate and Repo each layer.
|
||||||
@@ -196,16 +179,6 @@ type CheckVerdict struct {
|
|||||||
Members []JudgedHead
|
Members []JudgedHead
|
||||||
// Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not.
|
// Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not.
|
||||||
Built []CheckBuilt
|
Built []CheckBuilt
|
||||||
// Release is the release branch it was judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the controller
|
|
||||||
// that judged its gate (twelve characters), and BesideAt the branch each repository beside it was read at, by
|
|
||||||
// directory. Empty for a check of any other base.
|
|
||||||
Release string
|
|
||||||
JudgedBy string
|
|
||||||
BesideAt map[string]string
|
|
||||||
// Validator says which node-engine validator the judge of a release-branch check was built with (ADR 0358 §2):
|
|
||||||
// mesh-host's of the release branch with its commit, the one the release branch's controller vendors, the
|
|
||||||
// change's own, or the one the change's own controller vendors. Empty for a check of any other base.
|
|
||||||
Validator string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer is one layer of a check, judged.
|
// Layer is one layer of a check, judged.
|
||||||
@@ -301,17 +274,7 @@ func ScriptToolchain(script []byte) string {
|
|||||||
// Check runs one merge check. An error is that it could not run; the verdict is then "error".
|
// Check runs one merge check. An error is that it could not run; the verdict is then "error".
|
||||||
func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry string, forge GitCredential,
|
func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry string, forge GitCredential,
|
||||||
log Log) (CheckVerdict, error) {
|
log Log) (CheckVerdict, error) {
|
||||||
// Everything a check says goes to the build's log, which the bus keeps: said redacted (novox/hq issue 462). A line
|
// Everything a check says goes to the build's log, which the bus keeps: said redacted (novox/hq issue 462).
|
||||||
// at a time: its builds for the merge say theirs while its tests run (novox/hq issue 716).
|
|
||||||
if log != nil {
|
|
||||||
var saying sync.Mutex
|
|
||||||
inner := log
|
|
||||||
log = func(step, message string) {
|
|
||||||
saying.Lock()
|
|
||||||
defer saying.Unlock()
|
|
||||||
inner(step, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
redact := redactorFor(forge)
|
redact := redactorFor(forge)
|
||||||
say := logging(func(step, message string) {
|
say := logging(func(step, message string) {
|
||||||
if log != nil {
|
if log != nil {
|
||||||
@@ -340,32 +303,13 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
if err := forgetLeftCredentials(workspace); err != nil {
|
if err := forgetLeftCredentials(workspace); err != nil {
|
||||||
return CheckVerdict{}, err
|
return CheckVerdict{}, err
|
||||||
}
|
}
|
||||||
// **mesh-lab's replays run where no pull request reaches** (novox/hq issue 709, ADR 0358 §6): they hold the
|
clone := func(repository, ref, dir string) error {
|
||||||
// container runtime's socket, so their sources, home, Go cache and module cache are none of the workspace's.
|
|
||||||
replay, err := replayDirs(spec.JudgeCache, workspace, root, spec.ID)
|
|
||||||
if err != nil {
|
|
||||||
return CheckVerdict{}, err
|
|
||||||
}
|
|
||||||
if replay.base != "" {
|
|
||||||
defer os.RemoveAll(replay.base)
|
|
||||||
}
|
|
||||||
// cloneAt clones a repository and checks out ref — or, when the repository has no branch ref and orElse is said,
|
|
||||||
// orElse (novox/hq ADR 0358 §3); readAt keeps which, by directory.
|
|
||||||
readAt := map[string]string{}
|
|
||||||
cloneAt := func(repository, ref, orElse, dir string) error {
|
|
||||||
if _, err := run(ctx, root, "git", cloneWith(credentials, "clone", "--quiet", repository, dir)...); err != nil {
|
if _, err := run(ctx, root, "git", cloneWith(credentials, "clone", "--quiet", repository, dir)...); err != nil {
|
||||||
return fmt.Errorf("cannot clone %s: %s", redact.redact(repository), redact.redact(err.Error()))
|
return fmt.Errorf("cannot clone %s: %s", redact.redact(repository), redact.redact(err.Error()))
|
||||||
}
|
}
|
||||||
if err := recordedWithoutUserinfo(ctx, run, filepath.Join(root, dir), repository); err != nil {
|
if err := recordedWithoutUserinfo(ctx, run, filepath.Join(root, dir), repository); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if orElse != "" && ref != "" {
|
|
||||||
if _, err := run(ctx, filepath.Join(root, dir), "git", "rev-parse", "--verify", "--quiet",
|
|
||||||
"refs/remotes/origin/"+ref); err != nil {
|
|
||||||
ref = orElse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readAt[dir] = ref
|
|
||||||
if ref != "" {
|
if ref != "" {
|
||||||
if _, err := run(ctx, filepath.Join(root, dir), "git", "checkout", "--quiet", ref); err != nil {
|
if _, err := run(ctx, filepath.Join(root, dir), "git", "checkout", "--quiet", ref); err != nil {
|
||||||
return fmt.Errorf("%s has no %s: %w", redact.redact(repository), ref, err)
|
return fmt.Errorf("%s has no %s: %w", redact.redact(repository), ref, err)
|
||||||
@@ -373,9 +317,6 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
clone := func(repository, ref, dir string) error {
|
|
||||||
return cloneAt(repository, ref, "", dir)
|
|
||||||
}
|
|
||||||
name := spec.Repo
|
name := spec.Repo
|
||||||
if name == "" || !safeName.MatchString(name) {
|
if name == "" || !safeName.MatchString(name) {
|
||||||
name = "checked"
|
name = "checked"
|
||||||
@@ -411,28 +352,8 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
}
|
}
|
||||||
named := func(v CheckVerdict) CheckVerdict {
|
named := func(v CheckVerdict) CheckVerdict {
|
||||||
v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members
|
v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members
|
||||||
v.Release, v.JudgedBy, v.BesideAt, v.Validator = spec.Release, judged.JudgedBy, judged.BesideAt, judged.Validator
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
// **What the merge would move is built from a checkout nothing of the check touches** (novox/hq issue 703): the
|
|
||||||
// gate and the repository's own check run in containers that mount the workspace read-write, so they can change
|
|
||||||
// its tree, its objects and its caches. The builds are made from a copy of the merge's objects, cloned before
|
|
||||||
// anything of the change runs, outside the workspace — so what is kept under a key is what the key describes. Each
|
|
||||||
// build's working copy is cloned from it into the workspace only after the check has ended (forTheMerge): the
|
|
||||||
// host's container engine sees no other directory of the holder's (novox/hq issue 713).
|
|
||||||
buildingTree := ""
|
|
||||||
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
|
|
||||||
at := judged.Merge
|
|
||||||
if at == "" {
|
|
||||||
at = spec.Ref
|
|
||||||
}
|
|
||||||
var building string
|
|
||||||
building, buildingTree, err = checkoutForTheBuilds(ctx, run, tree, at, workspace)
|
|
||||||
if err != nil {
|
|
||||||
return CheckVerdict{}, err
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(building)
|
|
||||||
}
|
|
||||||
// **The base branch's check judges the change** (novox/hq issue 310), never the one the change brings.
|
// **The base branch's check judges the change** (novox/hq issue 310), never the one the change brings.
|
||||||
judging, err := TheCheckThatJudges(ctx, tree, spec.Base)
|
judging, err := TheCheckThatJudges(ctx, tree, spec.Base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -455,35 +376,9 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
if dir == name || !safeName.MatchString(dir) {
|
if dir == name || !safeName.MatchString(dir) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if dir == "mesh-lab" {
|
if err := clone(b.Repository, b.Ref, dir); err != nil {
|
||||||
// **mesh-lab is never read at a release branch** (novox/hq ADR 0358): its replays run with the container
|
|
||||||
// runtime's socket, so a branch of a release's name — which anyone may create — is never what runs there,
|
|
||||||
// whether it is asked with a fallback or without.
|
|
||||||
if b.Else != "" || (spec.Release != "" && b.Ref == spec.Release) {
|
|
||||||
b.Ref = b.Else
|
|
||||||
if b.Ref == "" {
|
|
||||||
b.Ref = "main"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.Else = ""
|
|
||||||
}
|
|
||||||
if err := cloneAt(b.Repository, b.Ref, b.Else, dir); err != nil {
|
|
||||||
return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
|
return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
|
||||||
}
|
}
|
||||||
if dir == "mesh-lab" {
|
|
||||||
// The copy the replays run from, made before anything of the change runs, outside every check container
|
|
||||||
// (novox/hq issue 709) — and whether it holds replays, read now, so nothing written later decides it.
|
|
||||||
if err := replay.copyLab(ctx, run, filepath.Join(root, dir)); err != nil {
|
|
||||||
return CheckVerdict{}, fmt.Errorf("mesh-lab could not be copied for its replays: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if spec.Release != "" {
|
|
||||||
// The branch each was read at is named with its commit (ADR 0358 §4).
|
|
||||||
if judged.BesideAt == nil {
|
|
||||||
judged.BesideAt = map[string]string{}
|
|
||||||
}
|
|
||||||
judged.BesideAt[dir] = readAt[dir]
|
|
||||||
}
|
|
||||||
// The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question.
|
// The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question.
|
||||||
if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" {
|
if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" {
|
||||||
if judged.Beside == nil {
|
if judged.Beside == nil {
|
||||||
@@ -491,7 +386,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
}
|
}
|
||||||
judged.Beside[dir] = at
|
judged.Beside[dir] = at
|
||||||
}
|
}
|
||||||
say("check", "beside it %s at %s", dir, refWords(readAt[dir]))
|
say("check", "beside it %s at %s", dir, short(b.Ref))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this.
|
// A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this.
|
||||||
@@ -543,25 +438,6 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
// Every clone is made: the credential is gone before anything of the check runs (novox/hq issue 462).
|
// Every clone is made: the credential is gone before anything of the check runs (novox/hq issue 462).
|
||||||
forget()
|
forget()
|
||||||
|
|
||||||
// **What the merge moves is built beside the tests** (novox/hq issue 716) when the build agent has a judge cache to
|
|
||||||
// build it in, where no container of the check reaches; without one, after the check has judged.
|
|
||||||
logged := func(step, message string) {
|
|
||||||
if log != nil {
|
|
||||||
log(step, redact.redact(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var beside *besideTheTests
|
|
||||||
if buildingTree != "" {
|
|
||||||
place, err := buildsBesideTheTests(spec.JudgeCache, workspace, spec.ID)
|
|
||||||
if err != nil {
|
|
||||||
return CheckVerdict{}, err
|
|
||||||
}
|
|
||||||
if place != "" {
|
|
||||||
beside = startBesideTheTests(ctx, run, spec, place, buildingTree, judged, forge, logged, say)
|
|
||||||
defer beside.abandon()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The facts, and the versions they say the mesh runs.
|
// The facts, and the versions they say the mesh runs.
|
||||||
if registry == "" {
|
if registry == "" {
|
||||||
return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from")
|
return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from")
|
||||||
@@ -624,56 +500,21 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
user = fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())
|
user = fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())
|
||||||
}
|
}
|
||||||
in := func(image, dir string, env []string, command ...string) []string {
|
in := func(image, dir string, env []string, command ...string) []string {
|
||||||
home := workspace
|
|
||||||
for _, e := range env {
|
|
||||||
if strings.HasPrefix(e, "HOME=") {
|
|
||||||
home = strings.TrimPrefix(e, "HOME=")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// As the builder itself: what a check writes into the workspace is the builder's to remove.
|
// As the builder itself: what a check writes into the workspace is the builder's to remove.
|
||||||
args := []string{"run", "--rm", "--network", "host", "--volume", workspace + ":" + workspace, "--workdir", dir,
|
args := []string{"run", "--rm", "--network", "host", "--volume", workspace + ":" + workspace, "--workdir", dir,
|
||||||
"--user", user, "--env", "HOME=" + home,
|
"--user", user, "--env", "HOME=" + workspace,
|
||||||
// The check's own checkouts, whoever cloned them: git in a container of another user than the
|
// The check's own checkouts, whoever cloned them: git in a container of another user than the
|
||||||
// one that cloned refuses a repository it does not own ("dubious ownership"), and Go's build
|
// one that cloned refuses a repository it does not own ("dubious ownership"), and Go's build
|
||||||
// stamps the version from git — a judge that would not build for want of it.
|
// stamps the version from git — a judge that would not build for want of it.
|
||||||
"--env", "GIT_CONFIG_COUNT=1", "--env", "GIT_CONFIG_KEY_0=safe.directory", "--env", "GIT_CONFIG_VALUE_0=*"}
|
"--env", "GIT_CONFIG_COUNT=1", "--env", "GIT_CONFIG_KEY_0=safe.directory", "--env", "GIT_CONFIG_VALUE_0=*"}
|
||||||
for _, e := range env {
|
for _, e := range env {
|
||||||
if !strings.HasPrefix(e, "HOME=") {
|
args = append(args, "--env", e)
|
||||||
args = append(args, "--env", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return append(append(args, image), command...)
|
return append(append(args, image), command...)
|
||||||
}
|
}
|
||||||
inToolchain := func(dir string, env []string, command ...string) []string {
|
inToolchain := func(dir string, env []string, command ...string) []string {
|
||||||
return in(spec.Toolchain, dir, env, command...)
|
return in(spec.Toolchain, dir, env, command...)
|
||||||
}
|
}
|
||||||
// **Every judge is built where no pull request can reach** (novox/hq issue 482, ADR 0358 review): the workspace's
|
|
||||||
// Go cache, HOME and git configuration are written by pull requests' own scripts, and git runs during a Go build
|
|
||||||
// to stamp its version — a planted hook would rewrite the judge's sources before they compile. So a judge is built
|
|
||||||
// with a HOME and configuration made fresh for this check and a Go cache only judge builds mount (judgeBuildDirs),
|
|
||||||
// no global or system git configuration, no Go environment file, and no version stamping from git.
|
|
||||||
judgeHome, judgeGoCache, err := judgeBuildDirs(spec.JudgeCache, workspace, root, spec.ID)
|
|
||||||
if err != nil {
|
|
||||||
return CheckVerdict{}, err
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(judgeHome)
|
|
||||||
inJudgeBuild := func(dir string, _ []string, command ...string) []string {
|
|
||||||
args := in(spec.Toolchain, dir, JudgeBuildEnv(judgeHome, judgeGoCache), command...)
|
|
||||||
if spec.JudgeCache == "" {
|
|
||||||
return args // under the check's root, in the workspace mounted already
|
|
||||||
}
|
|
||||||
// The judge cache's parts this build needs, mounted into judge builds alone (ADR 0358 §6).
|
|
||||||
return append([]string{args[0], "--volume", judgeHome + ":" + judgeHome, "--volume",
|
|
||||||
judgeGoCache + ":" + judgeGoCache}, args[1:]...)
|
|
||||||
}
|
|
||||||
inReplays := func(env []string, command ...string) []string {
|
|
||||||
args := in(spec.Toolchain, filepath.Join(replay.lab, "replays"), append(ReplayEnv(replay.home, replay.goCache,
|
|
||||||
replay.modCache), env...), command...)
|
|
||||||
// The socket goes to the replays alone, never to the change's own code.
|
|
||||||
mounts := []string{"--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", replay.base + ":" +
|
|
||||||
replay.base, "--volume", replay.goCache + ":" + replay.goCache, "--volume", replay.modCache + ":" + replay.modCache}
|
|
||||||
return append(append([]string{args[0]}, mounts...), args[1:]...)
|
|
||||||
}
|
|
||||||
var out tail
|
var out tail
|
||||||
// running runs one container of the check, its output into the report, in a process group of its own.
|
// running runs one container of the check, its output into the report, in a process group of its own.
|
||||||
running := func(args []string) error {
|
running := func(args []string) error {
|
||||||
@@ -685,23 +526,10 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
|
|
||||||
// The judge. Its failing to build is the change's fault when the change is the judge or its validator,
|
// The judge. Its failing to build is the change's fault when the change is the judge or its validator,
|
||||||
// and the check's when it is the controller the mesh runs.
|
// and the check's when it is the controller the mesh runs.
|
||||||
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, judged.BesideAt, inJudgeBuild, say)
|
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, inToolchain, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CheckVerdict{}, err
|
return CheckVerdict{}, err
|
||||||
}
|
}
|
||||||
if spec.Release != "" {
|
|
||||||
// **A release-branch judgement says so** (novox/hq ADR 0358 §4): the branch, the controller's commit that
|
|
||||||
// judged, and each repository read beside it at its branch and commit.
|
|
||||||
judgedBy := filepath.Join(root, "mesh-controller")
|
|
||||||
if spec.Judge == "self" {
|
|
||||||
judgedBy = tree
|
|
||||||
}
|
|
||||||
judged.JudgedBy = headAt(ctx, run, judgedBy)
|
|
||||||
judged.Validator = releaseValidator(spec, judged.BesideAt, judged.Beside)
|
|
||||||
said := releaseWords(spec.Release, judged.JudgedBy, judged.Validator, judged.BesideAt, judged.Beside)
|
|
||||||
fmt.Fprintf(&out, "--- %s\n", said)
|
|
||||||
say("check", "%s", said)
|
|
||||||
}
|
|
||||||
verdictFile := filepath.Join(root, "verdict.json")
|
verdictFile := filepath.Join(root, "verdict.json")
|
||||||
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
|
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
|
||||||
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
||||||
@@ -727,7 +555,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
say("check", "the gate: %d module(s) of the graph touched — %s", len(modules), strings.Join(modules, ", "))
|
say("check", "the gate: %d module(s) of the graph touched — %s", len(modules), strings.Join(modules, ", "))
|
||||||
fmt.Fprintf(&out, "--- the gate: %s\n", strings.Join(modules, ", "))
|
fmt.Fprintf(&out, "--- the gate: %s\n", strings.Join(modules, ", "))
|
||||||
v.Gate.Verdict, v.Gate.Summary = gateLayer(ctx, spec, tree, root, gate, verdictFile, env, inToolchain,
|
v.Gate.Verdict, v.Gate.Summary = gateLayer(ctx, spec, tree, root, gate, verdictFile, env, inToolchain,
|
||||||
running, &out, bus, name, replay.hasReplays, replay.refused(), inReplays, say)
|
running, &out, bus, workspace, name, say)
|
||||||
}
|
}
|
||||||
if timedOut() {
|
if timedOut() {
|
||||||
v.Gate.Verdict, v.Gate.Summary = "error", fmt.Sprintf("the check ran past %s and was ended", CheckTimeout)
|
v.Gate.Verdict, v.Gate.Summary = "error", fmt.Sprintf("the check ran past %s and was ended", CheckTimeout)
|
||||||
@@ -767,11 +595,6 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if spec.Release != "" {
|
|
||||||
// The statuses name the release branch and the judge first, so the forge's short description keeps them.
|
|
||||||
mark := releaseMark(spec.Release, judged.JudgedBy)
|
|
||||||
v.Gate.Summary, v.Repo.Summary = mark+v.Gate.Summary, mark+v.Repo.Summary
|
|
||||||
}
|
|
||||||
// What the check printed travels in the verdict to the forge and the controller: redacted as the log is.
|
// What the check printed travels in the verdict to the forge and the controller: redacted as the log is.
|
||||||
v.Gate.Summary, v.Repo.Summary, v.Repo.Failed = redact.redact(v.Gate.Summary), redact.redact(v.Repo.Summary),
|
v.Gate.Summary, v.Repo.Summary, v.Repo.Failed = redact.redact(v.Gate.Summary), redact.redact(v.Repo.Summary),
|
||||||
redact.redact(v.Repo.Failed)
|
redact.redact(v.Repo.Failed)
|
||||||
@@ -782,68 +605,24 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
|||||||
|
|
||||||
// **What the merge would move, built once, here** (novox/hq ADR 0350 §1): after the judging, from the merge it
|
// **What the merge would move, built once, here** (novox/hq ADR 0350 §1): after the judging, from the merge it
|
||||||
// judged, into the scratch namespace — so the walk of the merge commit takes it rather than building again.
|
// judged, into the scratch namespace — so the walk of the merge commit takes it rather than building again.
|
||||||
switch {
|
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
|
||||||
case beside != nil:
|
if why := skipsTheBuilds(v); why != "" {
|
||||||
v.Built = beside.settle(ctx, v)
|
say("check", "%s", why)
|
||||||
case len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0:
|
} else {
|
||||||
v.Built = forTheMerge(ctx, run, spec, v, workspace, buildingTree, judged, forge, logged, say)
|
began := time.Now()
|
||||||
|
v.Built = buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, tree, judged.Merge, judged, forge,
|
||||||
|
func(step, message string) {
|
||||||
|
if log != nil {
|
||||||
|
log(step, redact.redact(message))
|
||||||
|
}
|
||||||
|
}, say)
|
||||||
|
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(v.Built), len(v.Built),
|
||||||
|
time.Since(began).Round(time.Second))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return named(v), nil
|
return named(v), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// forTheMerge builds what the merge moves once the check has judged it, from merged — the copy of the merge
|
|
||||||
// checkoutForTheBuilds made outside the workspace — **in the builder's own workspace** (novox/hq issue 713). A build's
|
|
||||||
// containers are run by the host's container engine, which mounts a path as the host names it: the build seat's
|
|
||||||
// holder runs in a container that shares only its workspace with the host, at the same path, so a working copy in
|
|
||||||
// its own temporary directory reaches the toolchain as an empty directory ("go.mod file not found"), every check
|
|
||||||
// build failed, and every walk built again. The copy stays outside the workspace, so nothing the tests ran reaches it;
|
|
||||||
// the working copy cloned from it is made only now, after every container of the check has ended.
|
|
||||||
func forTheMerge(ctx context.Context, run Runner, spec CheckSpec, verdict CheckVerdict, workspace, merged string,
|
|
||||||
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
|
|
||||||
if why := skipsTheBuilds(verdict); why != "" {
|
|
||||||
say("check", "%s", why)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
began := time.Now()
|
|
||||||
built := buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, merged, judged.Merge, judged, forge, log, say)
|
|
||||||
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(built), len(built),
|
|
||||||
time.Since(began).Round(time.Second))
|
|
||||||
return built
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkoutForTheBuilds is a clone of the commit a check builds for its merge, made before anything of the check runs,
|
|
||||||
// outside the workspace its containers mount (novox/hq issue 703): the directory to remove, and the clone in it.
|
|
||||||
// Cloned over git's own transport, never by linking the objects: what the check's containers later write into the
|
|
||||||
// workspace's objects cannot reach it.
|
|
||||||
func checkoutForTheBuilds(ctx context.Context, run Runner, tree, at, workspace string) (string, string, error) {
|
|
||||||
dir, err := os.MkdirTemp("", "mesh-check-builds-")
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if withinDir(workspace, dir) {
|
|
||||||
os.RemoveAll(dir)
|
|
||||||
return "", "", fmt.Errorf("the temporary directory %s is inside the workspace %s, which a check's containers "+
|
|
||||||
"mount: the merge is not built from where its tests could change it", dir, workspace)
|
|
||||||
}
|
|
||||||
commit, err := run(ctx, tree, "git", "rev-parse", "--verify", at+"^{commit}")
|
|
||||||
if err != nil {
|
|
||||||
os.RemoveAll(dir)
|
|
||||||
return "", "", fmt.Errorf("the commit to build for the merge, %s: %w", at, err)
|
|
||||||
}
|
|
||||||
commit = strings.TrimSpace(commit)
|
|
||||||
if _, err := run(ctx, tree, "git", "update-ref", "refs/heads/mesh-check-builds", commit); err != nil {
|
|
||||||
os.RemoveAll(dir)
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
cloned := filepath.Join(dir, "merged")
|
|
||||||
if _, err := run(ctx, dir, "git", "clone", "--quiet", "--no-local", "--no-checkout", "--branch",
|
|
||||||
"mesh-check-builds", tree, cloned); err != nil {
|
|
||||||
os.RemoveAll(dir)
|
|
||||||
return "", "", fmt.Errorf("cannot copy the merge to build it: %w", err)
|
|
||||||
}
|
|
||||||
return dir, cloned, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// builtCount is how many of a check's builds for its merge were built.
|
// builtCount is how many of a check's builds for its merge were built.
|
||||||
func builtCount(built []CheckBuilt) int {
|
func builtCount(built []CheckBuilt) int {
|
||||||
n := 0
|
n := 0
|
||||||
@@ -1015,9 +794,8 @@ const SomeManifestsEnv = "MESH_MODULE_CHECK_SOME=1"
|
|||||||
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
|
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
|
||||||
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
|
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
|
||||||
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
|
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
|
||||||
inToolchain func(string, []string, ...string) []string, running func([]string) error, out *tail, bus,
|
inToolchain func(string, []string, ...string) []string, running func([]string) error, out *tail, bus, workspace,
|
||||||
name string, hasReplays bool, replaysRefused string, inReplays func([]string, ...string) []string,
|
name string, say func(step, format string, args ...any)) (string, string) {
|
||||||
say func(step, format string, args ...any)) (string, string) {
|
|
||||||
// 1. The manifests the change touches, as the judge reads them: a manifest it cannot read, or one with a
|
// 1. The manifests the change touches, as the judge reads them: a manifest it cannot read, or one with a
|
||||||
// problem the change brings, fails here. **What was already so on the base branch is said and fails
|
// problem the change brings, fails here. **What was already so on the base branch is said and fails
|
||||||
// nothing** — the gate's own rule: a module whose manifest the running controller already finds fault
|
// nothing** — the gate's own rule: a module whose manifest the running controller already finds fault
|
||||||
@@ -1109,37 +887,24 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
|||||||
// 3. **The replays of what the mesh runs** (to-be 45 §9, M9): mesh-lab's, from its main — reviewed code,
|
// 3. **The replays of what the mesh runs** (to-be 45 §9, M9): mesh-lab's, from its main — reviewed code,
|
||||||
// so given the container runtime the resolver replay raises containers with — against the bus of the
|
// so given the container runtime the resolver replay raises containers with — against the bus of the
|
||||||
// release the mesh runs and the change's own catalogue when the change is to the catalogue.
|
// release the mesh runs and the change's own catalogue when the change is to the catalogue.
|
||||||
if ctx.Err() == nil && hasReplays && replaysRefused != "" {
|
if lab := filepath.Join(root, "mesh-lab", "replays"); ctx.Err() == nil {
|
||||||
// **Never from the workspace** (novox/hq issue 709): without the judge cache there is no place outside it.
|
if _, err := os.Stat(lab); err == nil {
|
||||||
say("check", "%s", replaysRefused)
|
|
||||||
fmt.Fprintf(out, "--- the replays: %s\n", replaysRefused)
|
|
||||||
return "error", replaysRefused
|
|
||||||
}
|
|
||||||
if ctx.Err() == nil {
|
|
||||||
if hasReplays {
|
|
||||||
catalogue := filepath.Join(root, "mesh-catalog")
|
catalogue := filepath.Join(root, "mesh-catalog")
|
||||||
if name == "mesh-catalog" {
|
if name == "mesh-catalog" {
|
||||||
catalogue = tree
|
catalogue = tree
|
||||||
}
|
}
|
||||||
say("check", "the replays of what the mesh runs, from mesh-lab")
|
say("check", "the replays of what the mesh runs, from mesh-lab")
|
||||||
fmt.Fprintln(out, "--- the replays (mesh-lab replays/)")
|
fmt.Fprintln(out, "--- the replays (mesh-lab replays/)")
|
||||||
// In the replays' own environment (inReplays): reviewed code, the socket, and nothing the workspace holds
|
args := inToolchain(lab, []string{EnvTestBus + "=nats://" + bus, "MESH_REPLAY_CATALOGUE=" + catalogue,
|
||||||
// but the catalogue they read.
|
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")},
|
||||||
args := inReplays([]string{EnvTestBus + "=nats://" + bus, "MESH_REPLAY_CATALOGUE=" + catalogue},
|
|
||||||
"go", "test", "-count=1", "./...")
|
"go", "test", "-count=1", "./...")
|
||||||
|
// The socket goes to the replays alone, never to the change's own code above.
|
||||||
|
args = append([]string{args[0], "--volume", "/var/run/docker.sock:/var/run/docker.sock"}, args[1:]...)
|
||||||
if err := running(args); err != nil {
|
if err := running(args); err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return "error", "the check was ended during the replays"
|
return "error", "the check was ended during the replays"
|
||||||
}
|
}
|
||||||
if replaysNotStarted(err) {
|
return "fail", "a replay of a core incident fails with this change: " + lastLine(out.String())
|
||||||
// The container runtime could not start them (a mount, an image): the check's fault, never the change's.
|
|
||||||
said := "the replays could not be started, so the change is not judged by them: " + lastLine(out.String())
|
|
||||||
say("check", "%s", said)
|
|
||||||
return "error", said
|
|
||||||
}
|
|
||||||
said := "a replay of a core incident fails with this change: " + lastLine(out.String())
|
|
||||||
say("check", "%s", said)
|
|
||||||
return "fail", said
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1154,32 +919,21 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
|||||||
// the mesh runs. It answers the judge's path; or, when the change makes its own judge unbuildable, why —
|
// the mesh runs. It answers the judge's path; or, when the change makes its own judge unbuildable, why —
|
||||||
// the change's fault, a failing gate; or an error when the check cannot build a judge at all. A check
|
// the change's fault, a failing gate; or an error when the check cannot build a judge at all. A check
|
||||||
// whose gate does not run builds a judge only to hand its scripts one, and goes on without when it cannot.
|
// whose gate does not run builds a judge only to hand its scripts one, and goes on without when it cannot.
|
||||||
//
|
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string,
|
||||||
// **A pull request into a release branch is judged by that branch's controller** (novox/hq ADR 0358 §2): the one
|
|
||||||
// beside it as mesh-controller, read at the release branch — with mesh-host's validator from beside it when mesh-host
|
|
||||||
// has that branch too, or with the change's own validator for a change to the node-engine — never the controller the
|
|
||||||
// mesh runs. besideAt is the branch each repository beside it was read at.
|
|
||||||
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string, besideAt map[string]string,
|
|
||||||
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
|
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
|
||||||
gated := spec.Gated()
|
gated := spec.Gated()
|
||||||
switch spec.Judge {
|
switch spec.Judge {
|
||||||
case "self":
|
case "self":
|
||||||
bin := filepath.Join(root, "bin", "judge-of-itself")
|
bin := filepath.Join(root, "bin", "judge-of-itself")
|
||||||
if _, err := labelled(ctx, root, "docker", inToolchain(tree, nil,
|
if _, err := labelled(ctx, root, "docker", inToolchain(tree,
|
||||||
|
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
||||||
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
|
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
|
||||||
return "", "the change's controller, which judges itself, does not build: " + firstLine(err.Error()), nil
|
return "", "the change's controller, which judges itself, does not build: " + firstLine(err.Error()), nil
|
||||||
}
|
}
|
||||||
say("check", "judged by the change's own controller")
|
say("check", "judged by the change's own controller")
|
||||||
return bin, "", nil
|
return bin, "", nil
|
||||||
}
|
}
|
||||||
var bin, judgeTree string
|
bin, judgeTree, err := judge(ctx, labelled, run, root, inToolchain, say)
|
||||||
var err error
|
|
||||||
if spec.Release != "" {
|
|
||||||
withHost := spec.Judge != "validator" && besideAt["mesh-host"] == spec.Release
|
|
||||||
bin, judgeTree, err = releaseJudge(ctx, labelled, run, spec, root, withHost, inToolchain, say)
|
|
||||||
} else {
|
|
||||||
bin, judgeTree, err = judge(ctx, labelled, run, root, inToolchain, say)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !gated {
|
if !gated {
|
||||||
say("check", "no judge for its script: %v", err)
|
say("check", "no judge for its script: %v", err)
|
||||||
@@ -1198,346 +952,15 @@ func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
withValidator := filepath.Join(root, "bin", "judge-with-this-validator")
|
withValidator := filepath.Join(root, "bin", "judge-with-this-validator")
|
||||||
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree), nil,
|
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree),
|
||||||
|
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
||||||
"go", "build", "-o", withValidator, "./cmd/mesh-controller")...); err != nil {
|
"go", "build", "-o", withValidator, "./cmd/mesh-controller")...); err != nil {
|
||||||
if spec.Release != "" {
|
|
||||||
return "", spec.Release + "'s controller does not build with this change's validator: " +
|
|
||||||
firstLine(err.Error()), nil
|
|
||||||
}
|
|
||||||
return "", "the controller the mesh runs does not build with this change's validator: " + firstLine(err.Error()), nil
|
return "", "the controller the mesh runs does not build with this change's validator: " + firstLine(err.Error()), nil
|
||||||
}
|
}
|
||||||
if spec.Release != "" {
|
|
||||||
say("check", "judged by %s's controller, with this change's validator", spec.Release)
|
|
||||||
return withValidator, "", nil
|
|
||||||
}
|
|
||||||
say("check", "judged by the controller the mesh runs, with this change's validator")
|
say("check", "judged by the controller the mesh runs, with this change's validator")
|
||||||
return withValidator, "", nil
|
return withValidator, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// releaseJudge builds the controller that judges a pull request into a release branch (novox/hq ADR 0358 §2): the
|
|
||||||
// one beside the check as mesh-controller, read at that branch — with mesh-host's validator from beside it in place
|
|
||||||
// of the one it vendors when withHost. **Built once per release-branch head**: kept in the spec's judge cache by the
|
|
||||||
// commits it is built from and taken from there while they stand. It answers the binary and the directory it was
|
|
||||||
// built from.
|
|
||||||
func releaseJudge(ctx context.Context, run, plain Runner, spec CheckSpec, root string, withHost bool,
|
|
||||||
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
|
|
||||||
const dir = "mesh-controller"
|
|
||||||
if _, err := os.Stat(filepath.Join(root, dir)); err != nil {
|
|
||||||
return "", "", fmt.Errorf("no controller of %s beside the check to judge it with", spec.Release)
|
|
||||||
}
|
|
||||||
key := fullHeadAt(ctx, plain, filepath.Join(root, dir))
|
|
||||||
if key == "" {
|
|
||||||
return "", "", fmt.Errorf("the controller of %s beside the check names no commit", spec.Release)
|
|
||||||
}
|
|
||||||
with := ""
|
|
||||||
if withHost {
|
|
||||||
host := fullHeadAt(ctx, plain, filepath.Join(root, "mesh-host"))
|
|
||||||
if host == "" {
|
|
||||||
return "", "", fmt.Errorf("mesh-host of %s beside the check names no commit", spec.Release)
|
|
||||||
}
|
|
||||||
key += "-with-mesh-host-" + host
|
|
||||||
with = ", with mesh-host's validator of " + spec.Release + " at " + twelve(host)
|
|
||||||
}
|
|
||||||
bin := filepath.Join(root, "bin", "mesh-controller")
|
|
||||||
if took := judgeKept(keptJudges(spec.JudgeCache), key, bin); took {
|
|
||||||
say("check", "judged by %s's controller at %s%s, built for an earlier check of that head", spec.Release,
|
|
||||||
twelve(key), with)
|
|
||||||
return bin, dir, nil
|
|
||||||
}
|
|
||||||
if withHost {
|
|
||||||
vendored := filepath.Join(root, dir, "vendor", "github.com", "novox", "mesh-host")
|
|
||||||
for _, pkg := range []string{"validate", filepath.Join("internal", "declaration")} {
|
|
||||||
if err := replaceGoFiles(filepath.Join(root, "mesh-host", pkg), filepath.Join(vendored, pkg)); err != nil {
|
|
||||||
return "", "", fmt.Errorf("mesh-host's validator of %s could not be put in its controller: %w", spec.Release, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Built where no pull request reaches (JudgeBuildEnv): a judge kept for this head is the head's, nothing else.
|
|
||||||
if _, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir), nil,
|
|
||||||
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
|
|
||||||
return "", "", fmt.Errorf("the controller of %s does not build%s: %w", spec.Release, with, err)
|
|
||||||
}
|
|
||||||
// Kept before anything of the change runs: nothing a pull request's script writes can reach the cache.
|
|
||||||
keepJudge(keptJudges(spec.JudgeCache), key, bin, say)
|
|
||||||
say("check", "judged by %s's controller at %s%s", spec.Release, twelve(key), with)
|
|
||||||
return bin, dir, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// JudgeBuildEnv is the environment every judge is built in (novox/hq ADR 0358 §6, issue 482): home, made fresh for the
|
|
||||||
// build, as HOME and configuration; goCache, the judge cache's Go cache that no check's container mounts; no global
|
|
||||||
// or system git configuration; no Go environment file; and no version stamped from git, so no git runs at all.
|
|
||||||
func JudgeBuildEnv(home, goCache string) []string {
|
|
||||||
return []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
|
|
||||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GOENV=off",
|
|
||||||
"GOCACHE=" + goCache, "GOPATH=" + filepath.Join(home, "go"),
|
|
||||||
"CGO_ENABLED=0", "GOFLAGS=-mod=vendor -buildvcs=false", "GOPROXY=off"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// judgeBuildDirs are the home and the Go cache a check's judge builds use (ADR 0358 §6). With a judge cache: a home
|
|
||||||
// made fresh under its homes/ for this check, and its go-cache/, kept warm between checks — both on the host path the
|
|
||||||
// build agent declares, so the container runtime mounts what the agent sees, and nothing is left in a temporary
|
|
||||||
// directory of the host. Without one: both under the check's root, which every check empties before it clones. A
|
|
||||||
// judge cache inside the workspace is refused: a check's containers mount the workspace.
|
|
||||||
func judgeBuildDirs(cache, workspace, root, id string) (string, string, error) {
|
|
||||||
if cache == "" {
|
|
||||||
home, goCache := filepath.Join(root, "judge-home"), filepath.Join(root, "judge-go-cache")
|
|
||||||
for _, d := range []string{home, goCache} {
|
|
||||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return home, goCache, nil
|
|
||||||
}
|
|
||||||
if withinDir(workspace, cache) {
|
|
||||||
return "", "", fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers mount: "+
|
|
||||||
"no judge is built where a pull request could reach it", cache, workspace)
|
|
||||||
}
|
|
||||||
if !safeName.MatchString(id) {
|
|
||||||
return "", "", fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
|
|
||||||
}
|
|
||||||
home, goCache := filepath.Join(cache, "homes", id), filepath.Join(cache, "go-cache")
|
|
||||||
if err := os.RemoveAll(home); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
for _, d := range []string{home, goCache} {
|
|
||||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return home, goCache, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// replayPlace is where mesh-lab's replays run from and with (novox/hq issue 709), never anything a pull request can
|
|
||||||
// write: base, the directory made for this check under the judge cache's homes/ (the same path on the host and in the
|
|
||||||
// build agent), removed after it; lab, the copy of mesh-lab they run; home, their HOME and configuration; and the judge
|
|
||||||
// cache's Go build and module caches. Empty without a judge cache: the replays are then refused. hasReplays says whether
|
|
||||||
// mesh-lab held replays when it was cloned, before anything of the change ran.
|
|
||||||
type replayPlace struct {
|
|
||||||
base, lab, home, goCache, modCache string
|
|
||||||
hasReplays bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaysNeedTheJudgeCache is the gate's error when mesh-lab holds replays and the build agent has no judge cache.
|
|
||||||
const replaysNeedTheJudgeCache = "the replays need the build agent's judge cache (mesh-catalog#297); refused rather " +
|
|
||||||
"than run from the workspace"
|
|
||||||
|
|
||||||
// refused is why the replays cannot run here, empty when they can.
|
|
||||||
func (p replayPlace) refused() string {
|
|
||||||
if p.base == "" {
|
|
||||||
return replaysNeedTheJudgeCache
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// replayDirs makes the replays' place (issue 709, ADR 0358 §6). The replays' build is as trusted as a judge's: it
|
|
||||||
// compiles mesh-lab's trunk, reviewed code, and nothing of the pull request, which hands them only its catalogue, as
|
|
||||||
// data. Never the workspace: the gate runs before the replays, a pull request's own code among its containers. Without
|
|
||||||
// a judge cache there is no place: the build agent declares no other host path, and the replays are refused.
|
|
||||||
func replayDirs(cache, workspace, root, id string) (replayPlace, error) {
|
|
||||||
if cache == "" {
|
|
||||||
return replayPlace{}, nil
|
|
||||||
}
|
|
||||||
if withinDir(workspace, cache) {
|
|
||||||
return replayPlace{}, fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers "+
|
|
||||||
"mount: no replay is run where a pull request could reach it", cache, workspace)
|
|
||||||
}
|
|
||||||
if !safeName.MatchString(id) {
|
|
||||||
return replayPlace{}, fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
|
|
||||||
}
|
|
||||||
base := filepath.Join(cache, "homes", id+"-replays")
|
|
||||||
p := replayPlace{base: base, lab: filepath.Join(base, "mesh-lab"), home: filepath.Join(base, "home"),
|
|
||||||
goCache: filepath.Join(cache, "go-cache"), modCache: filepath.Join(cache, "go-modules")}
|
|
||||||
if err := os.RemoveAll(base); err != nil {
|
|
||||||
return replayPlace{}, err
|
|
||||||
}
|
|
||||||
for _, d := range []string{p.home, p.goCache, p.modCache} {
|
|
||||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
||||||
return replayPlace{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyLab reads whether mesh-lab's checkout, freshly cloned and before anything of the change runs, holds replays, and
|
|
||||||
// copies it over git's own transport to where the replays run it — when there is such a place.
|
|
||||||
func (p *replayPlace) copyLab(ctx context.Context, run Runner, from string) error {
|
|
||||||
_, err := os.Stat(filepath.Join(from, "replays"))
|
|
||||||
p.hasReplays = err == nil
|
|
||||||
if p.lab == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return copyCheckout(ctx, run, from, p.lab)
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaysNotStarted says the container runtime could not start the replays' container — its own exit status 125, or
|
|
||||||
// 126 and 127 for a command it could not run — rather than that a replay failed.
|
|
||||||
func replaysNotStarted(err error) bool {
|
|
||||||
var exit *exec.ExitError
|
|
||||||
if !errors.As(err, &exit) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
code := exit.ExitCode()
|
|
||||||
return code == 125 || code == 126 || code == 127
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplayEnv is the environment mesh-lab's replays run in (novox/hq issue 709): home as HOME and configuration, the Go
|
|
||||||
// build and module caches given, no global or system git configuration, no Go environment file, and no version
|
|
||||||
// stamped from git — as a judge is built (JudgeBuildEnv), with the modules the replays fetch.
|
|
||||||
func ReplayEnv(home, goCache, modCache string) []string {
|
|
||||||
return []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
|
|
||||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GOENV=off",
|
|
||||||
"GOCACHE=" + goCache, "GOMODCACHE=" + modCache, "GOPATH=" + filepath.Join(home, "go"),
|
|
||||||
"GOFLAGS=-buildvcs=false", "GOWORK=off"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyCheckout copies a checkout at the commit it stands at into dir, over git's own transport — never by linking
|
|
||||||
// objects — so what is written into the source afterwards does not reach the copy.
|
|
||||||
func copyCheckout(ctx context.Context, run Runner, from, dir string) error {
|
|
||||||
commit := fullHeadAt(ctx, run, from)
|
|
||||||
if commit == "" {
|
|
||||||
return fmt.Errorf("%s names no commit", from)
|
|
||||||
}
|
|
||||||
if _, err := run(ctx, filepath.Dir(dir), "git", "clone", "--quiet", "--no-local", "--no-checkout", from, dir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := run(ctx, dir, "git", "checkout", "--quiet", "--detach", commit)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// keptJudges is where a judge cache keeps the release-branch judges, empty when there is no judge cache.
|
|
||||||
func keptJudges(cache string) string {
|
|
||||||
if cache == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(cache, "judges")
|
|
||||||
}
|
|
||||||
|
|
||||||
// judgesKept is how many release-branch judges a judge cache keeps: a head moved past is not built from again.
|
|
||||||
const judgesKept = 4
|
|
||||||
|
|
||||||
// judgeKept puts the judge kept under key in the cache at bin, and says whether there was one.
|
|
||||||
func judgeKept(cache, key, bin string) bool {
|
|
||||||
if cache == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
body, err := os.ReadFile(filepath.Join(cache, key, "mesh-controller"))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(bin, body, 0o755); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
_ = os.Chtimes(filepath.Join(cache, key), now, now)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// keepJudge keeps a judge just built under key, and lets go of all but the judgesKept most recently used. A judge
|
|
||||||
// that cannot be kept is said and built again next time: the cache only saves time.
|
|
||||||
func keepJudge(cache, key, bin string, say func(step, format string, args ...any)) {
|
|
||||||
if cache == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := os.ReadFile(bin)
|
|
||||||
if err == nil {
|
|
||||||
err = os.MkdirAll(filepath.Join(cache, key), 0o755)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
kept := filepath.Join(cache, key, "mesh-controller")
|
|
||||||
if err = os.WriteFile(kept+".part", body, 0o755); err == nil {
|
|
||||||
err = os.Rename(kept+".part", kept)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
say("check", "the judge could not be kept for the next check, which builds it again: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries, err := os.ReadDir(cache)
|
|
||||||
if err != nil || len(entries) <= judgesKept {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
type used struct {
|
|
||||||
name string
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
var all []used
|
|
||||||
for _, e := range entries {
|
|
||||||
if info, err := e.Info(); err == nil && e.IsDir() {
|
|
||||||
all = append(all, used{e.Name(), info.ModTime()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(all, func(i, j int) bool { return all[i].at.After(all[j].at) })
|
|
||||||
for _, u := range all[min(judgesKept, len(all)):] {
|
|
||||||
if u.name != key {
|
|
||||||
_ = os.RemoveAll(filepath.Join(cache, u.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fullHeadAt is the whole commit a checkout stands at, empty when it names none.
|
|
||||||
func fullHeadAt(ctx context.Context, run Runner, tree string) string {
|
|
||||||
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseValidator says which node-engine validator a release-branch check's judge is built with (novox/hq ADR 0358
|
|
||||||
// §2), by the rule judgeFor builds it by.
|
|
||||||
func releaseValidator(spec CheckSpec, at, commits map[string]string) string {
|
|
||||||
switch {
|
|
||||||
case spec.Judge == "self":
|
|
||||||
return "the validator the change's own controller vendors"
|
|
||||||
case spec.Judge == "validator":
|
|
||||||
return "the change's own validator"
|
|
||||||
case at["mesh-host"] == spec.Release:
|
|
||||||
return fmt.Sprintf("mesh-host's validator of %s at %s", spec.Release, commits["mesh-host"])
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("the validator %s's controller vendors (mesh-host has no branch %s)", spec.Release, spec.Release)
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseWords is how a release-branch check says what judged it and what it read (novox/hq ADR 0358 §4).
|
|
||||||
func releaseWords(release, judgedBy, validator string, at, commits map[string]string) string {
|
|
||||||
var read []string
|
|
||||||
for _, dir := range sortedKeys(at) {
|
|
||||||
read = append(read, fmt.Sprintf("%s %s at %s", dir, refWords(at[dir]), commits[dir]))
|
|
||||||
}
|
|
||||||
said := fmt.Sprintf("judged for the release branch %s by its controller at %s, with %s", release, judgedBy, validator)
|
|
||||||
if len(read) > 0 {
|
|
||||||
said += "; read beside it: " + strings.Join(read, ", ")
|
|
||||||
}
|
|
||||||
return said
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseMark opens each layer's summary of a release-branch check, so a status says it first.
|
|
||||||
func releaseMark(release, judgedBy string) string {
|
|
||||||
return fmt.Sprintf("[release %s, judged by its controller %s] ", release, judgedBy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refWords is a ref as a line says it: a commit by its first eight characters, a branch whole.
|
|
||||||
func refWords(ref string) string {
|
|
||||||
if commitLike.MatchString(ref) {
|
|
||||||
return short(ref)
|
|
||||||
}
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
|
|
||||||
// commitLike is a ref that names a commit.
|
|
||||||
var commitLike = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
|
|
||||||
|
|
||||||
func sortedKeys(m map[string]string) []string {
|
|
||||||
keys := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaceGoFiles puts a package's Go files — never its tests — in place of another copy's.
|
// replaceGoFiles puts a package's Go files — never its tests — in place of another copy's.
|
||||||
func replaceGoFiles(from, into string) error {
|
func replaceGoFiles(from, into string) error {
|
||||||
old, err := filepath.Glob(filepath.Join(into, "*.go"))
|
old, err := filepath.Glob(filepath.Join(into, "*.go"))
|
||||||
@@ -1772,7 +1195,8 @@ func judge(ctx context.Context, run, plain Runner, root string, inToolchain func
|
|||||||
say func(step, format string, args ...any)) (string, string, error) {
|
say func(step, format string, args ...any)) (string, string, error) {
|
||||||
bin := filepath.Join(root, "bin", "mesh-controller")
|
bin := filepath.Join(root, "bin", "mesh-controller")
|
||||||
build := func(dir string) error {
|
build := func(dir string) error {
|
||||||
_, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir), nil,
|
_, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir),
|
||||||
|
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
||||||
"go", "build", "-o", bin, "./cmd/mesh-controller")...)
|
"go", "build", "-o", bin, "./cmd/mesh-controller")...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
@@ -36,17 +35,8 @@ type CheckBuilding struct {
|
|||||||
Publish Publisher
|
Publish Publisher
|
||||||
Scratch Scratch
|
Scratch Scratch
|
||||||
Held map[string]string
|
Held map[string]string
|
||||||
// Running is each artifact of the build the mesh has registered for its module, keyed "<module>/<artifact>"
|
|
||||||
// (novox/hq issue 721): what a build for the merge is compared with — never Held's newest build, which may be one
|
|
||||||
// its gate failed and the mesh put back.
|
|
||||||
Running map[string]string
|
|
||||||
Npmrc Npmrc
|
Npmrc Npmrc
|
||||||
Seats map[string]string
|
Seats map[string]string
|
||||||
// Named are the builds the build seat's verdicts of earlier checks of this pull request at this head name
|
|
||||||
// (novox/hq issue 703): a build kept under a module's key is kept again, instead of built, only when one of them
|
|
||||||
// names its key and every digest, and the build is this pull request's at this head. The record alone is never
|
|
||||||
// enough.
|
|
||||||
Named []NamedBuild
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckBuilt is one module a check built for its merge, as its verdict lists it (§1): its key and its artifacts, or
|
// CheckBuilt is one module a check built for its merge, as its verdict lists it (§1): its key and its artifacts, or
|
||||||
@@ -58,14 +48,6 @@ type CheckBuilt struct {
|
|||||||
// NotBuilt says why it was not: no key, a build that failed, or a module after one of those.
|
// NotBuilt says why it was not: no key, a build that failed, or a module after one of those.
|
||||||
NotBuilt string
|
NotBuilt string
|
||||||
Took time.Duration
|
Took time.Duration
|
||||||
// Kept is the check whose build this check kept instead of building, its key unchanged (novox/hq issue 703):
|
|
||||||
// the verdict names that build, by key and digest, as that check's. Empty when this check built it.
|
|
||||||
Kept string
|
|
||||||
// Same says every artifact came out the same as the build the mesh holds of the module (novox/hq issue 721), and
|
|
||||||
// SameAs names, per artifact, the held reference it was compared with: the walk leaves the module where it is
|
|
||||||
// while the mesh still holds that build. Empty when it differs, or could not be compared.
|
|
||||||
Same string
|
|
||||||
SameAs map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// skipsTheBuilds is why a check builds nothing for its merge; empty when it builds.
|
// skipsTheBuilds is why a check builds nothing for its merge; empty when it builds.
|
||||||
@@ -78,45 +60,13 @@ func skipsTheBuilds(v CheckVerdict) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildForTheMerge builds each module the plan moves into the scratch namespace (makeForTheMerge), and keeps what it
|
// buildForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace, and keeps what it
|
||||||
// built under its keys (keepForTheMerge): the order of a check that builds after it has judged.
|
// built under its keys. A module standing after one not built is not built either: what it stands on in the walk
|
||||||
|
// is not what it would stand on here. tree is the check's clone and merge the merge commit it made there.
|
||||||
func buildForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
|
func buildForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
|
||||||
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
|
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
|
||||||
out, kept := makeForTheMerge(ctx, run, spec, with, workspace, tree, merge, judged, forge, log, say)
|
|
||||||
return keepForTheMerge(ctx, spec, with, out, kept, say)
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace — **the modules of a
|
|
||||||
// tier at the same time** (novox/hq issue 716): none of them stands on another, and a tier waits only for the ones
|
|
||||||
// before it, whose builds it is handed. A module standing after one not built is not built either: what it stands on
|
|
||||||
// in the walk is not what it would stand on here. tree is the check's copy of the merge and merge the merge commit it
|
|
||||||
// made there; workspace is where each build's working copy is made, one directory per module, which the host's
|
|
||||||
// container engine sees at the same path (novox/hq issue 713). Answers what each module came to, in the plan's order,
|
|
||||||
// and the builds to keep — nothing is kept here.
|
|
||||||
func makeForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
|
|
||||||
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) ([]CheckBuilt, []CheckBuild) {
|
|
||||||
if len(spec.Builds) == 0 || with.Publish == nil || with.Scratch == nil {
|
if len(spec.Builds) == 0 || with.Publish == nil || with.Scratch == nil {
|
||||||
return nil, nil
|
return nil
|
||||||
}
|
|
||||||
// What an earlier check left, ended before it could remove it, is not built on.
|
|
||||||
place := filepath.Join(workspace, "for-the-merge")
|
|
||||||
_ = os.RemoveAll(place)
|
|
||||||
defer os.RemoveAll(place)
|
|
||||||
// One log, said to by builds running at once: a line at a time.
|
|
||||||
var saying sync.Mutex
|
|
||||||
if log != nil {
|
|
||||||
inner := log
|
|
||||||
log = func(step, message string) {
|
|
||||||
saying.Lock()
|
|
||||||
defer saying.Unlock()
|
|
||||||
inner(step, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inner := say
|
|
||||||
say = func(step, format string, args ...any) {
|
|
||||||
saying.Lock()
|
|
||||||
defer saying.Unlock()
|
|
||||||
inner(step, format, args...)
|
|
||||||
}
|
}
|
||||||
ref := merge
|
ref := merge
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
@@ -126,230 +76,76 @@ func makeForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with Check
|
|||||||
for k, v := range with.Held {
|
for k, v := range with.Held {
|
||||||
held[k] = v
|
held[k] = v
|
||||||
}
|
}
|
||||||
out := make([]CheckBuilt, len(spec.Builds))
|
var out []CheckBuilt
|
||||||
results := make([]Result, len(spec.Builds))
|
|
||||||
var kept []CheckBuild
|
var kept []CheckBuild
|
||||||
stopAt, stopWhy := -1, ""
|
stopAt, stopWhy := -1, ""
|
||||||
for start := 0; start < len(spec.Builds); {
|
for _, b := range spec.Builds {
|
||||||
tier := spec.Builds[start].Tier
|
began := time.Now()
|
||||||
end := start
|
if stopAt >= 0 && b.Tier > stopAt {
|
||||||
for end < len(spec.Builds) && spec.Builds[end].Tier == tier {
|
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: stopWhy})
|
||||||
end++
|
continue
|
||||||
}
|
}
|
||||||
// What this tier's builds are handed: the builds of the tiers before it, read alone while they run.
|
if b.Skip != "" {
|
||||||
handed := map[string]string{}
|
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
|
||||||
for k, v := range held {
|
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: b.Skip})
|
||||||
handed[k] = v
|
if stopAt < 0 || b.Tier < stopAt {
|
||||||
|
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
var building sync.WaitGroup
|
repository, at := b.Repository, b.Ref
|
||||||
for i := start; i < end; i++ {
|
if b.Merged {
|
||||||
b := spec.Builds[i]
|
repository, at = tree, ref
|
||||||
if stopAt >= 0 && b.Tier > stopAt {
|
|
||||||
out[i] = CheckBuilt{Module: b.Module, NotBuilt: stopWhy}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if b.Skip != "" {
|
|
||||||
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
|
|
||||||
out[i] = CheckBuilt{Module: b.Module, NotBuilt: b.Skip}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
repository, at := b.Repository, b.Ref
|
|
||||||
if b.Merged {
|
|
||||||
repository, at = tree, ref
|
|
||||||
}
|
|
||||||
name := b.Module
|
|
||||||
if !safeName.MatchString(name) {
|
|
||||||
name = fmt.Sprintf("module-%d", i)
|
|
||||||
}
|
|
||||||
building.Add(1)
|
|
||||||
go func(i int, b CheckBuildSpec, repository, at, dir string) {
|
|
||||||
defer building.Done()
|
|
||||||
began := time.Now()
|
|
||||||
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
|
|
||||||
var mirrored []string
|
|
||||||
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at, dir, handed,
|
|
||||||
with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true, Scratch: with.Scratch, Named: with.Named,
|
|
||||||
pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, head: judged.Head},
|
|
||||||
with.Seats)
|
|
||||||
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, errNoKey):
|
|
||||||
built.NotBuilt = err.Error()
|
|
||||||
case err != nil:
|
|
||||||
built.NotBuilt = "its build failed: " + err.Error()
|
|
||||||
case result.ReuseKey == "":
|
|
||||||
built.NotBuilt = "it has no reuse key: " + result.NoKey
|
|
||||||
}
|
|
||||||
if built.NotBuilt == "" {
|
|
||||||
// **Compared with what runs** (novox/hq issue 721): a failed comparison says nothing, and the
|
|
||||||
// module moves as before.
|
|
||||||
same, against, err := sameAsHeld(ctx, run, dir, b.Module, result.Built, with.Running)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
say("build", "%s could not be compared with the build the mesh holds, so it moves: %v", b.Module, err)
|
|
||||||
case same != "":
|
|
||||||
built.Same, built.SameAs = same, against
|
|
||||||
say("build", "%s %s", b.Module, same)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[i], results[i] = built, result
|
|
||||||
}(i, b, repository, at, filepath.Join(place, name))
|
|
||||||
}
|
}
|
||||||
building.Wait()
|
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
|
||||||
for i := start; i < end; i++ {
|
var mirrored []string
|
||||||
b, built, result := spec.Builds[i], &out[i], results[i]
|
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at,
|
||||||
if built.NotBuilt != "" {
|
filepath.Join(workspace, "for-the-merge"), held, with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true},
|
||||||
if built.Key != "" || built.Took > 0 {
|
with.Seats)
|
||||||
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
|
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
|
||||||
}
|
switch {
|
||||||
if stopAt < 0 || b.Tier < stopAt {
|
case errors.Is(err, errNoKey):
|
||||||
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
built.NotBuilt = err.Error()
|
||||||
}
|
case err != nil:
|
||||||
continue
|
built.NotBuilt = "its build failed: " + err.Error()
|
||||||
|
case result.ReuseKey == "":
|
||||||
|
built.NotBuilt = "it has no reuse key: " + result.NoKey
|
||||||
|
}
|
||||||
|
if built.NotBuilt != "" {
|
||||||
|
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
|
||||||
|
out = append(out, built)
|
||||||
|
if stopAt < 0 || b.Tier < stopAt {
|
||||||
|
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
||||||
}
|
}
|
||||||
built.Made = result.Built
|
continue
|
||||||
for _, m := range result.Built {
|
}
|
||||||
held[b.Module+"/"+m.Name] = m.Reference
|
built.Made = result.Built
|
||||||
}
|
for _, m := range result.Built {
|
||||||
if result.Reused != nil {
|
held[b.Module+"/"+m.Name] = m.Reference
|
||||||
// **Kept, not built** (novox/hq issue 703): the earlier check's build, named as that check's, so the
|
}
|
||||||
// walk takes it as the verdict names it; this check's document names it too, so it stays while this
|
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]), built.Took.Round(time.Second))
|
||||||
// check does (ADR 0350 §5).
|
out = append(out, built)
|
||||||
built.Kept = result.Reused.From.Check
|
if len(result.Built) > 0 {
|
||||||
say("build", "%s kept for the merge under %s: %s", b.Module, twelve(result.ReuseKey[3:]),
|
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
|
||||||
result.Reused.From.Said())
|
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
|
||||||
kept = append(kept, result.Reused.From)
|
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
|
||||||
continue
|
|
||||||
}
|
|
||||||
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]),
|
|
||||||
built.Took.Round(time.Second))
|
|
||||||
if len(result.Built) > 0 {
|
|
||||||
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
|
|
||||||
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
|
|
||||||
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
start = end
|
|
||||||
}
|
}
|
||||||
return out, kept
|
_ = os.RemoveAll(filepath.Join(workspace, "for-the-merge"))
|
||||||
}
|
if len(kept) > 0 {
|
||||||
|
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
|
||||||
// keepForTheMerge keeps a check's builds under their keys, its own document naming them, so the walk of its merge
|
time.Now().UTC(), kept); err != nil {
|
||||||
// finds them; what cannot be kept is let go of, said, and each module says it — never the check's verdict.
|
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
|
||||||
func keepForTheMerge(ctx context.Context, spec CheckSpec, with CheckBuilding, out []CheckBuilt, kept []CheckBuild,
|
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
|
||||||
say func(step, format string, args ...any)) []CheckBuilt {
|
if err := with.Scratch.Discard(ctx, kept); err != nil {
|
||||||
if len(kept) == 0 {
|
say("build", "and what they made could not all be let go of: %v", err)
|
||||||
return out
|
}
|
||||||
}
|
for i := range out {
|
||||||
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
|
if out[i].NotBuilt == "" {
|
||||||
time.Now().UTC(), kept); err != nil {
|
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
|
||||||
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
|
}
|
||||||
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
|
|
||||||
if err := with.Scratch.Discard(ctx, kept); err != nil {
|
|
||||||
say("build", "and what they made could not all be let go of: %v", err)
|
|
||||||
}
|
|
||||||
for i := range out {
|
|
||||||
if out[i].NotBuilt == "" {
|
|
||||||
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// discardForTheMerge lets go of builds a check made beside its tests when the check did not pass (novox/hq issue 716):
|
|
||||||
// a change that fails is not merged, and nothing it built is kept. A build another check names stays (Discard).
|
|
||||||
func discardForTheMerge(ctx context.Context, with CheckBuilding, kept []CheckBuild, why string,
|
|
||||||
say func(step, format string, args ...any)) {
|
|
||||||
say("check", "%s: the %d build(s) made beside it are let go of", why, len(kept))
|
|
||||||
if len(kept) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := with.Scratch.Discard(ctx, kept); err != nil {
|
|
||||||
say("build", "what they made could not all be let go of, and goes with the week's sweep: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// besideTheTests are a check's builds for its merge running while its gate and its own check run (novox/hq issue
|
|
||||||
// 716): started once every clone is made, waited for when the check has judged, kept when it passed and let go of
|
|
||||||
// when it did not.
|
|
||||||
type besideTheTests struct {
|
|
||||||
spec CheckSpec
|
|
||||||
with CheckBuilding
|
|
||||||
cancel context.CancelFunc
|
|
||||||
done chan struct{}
|
|
||||||
place string
|
|
||||||
out []CheckBuilt
|
|
||||||
kept []CheckBuild
|
|
||||||
began time.Time
|
|
||||||
settled bool
|
|
||||||
say func(step, format string, args ...any)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildsBesideTheTests is where a check's builds run beside its tests: a directory made for this check under the judge
|
|
||||||
// cache's homes/ (ADR 0358 §6), on the host path the build agent declares — so the host's container engine mounts what
|
|
||||||
// the agent sees (issue 713) — and never mounted by a check's container, so nothing a pull request's script runs can
|
|
||||||
// change a build while it is made (issue 703). Empty, and the builds wait for the check, without a judge cache.
|
|
||||||
func buildsBesideTheTests(cache, workspace, id string) (string, error) {
|
|
||||||
if cache == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if withinDir(workspace, cache) {
|
|
||||||
return "", fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers mount: "+
|
|
||||||
"nothing is built for the merge where a pull request could reach it", cache, workspace)
|
|
||||||
}
|
|
||||||
if !safeName.MatchString(id) {
|
|
||||||
return "", fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
|
|
||||||
}
|
|
||||||
place := filepath.Join(cache, "homes", id+"-builds")
|
|
||||||
if err := os.RemoveAll(place); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(place, 0o700); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return place, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// startBesideTheTests starts the builds for the merge in place, from merged, the check's copy of the merge.
|
|
||||||
func startBesideTheTests(ctx context.Context, run Runner, spec CheckSpec, place, merged string, judged CheckVerdict,
|
|
||||||
forge GitCredential, log Log, say func(step, format string, args ...any)) *besideTheTests {
|
|
||||||
building, cancel := context.WithCancel(ctx)
|
|
||||||
b := &besideTheTests{spec: spec, with: *spec.BuildWith, cancel: cancel, done: make(chan struct{}), place: place,
|
|
||||||
began: time.Now(), say: say}
|
|
||||||
say("check", "building what the merge moves (%d module(s)) beside its tests", len(spec.Builds))
|
|
||||||
go func() {
|
|
||||||
defer close(b.done)
|
|
||||||
b.out, b.kept = makeForTheMerge(building, run, spec, b.with, place, merged, judged.Merge, judged, forge, log, say)
|
|
||||||
}()
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// settle waits for the builds and keeps them when the check passed; a check that did not pass lets go of them, and
|
|
||||||
// its verdict names none.
|
|
||||||
func (b *besideTheTests) settle(ctx context.Context, verdict CheckVerdict) []CheckBuilt {
|
|
||||||
<-b.done
|
|
||||||
b.settled = true
|
|
||||||
defer os.RemoveAll(b.place)
|
|
||||||
if why := skipsTheBuilds(verdict); why != "" {
|
|
||||||
discardForTheMerge(ctx, b.with, b.kept, why, b.say)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
built := keepForTheMerge(ctx, b.spec, b.with, b.out, b.kept, b.say)
|
|
||||||
b.say("check", "built for the merge: %d of %d module(s), beside the tests (%s)", builtCount(built), len(built),
|
|
||||||
time.Since(b.began).Round(time.Second))
|
|
||||||
return built
|
|
||||||
}
|
|
||||||
|
|
||||||
// abandon ends builds a check that ended without a verdict leaves, and lets go of what they made.
|
|
||||||
func (b *besideTheTests) abandon() {
|
|
||||||
if b.settled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b.cancel()
|
|
||||||
<-b.done
|
|
||||||
defer os.RemoveAll(b.place)
|
|
||||||
letGo, done := context.WithTimeout(context.Background(), time.Minute)
|
|
||||||
defer done()
|
|
||||||
discardForTheMerge(letGo, b.with, b.kept, "the check ended without a verdict", b.say)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -210,53 +210,3 @@ func TestACheckMergeRunsNoHook(t *testing.T) {
|
|||||||
t.Fatalf("a hook ran when the check made its merge: %s", said)
|
t.Fatalf("a hook ran when the check made its merge: %s", said)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a check's tests change in the workspace never reaches what it builds for the merge (novox/hq issue 703): the
|
|
||||||
// builds are made from a copy of the merge, outside the workspace, cloned before anything of the change runs.
|
|
||||||
func TestWhatTheTestsChangeInTheWorkspaceNeverReachesTheBuild(t *testing.T) {
|
|
||||||
repo, head, _ := aTrunkAndABranch(t, map[string]string{"main.go": "package main // the merge\n"},
|
|
||||||
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
|
|
||||||
workspace := t.TempDir()
|
|
||||||
tree := filepath.Join(workspace, "check", "checked")
|
|
||||||
if out, err := exec.Command("git", "clone", "--quiet", repo, tree).CombinedOutput(); err != nil {
|
|
||||||
t.Fatalf("%v\n%s", err, out)
|
|
||||||
}
|
|
||||||
dir, cloned, err := checkoutForTheBuilds(t.Context(), Command, tree, head, workspace)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(dir)
|
|
||||||
if withinDir(workspace, cloned) {
|
|
||||||
t.Fatalf("the builds' checkout %s is inside the workspace %s the tests mount", cloned, workspace)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The tests rewrite the tree, its branch and every object it holds.
|
|
||||||
if err := os.WriteFile(filepath.Join(tree, "main.go"), []byte("package main // planted\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := filepath.WalkDir(filepath.Join(tree, ".git", "objects"), func(path string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil || d.IsDir() {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = os.Chmod(path, 0o644)
|
|
||||||
return os.WriteFile(path, []byte("planted"), 0o644)
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The build clones the merge from its own checkout, as buildForTheMerge does, and reads what the merge holds.
|
|
||||||
build := filepath.Join(t.TempDir(), "source")
|
|
||||||
if out, err := exec.Command("git", "clone", "--quiet", cloned, build).CombinedOutput(); err != nil {
|
|
||||||
t.Fatalf("%v\n%s", err, out)
|
|
||||||
}
|
|
||||||
if out, err := exec.Command("git", "-C", build, "checkout", "--quiet", head).CombinedOutput(); err != nil {
|
|
||||||
t.Fatalf("%v\n%s", err, out)
|
|
||||||
}
|
|
||||||
body, err := os.ReadFile(filepath.Join(build, "main.go"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(body) != "package main // the merge\n" {
|
|
||||||
t.Fatalf("the build read %q: what the tests wrote reached it", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A build reads the list of predefined keys at the root of the repository at the commit it builds (novox/hq ADR
|
||||||
|
// 0348 §7, design 57 package K2, issue 661): it refuses a list that is wrong, judges the manifest against it, and
|
||||||
|
// carries it with the commit's generation for the controller to publish.
|
||||||
|
|
||||||
|
const theFirstKeys = `{
|
||||||
|
"font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72},
|
||||||
|
"agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// fallingBack is a module of the catalogue whose font size falls back to the predefined key named.
|
||||||
|
func fallingBack(key, kind string) string {
|
||||||
|
def := "10"
|
||||||
|
if kind == "string" {
|
||||||
|
def = `"10"`
|
||||||
|
}
|
||||||
|
return `{"module":"dunst","version":"1",
|
||||||
|
"build":{"artifacts":[{"name":"look","kind":"archive","from":"files"}]},
|
||||||
|
"config":{"font-size":{"type":"` + kind + `","description":"the notification's font size, in points",
|
||||||
|
"default":` + def + `,"predefined":"` + key + `"}},
|
||||||
|
"resources":[
|
||||||
|
{"id":"theme","type":"archive","path":"/opt/dunst","artifact":"look"},
|
||||||
|
{"id":"rc","type":"file","path":"/etc/xdg/dunst/dunstrc","content":"font = Inter ${config:font-size}\n"}]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInTheCatalogue(t *testing.T, manifest string, list *string) (Result, error) {
|
||||||
|
t.Helper()
|
||||||
|
r := &recorded{contents: map[string]string{"modules/dunst/" + ManifestName: manifest,
|
||||||
|
"modules/dunst/files/theme.conf": "dark", "README.md": "the catalogue"}}
|
||||||
|
if list != nil {
|
||||||
|
r.contents[catalogue.PredefinedFile] = *list
|
||||||
|
}
|
||||||
|
return Build(context.Background(), onTrunk{recorded: r}.run, r, "https://forge.invalid/mesh-catalog.git",
|
||||||
|
"modules/dunst", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildNamingAPredefinedKeyIsJudgedAgainstItsTreesListAndCarriesIt(t *testing.T) {
|
||||||
|
list := theFirstKeys
|
||||||
|
got, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), &list)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a module falling back to font-size did not build: %v", err)
|
||||||
|
}
|
||||||
|
if got.Predefined == nil || got.Predefined.Generation != 42 {
|
||||||
|
t.Fatalf("the list was not carried with its generation: %+v", got.Predefined)
|
||||||
|
}
|
||||||
|
carried, err := catalogue.ParsePredefined(got.Predefined.Keys)
|
||||||
|
if err != nil || len(carried) != 2 || carried["font-size"].Type != catalogue.ConfigNumber {
|
||||||
|
t.Fatalf("the list carried is %s (%v)", got.Predefined.Keys, err)
|
||||||
|
}
|
||||||
|
// A change to the list is a change to the module that was judged against it.
|
||||||
|
if len(got.Sources) == 0 || !slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
|
||||||
|
t.Fatalf("its build source leaves the list out: %+v", got.Sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct{ manifest, says string }{
|
||||||
|
{fallingBack("font-scale", "number"), `"font-scale", which is not on the list`},
|
||||||
|
{fallingBack("font-size", "string"), "which is a number, and this value is a string"},
|
||||||
|
} {
|
||||||
|
if _, err := buildInTheCatalogue(t, c.manifest, &list); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("wanted the build refused saying %q, got %v", c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildRefusesAWrongList(t *testing.T) {
|
||||||
|
wrong := `{"font-size": {"type": "number", "description": "points", "unit": "pt"}}`
|
||||||
|
if _, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), &wrong); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), `unknown field "unit"`) {
|
||||||
|
t.Fatalf("a wrong list was built with: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A repository without the list builds as before, carrying none; a value naming a key there is refused.
|
||||||
|
func TestABuildWithoutAListCarriesNone(t *testing.T) {
|
||||||
|
plain := strings.Replace(fallingBack("font-size", "number"), `,"predefined":"font-size"`, "", 1)
|
||||||
|
got, err := buildInTheCatalogue(t, plain, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Predefined != nil {
|
||||||
|
t.Fatalf("a list was carried from a tree holding none: %+v", got.Predefined)
|
||||||
|
}
|
||||||
|
if slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
|
||||||
|
t.Fatalf("the build source names a list: %+v", got.Sources)
|
||||||
|
}
|
||||||
|
if _, err := buildInTheCatalogue(t, fallingBack("font-size", "number"), nil); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "is not present") {
|
||||||
|
t.Fatalf("a key named where no list is: %v", err)
|
||||||
|
}
|
||||||
|
// A list present and a module naming no key: carried, and not the module's build source.
|
||||||
|
list := theFirstKeys
|
||||||
|
got, err = buildInTheCatalogue(t, plain, &list)
|
||||||
|
if err != nil || got.Predefined == nil || slices.Contains(got.Sources[0].Paths, catalogue.PredefinedFile) {
|
||||||
|
t.Fatalf("a module naming no key: %+v %+v %v", got.Predefined, got.Sources, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,870 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A pull request into a release branch is judged by the controller of that branch** (novox/hq ADR 0358, issue
|
|
||||||
// 704): the build seat's check builds the judge from mesh-controller read at the release branch — with mesh-host's
|
|
||||||
// validator of that branch when mesh-host has one — reads every repository beside it at that branch where it has one
|
|
||||||
// and at its trunk otherwise, and names the branch, the judge's commit and each repository read with its commit.
|
|
||||||
|
|
||||||
const releaseBranch = "feat/57-config-and-provisioning"
|
|
||||||
|
|
||||||
// gitInRepo runs git in a repository made for a test.
|
|
||||||
func gitInRepo(t *testing.T, dir string, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
cmd.Dir = dir
|
|
||||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
|
|
||||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
// commitFiles writes files into a repository and commits them on the branch checked out.
|
|
||||||
func commitFiles(t *testing.T, dir string, files map[string]string) string {
|
|
||||||
t.Helper()
|
|
||||||
for name, body := range files {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gitInRepo(t, dir, "add", "-A")
|
|
||||||
gitInRepo(t, dir, "commit", "--quiet", "-m", "x")
|
|
||||||
return gitInRepo(t, dir, "rev-parse", "HEAD")
|
|
||||||
}
|
|
||||||
|
|
||||||
// aRepositoryWithARelease is a repository whose main holds mainFiles and, when releaseFiles is not nil, a release
|
|
||||||
// branch over them holding releaseFiles. It answers the repository, main's head and the release branch's head.
|
|
||||||
func aRepositoryWithARelease(t *testing.T, mainFiles, releaseFiles map[string]string) (string, string, string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
gitInRepo(t, dir, "init", "--quiet", "-b", "main")
|
|
||||||
main := commitFiles(t, dir, mainFiles)
|
|
||||||
if releaseFiles == nil {
|
|
||||||
return dir, main, ""
|
|
||||||
}
|
|
||||||
gitInRepo(t, dir, "checkout", "--quiet", "-b", releaseBranch)
|
|
||||||
head := commitFiles(t, dir, releaseFiles)
|
|
||||||
gitInRepo(t, dir, "checkout", "--quiet", "main")
|
|
||||||
return dir, main, head
|
|
||||||
}
|
|
||||||
|
|
||||||
// aJudgeOf is a controller that judges as told: its gate passes, saying who it is and the word of the node-engine
|
|
||||||
// validator it vendors — the one it was built with.
|
|
||||||
func aJudgeOf(who string) map[string]string {
|
|
||||||
return map[string]string{
|
|
||||||
"go.mod": "module example.org/judge\n\ngo 1.22\n\nrequire github.com/novox/mesh-host v0.0.0\n",
|
|
||||||
"vendor/modules.txt": "# github.com/novox/mesh-host v0.0.0\n## explicit; go 1.22\ngithub.com/novox/mesh-host/validate\n",
|
|
||||||
"vendor/github.com/novox/mesh-host/validate/v.go": "package validate\n\nconst Word = \"the vendored validator\"\n",
|
|
||||||
"vendor/github.com/novox/mesh-host/internal/declaration/d.go": "package declaration\n",
|
|
||||||
"cmd/mesh-controller/main.go": `package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-host/validate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if len(os.Args) == 2 && os.Args[1] == "merge-gate" {
|
|
||||||
fmt.Println("usage: merge-gate --facts <file> --store <postgres>")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
fmt.Printf("{\"verdict\":\"pass\",\"summary\":\"%s with %s\"}\n", "` + who + `", validate.Word)
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// aHostSaying is the node-engine's validator, saying a word.
|
|
||||||
func aHostSaying(word string) map[string]string {
|
|
||||||
return map[string]string{
|
|
||||||
"validate/v.go": "package validate\n\nconst Word = \"" + word + "\"\n",
|
|
||||||
"internal/declaration/d.go": "package declaration\n",
|
|
||||||
"validate/v_test.go": "package validate\n",
|
|
||||||
"internal/declaration/x_test.go": "package declaration\n",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// aRuntimeThatBuildsTheJudge puts a container runtime on PATH that runs every container's command on this machine,
|
|
||||||
// in the directory and with the environment the check gives it — the judge's build too — and a store and a bus that
|
|
||||||
// answer. It answers the file each `go` the check runs is written to.
|
|
||||||
func aRuntimeThatBuildsTheJudge(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
if _, err := exec.LookPath("go"); err != nil {
|
|
||||||
t.Skip("no Go toolchain on this machine to build a judge with")
|
|
||||||
}
|
|
||||||
bus, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { bus.Close() })
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
c, err := bus.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
dir := t.TempDir()
|
|
||||||
built := filepath.Join(dir, "built")
|
|
||||||
runtime := `#!/bin/sh
|
|
||||||
cmd="$1"; shift
|
|
||||||
case "$cmd" in
|
|
||||||
ps|rm|exec) exit 0 ;;
|
|
||||||
volume)
|
|
||||||
if [ -n "$VOLUMES" ]; then echo "$*" >> "$VOLUMES"; fi
|
|
||||||
exit 0 ;;
|
|
||||||
port) echo "` + bus.Addr().String() + `"; exit 0 ;;
|
|
||||||
run)
|
|
||||||
if [ -n "$RUNS" ]; then echo "$*" >> "$RUNS"; fi
|
|
||||||
workdir=""; detached=""
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
-d) detached=1; shift ;;
|
|
||||||
--rm) shift ;;
|
|
||||||
--workdir) workdir="$2"; shift 2 ;;
|
|
||||||
--env) export "$2"; shift 2 ;;
|
|
||||||
--network|--volume|--user|--label|--name|-p|-e) shift 2 ;;
|
|
||||||
*) break ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
if [ -n "$detached" ]; then echo a-container; exit 0; fi
|
|
||||||
shift
|
|
||||||
if [ "$1" = go ]; then echo "GOCACHE=$GOCACHE GOENV=$GOENV $*" >> "` + built + `"; fi
|
|
||||||
if [ "$1" = go ] && [ "$2" = build ] && [ -n "$JUDGE_HOMES" ]; then echo "$HOME" >> "$JUDGE_HOMES"; fi
|
|
||||||
if [ "$1" = go ] && [ "$2" = test ] && [ -n "$REPLAYS_CANNOT_START" ]; then
|
|
||||||
echo "docker: Error response from daemon: invalid mount config for type bind" >&2; exit 125
|
|
||||||
fi
|
|
||||||
if [ "$1" = go ] && [ "$2" = test ] && [ -n "$REPLAYS" ]; then
|
|
||||||
echo "HOME=$HOME GOCACHE=$GOCACHE GOMODCACHE=$GOMODCACHE GOENV=$GOENV GOFLAGS=$GOFLAGS DIR=$workdir" >> "$REPLAYS"
|
|
||||||
fi
|
|
||||||
if [ "$1" = go ] && [ -n "$WARM_GO_CACHE" ] && [ -n "$GOCACHE" ] && [ -z "$(ls -A "$GOCACHE" 2>/dev/null)" ]; then
|
|
||||||
mkdir -p "$GOCACHE" && cp -R "$WARM_GO_CACHE/." "$GOCACHE/"
|
|
||||||
fi
|
|
||||||
cd "$workdir" && exec "$@" ;;
|
|
||||||
esac
|
|
||||||
exit 1
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(runtime), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
t.Setenv("GOTOOLCHAIN", "local")
|
|
||||||
// An empty Go cache the check names is filled from a warm one before `go` runs (warm_go_cache_test.go).
|
|
||||||
t.Setenv("WARM_GO_CACHE", warmGoCache(t))
|
|
||||||
return built
|
|
||||||
}
|
|
||||||
|
|
||||||
// builds is how many times the check ran go to build a judge.
|
|
||||||
func builds(t *testing.T, file string) int {
|
|
||||||
t.Helper()
|
|
||||||
body, err := os.ReadFile(file)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return strings.Count(string(body), "go build")
|
|
||||||
}
|
|
||||||
|
|
||||||
// theReleasedMesh is the core beside a release-branch check: a controller whose main and release branch judge
|
|
||||||
// differently, a node-engine with a validator of each, and a lab with no release branch.
|
|
||||||
func theReleasedMesh(t *testing.T) (map[string]Beside, string, string, string) {
|
|
||||||
t.Helper()
|
|
||||||
controller, _, controllerRelease := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
|
|
||||||
aJudgeOf("the release branch's controller"))
|
|
||||||
host, _, hostRelease := aRepositoryWithARelease(t, aHostSaying("main's validator"),
|
|
||||||
aHostSaying("the release branch's validator"))
|
|
||||||
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{"README": "the lab"}, nil)
|
|
||||||
return map[string]Beside{
|
|
||||||
"mesh-controller": {Repository: controller, Ref: releaseBranch},
|
|
||||||
"mesh-controller-main": {Repository: controller, Ref: "main"},
|
|
||||||
"mesh-host": {Repository: host, Ref: releaseBranch, Else: "main"},
|
|
||||||
"mesh-lab": {Repository: lab, Ref: releaseBranch, Else: "main"},
|
|
||||||
}, controllerRelease, hostRelease, labMain
|
|
||||||
}
|
|
||||||
|
|
||||||
// aPullRequestInto is a repository whose pull request's head is cut from base, with a change.
|
|
||||||
func aPullRequestInto(t *testing.T, files map[string]string, base string, change map[string]string) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
repo, _, _ := aRepositoryWithARelease(t, files, map[string]string{"released": "r"})
|
|
||||||
gitInRepo(t, repo, "checkout", "--quiet", "-b", "change", base)
|
|
||||||
head := commitFiles(t, repo, change)
|
|
||||||
gitInRepo(t, repo, "checkout", "--quiet", "main")
|
|
||||||
return repo, head
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestACataloguePullRequestIntoAReleaseBranchIsJudgedByThatBranchsController(t *testing.T) {
|
|
||||||
built := aRuntimeThatBuildsTheJudge(t)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
beside, controllerRelease, hostRelease, labMain := theReleasedMesh(t)
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
cache := t.TempDir()
|
|
||||||
check := func(id string) CheckVerdict {
|
|
||||||
t.Helper()
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: id, Repository: catalogue, Ref: head, Owner: "novox",
|
|
||||||
Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch, JudgeCache: cache,
|
|
||||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside, Toolchain: "golang"},
|
|
||||||
t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
v := check("check-704-catalogue")
|
|
||||||
// The release branch's controller judged, with mesh-host's validator of the release branch.
|
|
||||||
if v.Gate == nil || v.Gate.Verdict != "pass" ||
|
|
||||||
!strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
|
|
||||||
t.Fatalf("the gate was not judged by the release branch's controller with its node-engine's validator: %+v\n%s",
|
|
||||||
v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
// It names the release branch, the judge's commit, and each repository read beside it at its branch and commit.
|
|
||||||
mark := "[release " + releaseBranch + ", judged by its controller " + controllerRelease[:12] + "] "
|
|
||||||
if !strings.HasPrefix(v.Gate.Summary, mark) || !strings.HasPrefix(v.Repo.Summary, mark) {
|
|
||||||
t.Fatalf("the statuses do not name the release branch and its judge first:\n%s\n%s", v.Gate.Summary, v.Repo.Summary)
|
|
||||||
}
|
|
||||||
if v.Release != releaseBranch || v.JudgedBy != controllerRelease[:12] {
|
|
||||||
t.Fatalf("the verdict names release %q judged by %q, not %s by %s", v.Release, v.JudgedBy, releaseBranch,
|
|
||||||
controllerRelease[:12])
|
|
||||||
}
|
|
||||||
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
|
|
||||||
t.Fatalf("mesh-host was read at %q (%s), not its release branch at %s", v.BesideAt["mesh-host"],
|
|
||||||
v.Beside["mesh-host"], hostRelease[:12])
|
|
||||||
}
|
|
||||||
// A repository with no release branch is read at its trunk, and says so.
|
|
||||||
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] {
|
|
||||||
t.Fatalf("the lab, with no release branch, was read at %q (%s), not main at %s", v.BesideAt["mesh-lab"],
|
|
||||||
v.Beside["mesh-lab"], labMain[:12])
|
|
||||||
}
|
|
||||||
if !strings.Contains(v.Report, "read beside it: ") || !strings.Contains(v.Report, "mesh-host "+releaseBranch+" at "+hostRelease[:12]) {
|
|
||||||
t.Fatalf("the report does not say what was read beside it:\n%s", v.Report)
|
|
||||||
}
|
|
||||||
// It says which validator judged: mesh-host's of the release branch, with its commit.
|
|
||||||
if want := "mesh-host's validator of " + releaseBranch + " at " + hostRelease[:12]; v.Validator != want ||
|
|
||||||
!strings.Contains(v.Report, want) {
|
|
||||||
t.Fatalf("the verdict says the validator was %q, not %q:\n%s", v.Validator, want, v.Report)
|
|
||||||
}
|
|
||||||
// Each repository read beside it is named with the branch and the commit read.
|
|
||||||
for dir := range beside {
|
|
||||||
if v.BesideAt[dir] == "" || v.Beside[dir] == "" ||
|
|
||||||
!strings.Contains(v.Report, dir+" "+v.BesideAt[dir]+" at "+v.Beside[dir]) {
|
|
||||||
t.Fatalf("%s is not named with its branch and commit: at %q, commit %q\n%s", dir, v.BesideAt[dir],
|
|
||||||
v.Beside[dir], v.Report)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n := builds(t, built); n != 1 {
|
|
||||||
t.Fatalf("the judge was built %d times for one check", n)
|
|
||||||
}
|
|
||||||
// **The judge is built with the judge cache's Go cache** (ADR 0358 §6), which no check's container mounts, and no
|
|
||||||
// Go environment file.
|
|
||||||
said, _ := os.ReadFile(built)
|
|
||||||
if !strings.Contains(string(said), "GOCACHE="+filepath.Join(cache, "go-cache")+" GOENV=off go build") {
|
|
||||||
t.Fatalf("the release judge was not built with the judge cache's Go cache:\n%s", said)
|
|
||||||
}
|
|
||||||
// **Clause 5**: the head was merged onto the release branch's head, which the verdict names as its trunk commit,
|
|
||||||
// so the release branch's move puts it out of date as a trunk's move does (ADR 0323 §4, the forge's holder).
|
|
||||||
if releaseHead := gitInRepo(t, catalogue, "rev-parse", releaseBranch); v.Trunk != releaseHead[:12] {
|
|
||||||
t.Fatalf("the verdict names trunk %q, not the release branch's head %s", v.Trunk, releaseHead[:12])
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Built once per release-branch head**: a second check while that head stands takes the judge kept.
|
|
||||||
v = check("check-704-catalogue-again")
|
|
||||||
if !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
|
|
||||||
t.Fatalf("the kept judge did not judge: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if n := builds(t, built); n != 1 {
|
|
||||||
t.Fatalf("the judge of the same release-branch head was built again: %d builds", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAControllerPullRequestIntoAReleaseBranchReadsTheNodeEngineAtThatBranch(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
beside, _, hostRelease, _ := theReleasedMesh(t)
|
|
||||||
delete(beside, "mesh-controller")
|
|
||||||
controller, head := aPullRequestInto(t, aJudgeOf("the change's controller"), releaseBranch,
|
|
||||||
map[string]string{"cmd/x.txt": "x"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-controller", Repository: controller, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-controller", Number: 315, Base: releaseBranch, Release: releaseBranch,
|
|
||||||
JudgeCache: t.TempDir(), Paths: []string{"cmd/x.txt"}, Modules: []string{"mesh-controller"}, Judge: "self",
|
|
||||||
Beside: beside, Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the change's controller with the vendored validator") {
|
|
||||||
t.Fatalf("a controller change into a release branch did not judge itself: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
|
|
||||||
t.Fatalf("mesh-host was read beside it at %q (%s), not at the release branch's %s", v.BesideAt["mesh-host"],
|
|
||||||
v.Beside["mesh-host"], hostRelease[:12])
|
|
||||||
}
|
|
||||||
if v.Release != releaseBranch || v.JudgedBy == "" || v.JudgedBy != v.Merge[:12] {
|
|
||||||
t.Fatalf("the verdict names release %q judged by %q, not its own merge %s", v.Release, v.JudgedBy, v.Merge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPullRequestIntoTheTrunkIsJudgedAsBefore(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
|
|
||||||
aJudgeOf("the release branch's controller"))
|
|
||||||
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"),
|
|
||||||
aHostSaying("the release branch's validator"))
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-trunk", Repository: catalogue, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: t.TempDir(),
|
|
||||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang",
|
|
||||||
Beside: map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain},
|
|
||||||
"mesh-host": {Repository: host, Ref: hostMain}}}, t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Summary != "main's controller with the vendored validator" {
|
|
||||||
t.Fatalf("a trunk pull request was not judged by the controller beside it as before: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if v.Release != "" || v.JudgedBy != "" || v.BesideAt != nil || strings.Contains(v.Report, "release branch") {
|
|
||||||
t.Fatalf("a trunk pull request's verdict speaks of a release branch: %q %q %v", v.Release, v.JudgedBy, v.BesideAt)
|
|
||||||
}
|
|
||||||
if v.Beside["mesh-host"] != hostMain[:12] {
|
|
||||||
t.Fatalf("mesh-host was read at %s, not the commit asked %s", v.Beside["mesh-host"], hostMain[:12])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Without mesh-host's release branch, the release branch's controller keeps the validator it vendors** (ADR 0358
|
|
||||||
// §2), and the verdict says so.
|
|
||||||
func TestWithoutTheNodeEnginesReleaseBranchTheJudgeKeepsItsVendoredValidator(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
beside, controllerRelease, _, _ := theReleasedMesh(t)
|
|
||||||
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"), nil)
|
|
||||||
beside["mesh-host"] = Beside{Repository: host, Ref: releaseBranch, Else: "main"}
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-vendored", Repository: catalogue, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
|
||||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
|
||||||
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the vendored validator") {
|
|
||||||
t.Fatalf("without mesh-host's release branch the judge did not keep its vendored validator: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if v.JudgedBy != controllerRelease[:12] || v.BesideAt["mesh-host"] != "main" || v.Beside["mesh-host"] != hostMain[:12] {
|
|
||||||
t.Fatalf("judged by %q, mesh-host read at %q (%s)", v.JudgedBy, v.BesideAt["mesh-host"], v.Beside["mesh-host"])
|
|
||||||
}
|
|
||||||
if !strings.Contains(v.Validator, "controller vendors") || !strings.Contains(v.Report, v.Validator) {
|
|
||||||
t.Fatalf("the verdict does not say the vendored validator judged: %q\n%s", v.Validator, v.Report)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Clause 7** (ADR 0358 §7): a release branch's own pull request into its trunk is checked as any pull request into a
|
|
||||||
// trunk — by the controller beside it as the mesh runs it, the repositories at what a trunk check reads — and its
|
|
||||||
// verdict names no release branch.
|
|
||||||
func TestAReleaseBranchsPullRequestIntoItsTrunkIsJudgedByTheControllerTheMeshRuns(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"),
|
|
||||||
aJudgeOf("the release branch's controller"))
|
|
||||||
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"),
|
|
||||||
aHostSaying("the release branch's validator"))
|
|
||||||
catalogue, _, releaseHead := aRepositoryWithARelease(t, map[string]string{
|
|
||||||
"modules/gitea/module.json": `{"module":"gitea"}`, CheckScript: "true\n"},
|
|
||||||
map[string]string{"modules/gitea/index.ts": "released"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-release-into-trunk", Repository: catalogue,
|
|
||||||
Ref: releaseHead, Owner: "novox", Repo: "mesh-catalog", Number: 300, Base: "main", JudgeCache: t.TempDir(),
|
|
||||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang",
|
|
||||||
Beside: map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain},
|
|
||||||
"mesh-host": {Repository: host, Ref: hostMain}}}, t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Summary != "the controller the mesh runs with the vendored validator" {
|
|
||||||
t.Fatalf("the release branch's pull request into its trunk was not judged by the controller the mesh runs: %+v\n%s",
|
|
||||||
v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if v.Release != "" || v.JudgedBy != "" || v.BesideAt != nil || v.Validator != "" ||
|
|
||||||
strings.Contains(v.Report, "release branch") || strings.Contains(v.Gate.Summary, "[release") {
|
|
||||||
t.Fatalf("its verdict names a release branch: %q %q %v %q", v.Release, v.JudgedBy, v.BesideAt, v.Validator)
|
|
||||||
}
|
|
||||||
if v.Beside["mesh-host"] != hostMain[:12] || v.Beside["mesh-controller"] != controllerMain[:12] {
|
|
||||||
t.Fatalf("beside it were read %v, not the trunk's %s and %s", v.Beside, controllerMain[:12], hostMain[:12])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **mesh-lab is read at its trunk, always** (ADR 0358): its replays run with the container runtime's socket, so a
|
|
||||||
// branch of the release's name in mesh-lab — which anyone may create — is never what a check reads.
|
|
||||||
func TestAReleaseBranchCheckReadsMeshLabAtItsTrunkEvenWhenItHasThatBranch(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
beside, _, _, _ := theReleasedMesh(t)
|
|
||||||
lab, labMain, labRelease := aRepositoryWithARelease(t, map[string]string{"README": "the lab"},
|
|
||||||
map[string]string{"README": "a branch anyone could push"})
|
|
||||||
beside["mesh-lab"] = Beside{Repository: lab, Ref: releaseBranch, Else: "main"}
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-lab", Repository: catalogue, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
|
||||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
|
||||||
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] || v.Beside["mesh-lab"] == labRelease[:12] {
|
|
||||||
t.Fatalf("mesh-lab was read at %q (%s), not its trunk main at %s", v.BesideAt["mesh-lab"], v.Beside["mesh-lab"],
|
|
||||||
labMain[:12])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// aPlantedWorkspace is a workspace a pull request's own script has written into, as it can: a git configuration in its
|
|
||||||
// HOME whose fsmonitor hook says each directory git runs in, a git configuration under XDG_CONFIG_HOME doing the same,
|
|
||||||
// and a Go environment file that would hand the judge's build a tool of its own. It answers the workspace and the
|
|
||||||
// file the hook writes to.
|
|
||||||
func aPlantedWorkspace(t *testing.T) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
workspace, logs := t.TempDir(), t.TempDir()
|
|
||||||
ran := filepath.Join(logs, "hook-ran")
|
|
||||||
hook := filepath.Join(workspace, "hook.sh")
|
|
||||||
files := map[string]string{
|
|
||||||
hook: "#!/bin/sh\necho \"$PWD\" >> " + ran + "\n",
|
|
||||||
filepath.Join(workspace, ".gitconfig"): "[core]\n\tfsmonitor = " + hook + "\n",
|
|
||||||
filepath.Join(workspace, ".config", "git", "config"): "[core]\n\tfsmonitor = " + hook + "\n",
|
|
||||||
filepath.Join(workspace, ".config", "go", "env"): "GOFLAGS=-toolexec=" + hook + "\n",
|
|
||||||
}
|
|
||||||
for name, body := range files {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(name, []byte(body), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return workspace, ran
|
|
||||||
}
|
|
||||||
|
|
||||||
// judgeRanTheHook says whether the planted hook ran in a judge's tree: the controller beside the check, or the
|
|
||||||
// change's own when it judges itself.
|
|
||||||
func judgeRanTheHook(t *testing.T, ran string) string {
|
|
||||||
t.Helper()
|
|
||||||
body, _ := os.ReadFile(ran)
|
|
||||||
for _, line := range strings.Split(string(body), "\n") {
|
|
||||||
if strings.HasSuffix(line, "/check/mesh-controller") {
|
|
||||||
return string(body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Every judge is built where no pull request reaches** (issue 482, ADR 0358 review): a git configuration with a
|
|
||||||
// hook, and a Go environment file, planted in the workspace by an earlier pull request's script, do not run during
|
|
||||||
// the release judge's build — while the pull request's own script, in the workspace, still meets them.
|
|
||||||
func TestAHookPlantedInTheWorkspaceDoesNotRunDuringTheReleaseJudgesBuild(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
homes := filepath.Join(t.TempDir(), "homes")
|
|
||||||
t.Setenv("JUDGE_HOMES", homes)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
workspace, ran := aPlantedWorkspace(t)
|
|
||||||
beside, _, _, _ := theReleasedMesh(t)
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "git status >/dev/null 2>&1; true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-planted", Repository: catalogue, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
|
||||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
|
||||||
Toolchain: "golang"}, workspace, registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Verdict != "pass" {
|
|
||||||
t.Fatalf("the check did not judge: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if body, _ := os.ReadFile(ran); !strings.Contains(string(body), "/check/mesh-catalog") {
|
|
||||||
t.Fatalf("the planted hook never ran, so this test proves nothing: %q", body)
|
|
||||||
}
|
|
||||||
if said := judgeRanTheHook(t, ran); said != "" {
|
|
||||||
t.Fatalf("the hook planted in the workspace ran during the judge's build:\n%s", said)
|
|
||||||
}
|
|
||||||
if body, _ := os.ReadFile(homes); len(body) == 0 || strings.Contains(string(body), workspace) {
|
|
||||||
t.Fatalf("the judge was built with a HOME in the workspace: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same for the judge built with a node-engine change's own validator, into a release branch (ADR 0358 §2).
|
|
||||||
func TestAHookPlantedInTheWorkspaceDoesNotRunDuringTheValidatorJudgesBuild(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
homes := filepath.Join(t.TempDir(), "homes")
|
|
||||||
t.Setenv("JUDGE_HOMES", homes)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
workspace, ran := aPlantedWorkspace(t)
|
|
||||||
beside, _, _, _ := theReleasedMesh(t)
|
|
||||||
delete(beside, "mesh-host")
|
|
||||||
files := aHostSaying("the release branch's validator")
|
|
||||||
files["module.json"] = `{"module":"mesh-host"}`
|
|
||||||
files[CheckScript] = "git status >/dev/null 2>&1; true\n"
|
|
||||||
host, head := aPullRequestInto(t, files, releaseBranch, aHostSaying("this change's validator"))
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-planted-validator", Repository: host, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-host", Number: 111, Base: releaseBranch, Release: releaseBranch, Judge: "validator",
|
|
||||||
JudgeCache: t.TempDir(), Paths: []string{"validate/v.go"}, Modules: []string{"mesh-host"}, Beside: beside,
|
|
||||||
Toolchain: "golang"}, workspace, registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with this change's validator") {
|
|
||||||
t.Fatalf("a node-engine change into a release branch was not judged by that branch's controller with its own "+
|
|
||||||
"validator: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if body, _ := os.ReadFile(ran); !strings.Contains(string(body), "/check/mesh-host") {
|
|
||||||
t.Fatalf("the planted hook never ran, so this test proves nothing: %q", body)
|
|
||||||
}
|
|
||||||
if said := judgeRanTheHook(t, ran); said != "" {
|
|
||||||
t.Fatalf("the hook planted in the workspace ran during a judge's build:\n%s", said)
|
|
||||||
}
|
|
||||||
if body, _ := os.ReadFile(homes); strings.Count(string(body), "\n") != 2 || strings.Contains(string(body), workspace) {
|
|
||||||
t.Fatalf("the judges were not both built with a HOME outside the workspace: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **mesh-lab is never read at a release branch**, asked with a fallback or without (ADR 0358).
|
|
||||||
func TestTheBuildSeatNeverReadsMeshLabAtAReleaseBranch(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{"README": "the lab"},
|
|
||||||
map[string]string{"README": "a branch anyone could push"})
|
|
||||||
for _, asked := range []Beside{{Repository: lab, Ref: releaseBranch}, {Repository: lab, Ref: releaseBranch, Else: "main"}} {
|
|
||||||
beside, _, _, _ := theReleasedMesh(t)
|
|
||||||
beside["mesh-lab"] = asked
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-lab-asked", Repository: catalogue, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
|
||||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
|
||||||
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] {
|
|
||||||
t.Fatalf("mesh-lab asked as %+v was read at %q (%s), not main at %s", asked, v.BesideAt["mesh-lab"],
|
|
||||||
v.Beside["mesh-lab"], labMain[:12])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Judges are built warm and where no pull request reaches** (ADR 0358 §6): every judge build uses the judge cache's
|
|
||||||
// one Go cache, kept between checks; its home is made fresh under the judge cache and removed after; nothing is left in
|
|
||||||
// a temporary directory of the host; and no container a check runs for the pull request mounts the judge cache.
|
|
||||||
func TestJudgesAreBuiltWarmFromAJudgeCacheNoCheckContainerMounts(t *testing.T) {
|
|
||||||
built := aRuntimeThatBuildsTheJudge(t)
|
|
||||||
// Not filled from the test's warm cache: what the first judge's build leaves in the judge cache is what is proved.
|
|
||||||
t.Setenv("WARM_GO_CACHE", "")
|
|
||||||
runs := filepath.Join(t.TempDir(), "runs")
|
|
||||||
t.Setenv("RUNS", runs)
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("TMPDIR", tmp)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
cache := t.TempDir()
|
|
||||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
|
||||||
beside := map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain}}
|
|
||||||
for i := range 2 {
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": fmt.Sprint(i)})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: fmt.Sprintf("check-704-warm-%d", i), Repository: catalogue,
|
|
||||||
Ref: head, Owner: "novox", Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: cache,
|
|
||||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang", Beside: beside},
|
|
||||||
t.TempDir(), registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Summary != "the controller the mesh runs with the vendored validator" {
|
|
||||||
t.Fatalf("check %d was not judged: %+v\n%s", i, v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
if i == 0 {
|
|
||||||
if entries, _ := os.ReadDir(filepath.Join(cache, "go-cache")); len(entries) == 0 {
|
|
||||||
t.Fatal("the judge's build left nothing in the judge cache's Go cache to build the next judge warm")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Both judges built with the one Go cache of the judge cache.
|
|
||||||
said, _ := os.ReadFile(built)
|
|
||||||
if n := strings.Count(string(said), "GOCACHE="+filepath.Join(cache, "go-cache")+" GOENV=off go build"); n != 2 {
|
|
||||||
t.Fatalf("%d of 2 judge builds used the judge cache's Go cache:\n%s", n, said)
|
|
||||||
}
|
|
||||||
// Nothing left behind: no home under the judge cache, nothing in the host's temporary directory.
|
|
||||||
if left, _ := os.ReadDir(filepath.Join(cache, "homes")); len(left) != 0 {
|
|
||||||
t.Fatalf("judge builds left %d home(s) under the judge cache", len(left))
|
|
||||||
}
|
|
||||||
if left, _ := os.ReadDir(tmp); len(left) != 0 {
|
|
||||||
t.Fatalf("a check left %d entr(ies) in the temporary directory, e.g. %s", len(left), left[0].Name())
|
|
||||||
}
|
|
||||||
// Only a judge build mounts the judge cache: no container the check runs for the pull request does.
|
|
||||||
body, _ := os.ReadFile(runs)
|
|
||||||
judgeBuilds := 0
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
|
||||||
judging := strings.Contains(line, " go build ")
|
|
||||||
if judging {
|
|
||||||
judgeBuilds++
|
|
||||||
if !strings.Contains(line, "--volume "+filepath.Join(cache, "go-cache")+":") {
|
|
||||||
t.Fatalf("a judge build did not mount the judge cache's Go cache: %s", line)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(line, cache) {
|
|
||||||
t.Fatalf("a container the check runs for the pull request names the judge cache: %s", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if judgeBuilds != 2 {
|
|
||||||
t.Fatalf("%d judge builds were run, not 2", judgeBuilds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without a judge cache, a judge is built cold under the check's own root — never in a temporary directory of the
|
|
||||||
// host — and a judge cache inside the workspace is refused.
|
|
||||||
func TestWithoutAJudgeCacheAJudgeIsBuiltColdUnderTheChecksRoot(t *testing.T) {
|
|
||||||
built := aRuntimeThatBuildsTheJudge(t)
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("TMPDIR", tmp)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
|
||||||
beside := map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain}}
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
workspace := t.TempDir()
|
|
||||||
spec := CheckSpec{ID: "check-704-cold", Repository: catalogue, Ref: head, Owner: "novox", Repo: "mesh-catalog",
|
|
||||||
Number: 1, Base: "main", Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"},
|
|
||||||
Toolchain: "golang", Beside: beside}
|
|
||||||
v, err := Check(t.Context(), Command, spec, workspace, registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Verdict != "pass" {
|
|
||||||
t.Fatalf("the check was not judged: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
said, _ := os.ReadFile(built)
|
|
||||||
if !strings.Contains(string(said), "GOCACHE="+filepath.Join(workspace, "check", "judge-go-cache")+" GOENV=off go build") {
|
|
||||||
t.Fatalf("without a judge cache the judge was not built under the check's root:\n%s", said)
|
|
||||||
}
|
|
||||||
if left, _ := os.ReadDir(tmp); len(left) != 0 {
|
|
||||||
t.Fatalf("a check left %d entr(ies) in the temporary directory", len(left))
|
|
||||||
}
|
|
||||||
spec.JudgeCache = filepath.Join(workspace, "judge-cache")
|
|
||||||
if _, err := Check(t.Context(), Command, spec, workspace, registry, GitCredential{}, nil); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "inside the workspace") {
|
|
||||||
t.Fatalf("a judge cache inside the workspace was taken: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// aLabWithReplays is mesh-lab with replays: a Go module of its own whose one test passes, needing nothing fetched.
|
|
||||||
func aLabWithReplays(t *testing.T) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{
|
|
||||||
"replays/go.mod": "module example.org/replays\n\ngo 1.22\n",
|
|
||||||
"replays/replay_test.go": "package replays\n\nimport \"testing\"\n\nfunc TestAReplay(t *testing.T) {}\n",
|
|
||||||
}, nil)
|
|
||||||
return lab, labMain
|
|
||||||
}
|
|
||||||
|
|
||||||
// **mesh-lab's replays run where no pull request reaches** (novox/hq issue 709, ADR 0358 §6): they hold the container
|
|
||||||
// runtime's socket, so a Go environment file with a -toolexec hook and a Go cache and module cache planted in the
|
|
||||||
// workspace by a pull request's script are neither run nor read — the replays run from a copy of mesh-lab outside
|
|
||||||
// the workspace with a fresh home and the judge cache's caches, all removed or kept outside every check container.
|
|
||||||
func TestTheReplaysRunWhereNoPullRequestReaches(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
replays := filepath.Join(t.TempDir(), "replays")
|
|
||||||
t.Setenv("REPLAYS", replays)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
workspace, ran := aPlantedWorkspace(t)
|
|
||||||
for _, poisoned := range []string{filepath.Join(workspace, "go-cache", "README"),
|
|
||||||
filepath.Join(workspace, "go-modules", "cache", "download", "planted")} {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(poisoned), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(poisoned, []byte("planted by a pull request"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cache := t.TempDir()
|
|
||||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
|
||||||
lab, labMain := aLabWithReplays(t)
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-709", Repository: catalogue, Ref: head, Owner: "novox",
|
|
||||||
Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: cache, Paths: []string{"modules/gitea/index.ts"},
|
|
||||||
Modules: []string{"gitea"}, Toolchain: "golang", Beside: map[string]Beside{
|
|
||||||
"mesh-controller": {Repository: controller, Ref: controllerMain}, "mesh-lab": {Repository: lab, Ref: labMain}}},
|
|
||||||
workspace, registry, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Verdict != "pass" || !strings.Contains(v.Report, "--- the replays") {
|
|
||||||
t.Fatalf("the replays did not run and pass: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
body, _ := os.ReadFile(replays)
|
|
||||||
said := strings.TrimSpace(string(body))
|
|
||||||
if said == "" || strings.Count(said, "\n") != 0 {
|
|
||||||
t.Fatalf("the replays ran %q, not once", said)
|
|
||||||
}
|
|
||||||
for _, want := range []string{"GOCACHE=" + filepath.Join(cache, "go-cache") + " ",
|
|
||||||
"GOMODCACHE=" + filepath.Join(cache, "go-modules") + " ", "GOENV=off ", "GOFLAGS=-buildvcs=false ",
|
|
||||||
"HOME=" + filepath.Join(cache, "homes", "check-709-replays", "home") + " ",
|
|
||||||
"DIR=" + filepath.Join(cache, "homes", "check-709-replays", "mesh-lab", "replays")} {
|
|
||||||
if !strings.Contains(said, want) {
|
|
||||||
t.Fatalf("the replays did not run with %q: %s", want, said)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(said, workspace) {
|
|
||||||
t.Fatalf("the replays ran with something of the workspace: %s", said)
|
|
||||||
}
|
|
||||||
if body, _ := os.ReadFile(ran); strings.Contains(string(body), "replays") || strings.Contains(string(body), "mesh-lab") {
|
|
||||||
t.Fatalf("a hook planted in the workspace ran during the replays:\n%s", body)
|
|
||||||
}
|
|
||||||
if left, _ := os.ReadDir(filepath.Join(cache, "homes")); len(left) != 0 {
|
|
||||||
t.Fatalf("the replays left %d entr(ies) under the judge cache's homes", len(left))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// aSelfJudgingControllerThatWritesIntoTheReplays is a controller pull request whose own judge, while the gate runs it,
|
|
||||||
// writes a test into the replays beside the check — the reviewer's case for issue 709: code of the pull request's,
|
|
||||||
// run before the replays, reaching what they run.
|
|
||||||
func aSelfJudgingControllerThatWritesIntoTheReplays(t *testing.T, marker string) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
files := aJudgeOf("the change's controller")
|
|
||||||
evil := "package replays\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestEvil(t *testing.T) {\n\t_ = os.WriteFile(" +
|
|
||||||
fmt.Sprintf("%q", marker) + ", []byte(\"ran\"), 0o644)\n}\n"
|
|
||||||
files["cmd/mesh-controller/main.go"] = strings.Replace(files["cmd/mesh-controller/main.go"], "func main() {",
|
|
||||||
"func main() {\n\tif len(os.Args) > 2 && os.Args[1] == \"merge-gate\" {\n\t\t_ = os.WriteFile(os.Getenv(\"MESH_CHECK_BESIDE\")+"+
|
|
||||||
"\"/mesh-lab/replays/evil_test.go\", []byte("+fmt.Sprintf("%q", evil)+"), 0o644)\n\t\t_ = os.WriteFile("+
|
|
||||||
fmt.Sprintf("%q", marker+".wrote")+", nil, 0o644)\n\t}", 1)
|
|
||||||
return aPullRequestInto(t, files, "main", map[string]string{"cmd/x.txt": "x"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// **The replays never read what a pull request wrote** (issue 709, reviews 12303 and 12308): with a judge cache they
|
|
||||||
// run from a copy under it, and a self-judging controller pull request writing a test into the replays beside the
|
|
||||||
// check, a planted GOFLAGS hook and a poisoned cache entry in the workspace neither run nor are read. Without one the
|
|
||||||
// replays are refused — the gate an error that says why, never a pass, never the workspace — they never start, and
|
|
||||||
// nothing is made on the host.
|
|
||||||
func TestTheReplaysNeverReadWhatAPullRequestWroteAndNeedAJudgeCache(t *testing.T) {
|
|
||||||
for _, withCache := range []bool{true, false} {
|
|
||||||
t.Run(fmt.Sprintf("judge cache %v", withCache), func(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
replays, volumes := filepath.Join(t.TempDir(), "replays"), filepath.Join(t.TempDir(), "volumes")
|
|
||||||
t.Setenv("REPLAYS", replays)
|
|
||||||
t.Setenv("VOLUMES", volumes)
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("TMPDIR", tmp)
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
workspace, ran := aPlantedWorkspace(t)
|
|
||||||
if err := os.MkdirAll(filepath.Join(workspace, "go-cache"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(workspace, "go-cache", "README"), []byte("planted"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
evilRan := filepath.Join(t.TempDir(), "evil-ran")
|
|
||||||
controller, head := aSelfJudgingControllerThatWritesIntoTheReplays(t, evilRan)
|
|
||||||
lab, labMain := aLabWithReplays(t)
|
|
||||||
cache := ""
|
|
||||||
if withCache {
|
|
||||||
cache = t.TempDir()
|
|
||||||
}
|
|
||||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-709-self", Repository: controller, Ref: head,
|
|
||||||
Owner: "novox", Repo: "mesh-controller", Number: 330, Base: "main", Judge: "self", JudgeCache: cache,
|
|
||||||
Paths: []string{"cmd/x.txt"}, Modules: []string{"mesh-controller"}, Toolchain: "golang",
|
|
||||||
Beside: map[string]Beside{"mesh-lab": {Repository: lab, Ref: labMain}}}, workspace, registry,
|
|
||||||
GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(evilRan + ".wrote"); err != nil {
|
|
||||||
t.Fatalf("the self-judging pull request did not write into the replays beside it, so this proves nothing: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(evilRan); err == nil {
|
|
||||||
t.Fatal("the test the pull request wrote into the replays ran with the socket")
|
|
||||||
}
|
|
||||||
if body, _ := os.ReadFile(ran); strings.Contains(string(body), "replays") || strings.Contains(string(body), "mesh-lab") {
|
|
||||||
t.Fatalf("a hook planted in the workspace ran during the replays:\n%s", body)
|
|
||||||
}
|
|
||||||
body, _ := os.ReadFile(replays)
|
|
||||||
said := strings.TrimSpace(string(body))
|
|
||||||
if vols, _ := os.ReadFile(volumes); len(vols) != 0 {
|
|
||||||
t.Fatalf("the check made a container volume: %q", vols)
|
|
||||||
}
|
|
||||||
if left, _ := os.ReadDir(tmp); len(left) != 0 {
|
|
||||||
t.Fatalf("a check left %d entr(ies) in the temporary directory", len(left))
|
|
||||||
}
|
|
||||||
if !withCache {
|
|
||||||
if v.Gate == nil || v.Gate.Verdict != "error" || !strings.Contains(v.Gate.Summary, replaysNeedTheJudgeCache) ||
|
|
||||||
v.Verdict != "error" {
|
|
||||||
t.Fatalf("without a judge cache the gate said %+v, not an error saying the replays need it", v.Gate)
|
|
||||||
}
|
|
||||||
if said != "" {
|
|
||||||
t.Fatalf("without a judge cache the replays started: %s", said)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Verdict != "pass" || !strings.Contains(v.Report, "--- the replays") {
|
|
||||||
t.Fatalf("the replays did not run and pass: %+v\n%s", v.Gate, v.Report)
|
|
||||||
}
|
|
||||||
base := filepath.Join(cache, "homes", "check-709-self-replays")
|
|
||||||
if said == "" || strings.Contains(said, workspace) || !strings.Contains(said, "GOENV=off ") ||
|
|
||||||
!strings.Contains(said, "DIR="+filepath.Join(base, "mesh-lab", "replays")) {
|
|
||||||
t.Fatalf("the replays did not run from the copy under %s, apart from the workspace: %q", base, said)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(base); err == nil {
|
|
||||||
t.Fatalf("the replays' place %s was left after the check", base)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Replays the container runtime could not start are the check's error, never the change's failure**, said in the
|
|
||||||
// build's log (review 12308); and a check whose id cannot name a place in the judge cache is refused, never given a
|
|
||||||
// shared one.
|
|
||||||
func TestReplaysThatCannotStartAreAnErrorAndAnUnsafeIDIsRefused(t *testing.T) {
|
|
||||||
aRuntimeThatBuildsTheJudge(t)
|
|
||||||
t.Setenv("REPLAYS_CANNOT_START", "1")
|
|
||||||
registry := aFactsRegistry(t)
|
|
||||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
|
||||||
lab, labMain := aLabWithReplays(t)
|
|
||||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
|
||||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
|
||||||
spec := CheckSpec{ID: "check-709-start", Repository: catalogue, Ref: head, Owner: "novox", Repo: "mesh-catalog",
|
|
||||||
Number: 1, Base: "main", JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"},
|
|
||||||
Modules: []string{"gitea"}, Toolchain: "golang", Beside: map[string]Beside{
|
|
||||||
"mesh-controller": {Repository: controller, Ref: controllerMain}, "mesh-lab": {Repository: lab, Ref: labMain}}}
|
|
||||||
var logged []string
|
|
||||||
v, err := Check(t.Context(), Command, spec, t.TempDir(), registry, GitCredential{}, func(step, message string) {
|
|
||||||
logged = append(logged, message)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if v.Gate == nil || v.Gate.Verdict != "error" || !strings.Contains(v.Gate.Summary, "could not be started") ||
|
|
||||||
!strings.Contains(v.Gate.Summary, "invalid mount config") {
|
|
||||||
t.Fatalf("replays that could not start were said %+v, not an error naming why", v.Gate)
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.Join(logged, "\n"), "the replays could not be started") {
|
|
||||||
t.Fatalf("the build's log does not say what failed:\n%s", strings.Join(logged, "\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
spec.ID = "Check 1/../../x"
|
|
||||||
if _, err := Check(t.Context(), Command, spec, t.TempDir(), registry, GitCredential{}, nil); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "cannot name its place in the judge cache") {
|
|
||||||
t.Fatalf("a check with an unsafe id was taken: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq issue 713: after ADR 0350 the walk of novox/mesh-controller#331 built mesh-controller, build-agent and
|
|
||||||
// route-proxy again, each "built: no check of novox/mesh-controller#331 built it". The check had built none: its
|
|
||||||
// build log said "built for the merge: 0 of 3 module(s)", mesh-controller's go bundle failing with "go.mod file not
|
|
||||||
// found". The build seat's holder runs in a container sharing only its workspace with the host, and a build's
|
|
||||||
// toolchain container is run by the host's container engine, which mounts a path as the host names it. The check
|
|
||||||
// built in a temporary directory of the holder's own (issue 703's copy), which the engine mounted as an empty one.
|
|
||||||
|
|
||||||
// aHostsEngine runs as the host's container engine does for a holder in a container: a mount of a path outside the
|
|
||||||
// workspace the holder shares with the host is an empty directory, and a Go toolchain finds no go.mod in it.
|
|
||||||
type aHostsEngine struct {
|
|
||||||
onTrunk
|
|
||||||
shared string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h aHostsEngine) run(ctx context.Context, dir, name string, args ...string) (string, error) {
|
|
||||||
if name == "docker" && len(args) > 0 && args[0] == "run" {
|
|
||||||
for i, a := range args {
|
|
||||||
if a != "--volume" || i+1 >= len(args) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
from, _, _ := strings.Cut(args[i+1], ":")
|
|
||||||
if !withinDir(h.shared, from) {
|
|
||||||
return "", errors.New("go: go.mod file not found in current directory or any parent directory")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return h.onTrunk.run(ctx, dir, name, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// aGoModule is a module of one Go bundle built from a repository's root.
|
|
||||||
func aGoModule(module string) map[string]string {
|
|
||||||
files := aSharedRepository("one", "1")
|
|
||||||
files[ManifestName] = `{"module":"` + module + `","version":"1","build":{"artifacts":[
|
|
||||||
{"name":"server","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},
|
|
||||||
"resources":[{"id":"server","type":"process","name":"` + module + `","artifact":"server","run":["./ctl"]}]}`
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// A controller change whose walk moves mesh-controller, build-agent and route-proxy (#331's) has its check build all
|
|
||||||
// three, in the workspace the host's engine sees, and the walk of its merge takes each from the check by its key.
|
|
||||||
func TestReplay713TheWalkOfAControllerChangeTakesAllThreeFromItsCheck(t *testing.T) {
|
|
||||||
withVersion(t, "builder-1")
|
|
||||||
pull := PullRef{Owner: "novox", Repo: "mesh-controller", Number: 331}
|
|
||||||
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
|
|
||||||
repositories := map[string]string{"build-agent": "https://forge.invalid/mesh-catalog-build-agent.git",
|
|
||||||
"route-proxy": "https://forge.invalid/mesh-catalog-route-proxy.git"}
|
|
||||||
checked := func(shared string) (*recorded, aHostsEngine) {
|
|
||||||
r := &recorded{tree: "tree-of-the-merge", contents: aGoModule("mesh-controller"),
|
|
||||||
secondary: map[string]map[string]string{}}
|
|
||||||
for module, repository := range repositories {
|
|
||||||
r.secondary[repository] = aGoModule(module)
|
|
||||||
}
|
|
||||||
return r, aHostsEngine{onTrunk: onTrunk{recorded: r}, shared: shared}
|
|
||||||
}
|
|
||||||
spec := CheckSpec{ID: "build-331", Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number,
|
|
||||||
Builds: []CheckBuildSpec{{Module: "mesh-controller", Merged: true, Tier: 0},
|
|
||||||
{Module: "build-agent", Repository: repositories["build-agent"], Ref: "main", Tier: 1},
|
|
||||||
{Module: "route-proxy", Repository: repositories["route-proxy"], Ref: "main", Tier: 2}}}
|
|
||||||
judged := CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053", Merge: "2d22279b1d07"}
|
|
||||||
passed := CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "pass"}}
|
|
||||||
|
|
||||||
// As it ran: the builds made in the holder's own temporary directory, which the host's engine does not see.
|
|
||||||
shared, outside := t.TempDir(), t.TempDir()
|
|
||||||
merged := filepath.Join(outside, "merged")
|
|
||||||
r, engine := checked(shared)
|
|
||||||
asItRan := buildForTheMerge(t.Context(), engine.run, spec, CheckBuilding{Publish: r, Scratch: newScratch(), Held: held},
|
|
||||||
outside, merged, judged.Merge, judged, GitCredential{}, nil, logging(nil))
|
|
||||||
if builtCount(asItRan) != 0 {
|
|
||||||
t.Fatalf("the engine of this test sees the holder's temporary directory: %+v", asItRan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The check as it builds now: every module its merge moves, kept under its key.
|
|
||||||
scratch := newScratch()
|
|
||||||
spec.BuildWith = &CheckBuilding{Publish: r, Scratch: scratch, Held: held}
|
|
||||||
built := forTheMerge(t.Context(), engine.run, spec, passed, shared, merged, judged, GitCredential{}, nil,
|
|
||||||
logging(nil))
|
|
||||||
if len(built) != 3 || builtCount(built) != 3 {
|
|
||||||
t.Fatalf("the check built %d of %d module(s) its merge moves: %+v", builtCount(built), len(built), built)
|
|
||||||
}
|
|
||||||
var names []NamedBuild
|
|
||||||
for _, b := range built {
|
|
||||||
n := NamedBuild{Pull: pull, Check: spec.ID, Module: b.Module, Key: b.Key, Digests: map[string]string{}}
|
|
||||||
for _, m := range b.Made {
|
|
||||||
d := m.Digest
|
|
||||||
if d == "" {
|
|
||||||
d = DigestOf(m.Reference)
|
|
||||||
}
|
|
||||||
n.Digests[m.Name] = d
|
|
||||||
}
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The walk of the merge commit, tier by tier: each module taken from the check, none built.
|
|
||||||
walked := map[string]string{"mesh-controller": "https://forge.invalid/mesh-controller.git"}
|
|
||||||
for module, repository := range repositories {
|
|
||||||
walked[module] = repository
|
|
||||||
}
|
|
||||||
for _, module := range []string{"mesh-controller", "build-agent", "route-proxy"} {
|
|
||||||
w, walk := checked(shared)
|
|
||||||
w.secondary[walked["mesh-controller"]] = aGoModule("mesh-controller")
|
|
||||||
got, err := BuildReusing(t.Context(), walk.run, w, Reuse{Scratch: scratch, Pulls: []PullRef{pull}, Named: names},
|
|
||||||
walked[module], "", "main", t.TempDir(), held, Npmrc{}, GitCredential{}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", module, err)
|
|
||||||
}
|
|
||||||
if got.Reused == nil || got.Why != "" {
|
|
||||||
t.Fatalf("the walk built %s again: %s", module, got.Why)
|
|
||||||
}
|
|
||||||
for _, line := range w.ran {
|
|
||||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "go build") {
|
|
||||||
t.Fatalf("the walk compiled %s: %s", module, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq issue 716: a check built the images its merge moves one after another, and only after its merge-check.sh
|
|
||||||
// had passed — 3 to 6 minutes on a check of about 5. The modules of a tier stand on none of each other and are built
|
|
||||||
// at the same time; with a judge cache, beside the tests; a check that does not pass lets go of them.
|
|
||||||
|
|
||||||
// atOnce is a fake engine whose compiles wait until every one of a tier's has started: builds one after another never
|
|
||||||
// get there, and the first one's compile says so.
|
|
||||||
type atOnce struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
r *recorded
|
|
||||||
want int
|
|
||||||
started int
|
|
||||||
all chan struct{}
|
|
||||||
shared string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atOnce) run(ctx context.Context, dir, name string, args ...string) (string, error) {
|
|
||||||
if name == "docker" && len(args) > 0 && args[0] == "run" && strings.Contains(strings.Join(args, " "), "go build") {
|
|
||||||
a.mu.Lock()
|
|
||||||
a.started++
|
|
||||||
if a.started == a.want {
|
|
||||||
close(a.all)
|
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
|
||||||
select {
|
|
||||||
case <-a.all:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
return "", errors.New("built alone: no other module of its tier was being built")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
return onTrunk{recorded: a.r}.run(ctx, dir, name, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atOnce) PublishImage(ctx context.Context, localTag, repository string) (string, error) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
return a.r.PublishImage(ctx, localTag, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *atOnce) PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) {
|
|
||||||
a.mu.Lock()
|
|
||||||
defer a.mu.Unlock()
|
|
||||||
return a.r.PublishArchive(ctx, repository, body, digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// aLockedScratch is the scratch namespace in memory, said to by builds at once.
|
|
||||||
type aLockedScratch struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
*aScratch
|
|
||||||
discarded []CheckBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *aLockedScratch) Keep(ctx context.Context, p PullRef, c string, at time.Time, b []CheckBuild) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.aScratch.Keep(ctx, p, c, at, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *aLockedScratch) Find(ctx context.Context, module, key string) (CheckBuild, bool, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.aScratch.Find(ctx, module, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *aLockedScratch) Discard(_ context.Context, b []CheckBuild) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.discarded = append(s.discarded, b...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// threeAtOnce are three Go modules in one tier, each from its own repository.
|
|
||||||
func threeAtOnce(t *testing.T) (*atOnce, CheckSpec, map[string]string) {
|
|
||||||
t.Helper()
|
|
||||||
r := &recorded{tree: "tree-of-the-merge", contents: aGoModule("mesh-controller"),
|
|
||||||
secondary: map[string]map[string]string{}}
|
|
||||||
spec := CheckSpec{ID: "build-716", Owner: "novox", Repo: "mesh-controller", Number: 716,
|
|
||||||
Builds: []CheckBuildSpec{{Module: "mesh-controller", Merged: true, Tier: 0}}}
|
|
||||||
for _, module := range []string{"build-agent", "route-proxy"} {
|
|
||||||
repository := "https://forge.invalid/" + module + ".git"
|
|
||||||
r.secondary[repository] = aGoModule(module)
|
|
||||||
spec.Builds = append(spec.Builds, CheckBuildSpec{Module: module, Repository: repository, Ref: "main", Tier: 0})
|
|
||||||
}
|
|
||||||
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
|
|
||||||
return &atOnce{r: r, want: 3, all: make(chan struct{})}, spec, held
|
|
||||||
}
|
|
||||||
|
|
||||||
// The modules of one tier are built at the same time, each in a working copy of its own.
|
|
||||||
func TestReplay716TheModulesOfATierAreBuiltAtTheSameTime(t *testing.T) {
|
|
||||||
withVersion(t, "builder-1")
|
|
||||||
engine, spec, held := threeAtOnce(t)
|
|
||||||
scratch := &aLockedScratch{aScratch: newScratch()}
|
|
||||||
built := buildForTheMerge(t.Context(), engine.run, spec, CheckBuilding{Publish: engine, Scratch: scratch, Held: held},
|
|
||||||
t.TempDir(), filepath.Join(t.TempDir(), "merged"), "2d22279b1d07",
|
|
||||||
CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053"}, GitCredential{}, nil, logging(nil))
|
|
||||||
if builtCount(built) != 3 {
|
|
||||||
t.Fatalf("the check built %d of 3 module(s) of one tier: %+v", builtCount(built), built)
|
|
||||||
}
|
|
||||||
if len(scratch.builds) != 3 {
|
|
||||||
t.Fatalf("the check kept %d build(s), not 3", len(scratch.builds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// With a judge cache, the builds run while the tests run, where no container of the check reaches; a check that passed
|
|
||||||
// keeps them, and one that failed lets go of every one and names none.
|
|
||||||
func TestReplay716TheBuildsRunBesideTheTestsAndAFailedCheckLetsGoOfThem(t *testing.T) {
|
|
||||||
withVersion(t, "builder-1")
|
|
||||||
workspace := t.TempDir()
|
|
||||||
if _, err := buildsBesideTheTests(filepath.Join(workspace, "judge-cache"), workspace, "build-716"); err == nil {
|
|
||||||
t.Fatal("a judge cache inside the workspace was built in, where a pull request's script reaches")
|
|
||||||
}
|
|
||||||
for _, passed := range []bool{true, false} {
|
|
||||||
cache := t.TempDir()
|
|
||||||
place, err := buildsBesideTheTests(cache, workspace, "build-716")
|
|
||||||
if err != nil || !withinDir(cache, place) || withinDir(workspace, place) {
|
|
||||||
t.Fatalf("the builds' place %q (%v) is not the judge cache's, outside the workspace", place, err)
|
|
||||||
}
|
|
||||||
engine, spec, held := threeAtOnce(t)
|
|
||||||
scratch := &aLockedScratch{aScratch: newScratch()}
|
|
||||||
spec.BuildWith = &CheckBuilding{Publish: engine, Scratch: scratch, Held: held}
|
|
||||||
judged := CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053", Merge: "2d22279b1d07"}
|
|
||||||
beside := startBesideTheTests(t.Context(), engine.run, spec, place, filepath.Join(t.TempDir(), "merged"),
|
|
||||||
judged, GitCredential{}, nil, logging(nil))
|
|
||||||
// The tests: the builds are under way before the check has a verdict.
|
|
||||||
select {
|
|
||||||
case <-engine.all:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("nothing was built while the tests ran")
|
|
||||||
}
|
|
||||||
verdict := CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "pass"}}
|
|
||||||
if !passed {
|
|
||||||
verdict.Repo.Verdict = "fail"
|
|
||||||
}
|
|
||||||
built := beside.settle(t.Context(), verdict)
|
|
||||||
beside.abandon()
|
|
||||||
switch {
|
|
||||||
case passed && (builtCount(built) != 3 || len(scratch.builds) != 3 || len(scratch.discarded) != 0):
|
|
||||||
t.Fatalf("a check that passed: built %+v, kept %d, let go of %d", built, len(scratch.builds),
|
|
||||||
len(scratch.discarded))
|
|
||||||
case !passed && (built != nil || len(scratch.builds) != 0 || len(scratch.discarded) != 3):
|
|
||||||
t.Fatalf("a check that failed: named %+v, kept %d, let go of %d", built, len(scratch.builds),
|
|
||||||
len(scratch.discarded))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -494,15 +494,11 @@ func (r Registry) Discard(ctx context.Context, builds []CheckBuild) error {
|
|||||||
func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex) (int, error) {
|
func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex) (int, error) {
|
||||||
store := r.store()
|
store := r.store()
|
||||||
stays := map[string]bool{}
|
stays := map[string]bool{}
|
||||||
// recorded are the records under a key another check still names (novox/hq issue 703): a later check that kept
|
|
||||||
// the build names it as the check's that built it, and the walk finds it by that record.
|
|
||||||
recorded := map[string]bool{}
|
|
||||||
for tag, c := range all {
|
for tag, c := range all {
|
||||||
if _, going := checks[tag]; going {
|
if _, going := checks[tag]; going {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, b := range c.Builds {
|
for _, b := range c.Builds {
|
||||||
recorded[b.Module+" "+b.Key+" "+b.Check] = true
|
|
||||||
for _, m := range b.Made {
|
for _, m := range b.Made {
|
||||||
stays[catalogue.Recorded(m.Reference)] = true
|
stays[catalogue.Recorded(m.Reference)] = true
|
||||||
}
|
}
|
||||||
@@ -527,10 +523,8 @@ func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The record under the key, only while it is still this check's: a later check of the same key
|
// The record under the key, only while it is still this check's: a later check of the same key
|
||||||
// replaced it, and that one is its own check's to let go of. A build a check kept is that build's
|
// replaced it, and that one is its own check's to let go of.
|
||||||
// check's record, and goes with the last check naming it (novox/hq issue 703).
|
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found && kept.Check == c.Check {
|
||||||
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found &&
|
|
||||||
(kept.Check == c.Check || kept.Check == b.Check) && !recorded[b.Module+" "+b.Key+" "+kept.Check] {
|
|
||||||
if err := store.DeleteTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key)); err != nil && !errors.Is(err, artifacts.Gone) {
|
if err := store.DeleteTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key)); err != nil && !errors.Is(err, artifacts.Gone) {
|
||||||
ok = false
|
ok = false
|
||||||
failed = append(failed, fmt.Sprintf("%s's record: %v", b.Module, err))
|
failed = append(failed, fmt.Sprintf("%s's record: %v", b.Module, err))
|
||||||
@@ -616,8 +610,6 @@ func unnamed(named []NamedBuild, module, key string, found CheckBuild) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A build of another pull request, or of a head the forge did not merge, is in no verdict the walk is handed
|
return "a build is kept under this key, and no check verdict of the walk's pull requests names it with its " +
|
||||||
// (novox/hq issue 703): said with the check that made it, whose head names which head it was.
|
"digests: not taken"
|
||||||
return "a build is kept under this key, by " + found.Said() + ", and no check verdict of the walk's pull requests " +
|
|
||||||
"at the head the forge merged names it with its digests: not taken"
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-155
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -74,8 +73,14 @@ const reusedModule = `{"module":"meshboard","version":"1",
|
|||||||
// aModuleAt is a repository holding the module under mod/, whose directory git names as tree, on its trunk.
|
// aModuleAt is a repository holding the module under mod/, whose directory git names as tree, on its trunk.
|
||||||
func aModuleAt(t *testing.T, tree string) (*recorded, Runner, string) {
|
func aModuleAt(t *testing.T, tree string) (*recorded, Runner, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r := &recorded{tree: tree, contents: map[string]string{"mod/" + ManifestName: reusedModule,
|
return aModuleWith(t, tree, map[string]string{"mod/" + ManifestName: reusedModule,
|
||||||
"mod/Dockerfile": "FROM scratch", "mod/files/theme.conf": "dark"}}
|
"mod/Dockerfile": "FROM scratch", "mod/files/theme.conf": "dark"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// aModuleWith is aModuleAt with the repository's files given.
|
||||||
|
func aModuleWith(t *testing.T, tree string, contents map[string]string) (*recorded, Runner, string) {
|
||||||
|
t.Helper()
|
||||||
|
r := &recorded{tree: tree, contents: contents}
|
||||||
run := func(ctx context.Context, dir, name string, args ...string) (string, error) {
|
run := func(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||||
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
|
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
|
||||||
r.ran = append(r.ran, name+" "+strings.Join(args, " "))
|
r.ran = append(r.ran, name+" "+strings.Join(args, " "))
|
||||||
@@ -101,6 +106,12 @@ var aPull = PullRef{Owner: "novox", Repo: "meshboard", Number: 7}
|
|||||||
func aCheckBuilt(t *testing.T, scratch *aScratch, tree string) CheckBuilt {
|
func aCheckBuilt(t *testing.T, scratch *aScratch, tree string) CheckBuilt {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
r, run, workspace := aModuleAt(t, tree)
|
r, run, workspace := aModuleAt(t, tree)
|
||||||
|
return aCheckBuiltBy(t, scratch, r, run, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aCheckBuiltBy is aCheckBuilt of the repository given.
|
||||||
|
func aCheckBuiltBy(t *testing.T, scratch *aScratch, r *recorded, run Runner, workspace string) CheckBuilt {
|
||||||
|
t.Helper()
|
||||||
spec := CheckSpec{ID: "build-1", Owner: aPull.Owner, Repo: aPull.Repo, Number: aPull.Number,
|
spec := CheckSpec{ID: "build-1", Owner: aPull.Owner, Repo: aPull.Repo, Number: aPull.Number,
|
||||||
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
|
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
|
||||||
Ref: "main"}}}
|
Ref: "main"}}}
|
||||||
@@ -216,8 +227,7 @@ func TestARecordNoVerdictNamesIsRefusedAndBuilt(t *testing.T) {
|
|||||||
if got.Reused != nil || len(scratch.took) > 0 || !dockerBuilt(r) {
|
if got.Reused != nil || len(scratch.took) > 0 || !dockerBuilt(r) {
|
||||||
t.Fatalf("a record no verdict names was taken: %+v, copied %v", got.Reused, scratch.took)
|
t.Fatalf("a record no verdict names was taken: %+v, copied %v", got.Reused, scratch.took)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests at the head the forge merged names it") ||
|
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests names it") {
|
||||||
!strings.Contains(got.Why, "at head abcdefabcdef") {
|
|
||||||
t.Fatalf("the walk says %q", got.Why)
|
t.Fatalf("the walk says %q", got.Why)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -627,153 +637,3 @@ func TestABuildTwoChecksShareGoesWithTheLastOfThem(t *testing.T) {
|
|||||||
t.Fatal("the build stayed after the last check naming it went")
|
t.Fatal("the build stayed after the last check naming it went")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// aCheckAgain is a later check (build-2) of aPull building the module from a tree, with the builds the build seat's
|
|
||||||
// verdicts name for it (novox/hq issue 703).
|
|
||||||
func aCheckAgain(t *testing.T, scratch *aScratch, tree string, leave []NamedBuild) (CheckBuilt, *recorded) {
|
|
||||||
t.Helper()
|
|
||||||
return aCheckOf(t, scratch, aPull, "abcdefabcdef", tree, leave)
|
|
||||||
}
|
|
||||||
|
|
||||||
// aCheckOf is a later check (build-2) of a pull request at a head, the trunk moved since build-1.
|
|
||||||
func aCheckOf(t *testing.T, scratch *aScratch, pull PullRef, head, tree string, leave []NamedBuild) (CheckBuilt, *recorded) {
|
|
||||||
t.Helper()
|
|
||||||
r, run, workspace := aModuleAt(t, tree)
|
|
||||||
spec := CheckSpec{ID: "build-2", Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number,
|
|
||||||
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
|
|
||||||
Ref: "main"}}}
|
|
||||||
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: scratch, Named: leave},
|
|
||||||
workspace, "", "", CheckVerdict{Head: head, Trunk: "654321654321"}, GitCredential{}, nil, logging(nil))
|
|
||||||
if len(built) != 1 || built[0].NotBuilt != "" || built[0].Key == "" {
|
|
||||||
t.Fatalf("the second check did not answer the module for its merge: %+v", built)
|
|
||||||
}
|
|
||||||
return built[0], r
|
|
||||||
}
|
|
||||||
|
|
||||||
// A check asked again when only the trunk moved, whose key is unchanged, builds nothing: it keeps the build an
|
|
||||||
// earlier verdict names and names it by key and digest, so the walk can take it (novox/hq issue 703).
|
|
||||||
func TestASecondCheckWhoseKeyIsUnchangedBuildsNothingAndNamesTheEarlierBuild(t *testing.T) {
|
|
||||||
withVersion(t, "builder-1")
|
|
||||||
scratch := newScratch()
|
|
||||||
first := aCheckBuilt(t, scratch, "tree-one")
|
|
||||||
|
|
||||||
again, r := aCheckAgain(t, scratch, "tree-one", named(first))
|
|
||||||
if dockerBuilt(r) || len(r.images) > 0 || len(r.archives) > 0 {
|
|
||||||
t.Fatalf("the second check built again: ran %v, published %v %v", r.ran, r.images, r.archives)
|
|
||||||
}
|
|
||||||
if again.Key != first.Key || again.Kept != "build-1" || !reflect.DeepEqual(again.Made, first.Made) {
|
|
||||||
t.Fatalf("the second check names %+v, not the first check's build %+v", again, first)
|
|
||||||
}
|
|
||||||
// The walk takes it by what the second verdict names.
|
|
||||||
leave := named(again)
|
|
||||||
leave[0].Check = again.Kept
|
|
||||||
w, run, workspace := aModuleAt(t, "tree-one")
|
|
||||||
got, err := BuildReusing(t.Context(), run, w, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: leave},
|
|
||||||
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
|
||||||
if err != nil || got.Reused == nil || dockerBuilt(w) {
|
|
||||||
t.Fatalf("the walk did not take the kept build: %v, why %q", err, got.Why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A check whose key changed builds, as before.
|
|
||||||
func TestASecondCheckWhoseKeyChangedBuilds(t *testing.T) {
|
|
||||||
withVersion(t, "builder-1")
|
|
||||||
scratch := newScratch()
|
|
||||||
first := aCheckBuilt(t, scratch, "tree-one")
|
|
||||||
|
|
||||||
again, r := aCheckAgain(t, scratch, "tree-two", named(first))
|
|
||||||
if !dockerBuilt(r) || again.Kept != "" || again.Key == first.Key {
|
|
||||||
t.Fatalf("a check whose key changed did not build: %+v", again)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A scratch record no verdict names is never kept: the store takes writes from anything on its network, so a
|
|
||||||
// check builds rather than name a planted record (novox/hq issue 703, ADR 0350 §2).
|
|
||||||
func TestASecondCheckKeepsNoRecordAVerdictDoesNotName(t *testing.T) {
|
|
||||||
withVersion(t, "builder-1")
|
|
||||||
scratch := newScratch()
|
|
||||||
first := aCheckBuilt(t, scratch, "tree-one")
|
|
||||||
for name, leave := range map[string][]NamedBuild{
|
|
||||||
"no verdict": nil,
|
|
||||||
"another digest": func() []NamedBuild {
|
|
||||||
n := named(first)
|
|
||||||
n[0].Digests["server"] = "sha256:" + strings.Repeat("e", 64)
|
|
||||||
return n
|
|
||||||
}(),
|
|
||||||
"another key": func() []NamedBuild { n := named(first); n[0].Key = "r1:" + strings.Repeat("f", 64); return n }(),
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
again, r := aCheckAgain(t, scratch, "tree-one", leave)
|
|
||||||
if !dockerBuilt(r) || again.Kept != "" {
|
|
||||||
t.Fatalf("a record no verdict names was kept: %+v", again)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build a later check kept stays when the check that built it goes: its artifacts and its record, which the later
|
|
||||||
// check's verdict names (novox/hq issue 703, ADR 0350 §5).
|
|
||||||
func TestABuildALaterCheckKeptStaysWhenTheCheckThatBuiltItGoes(t *testing.T) {
|
|
||||||
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
|
|
||||||
server := httptest.NewServer(m.serve())
|
|
||||||
defer server.Close()
|
|
||||||
address := strings.TrimPrefix(server.URL, "http://")
|
|
||||||
registry := Registry{Address: address}
|
|
||||||
ctx := t.Context()
|
|
||||||
now := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
body := `{"mediaType":"` + mediaManifestOCI + `","layers":[]}`
|
|
||||||
digest := digestOf([]byte(body))
|
|
||||||
m.manifests["scratch/meshboard/server@"+digest] = []byte(body)
|
|
||||||
key := "r1:" + strings.Repeat("1", 64)
|
|
||||||
b := CheckBuild{Key: key, Module: "meshboard", Pull: aPull, Check: "build-1", At: now,
|
|
||||||
Made: []catalogue.Built{{Name: "server", Kind: "image", Reference: address + "/scratch/meshboard/server@" + digest}}}
|
|
||||||
if err := registry.Keep(ctx, aPull, "build-1", now, []CheckBuild{b}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Another pull request's check keeps it: its own document names the build, which stays the first check's.
|
|
||||||
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
|
|
||||||
if err := registry.Keep(ctx, other, "build-2", now, []CheckBuild{b}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
held := func() bool { _, ok := m.manifests["scratch/meshboard/server@"+digest]; return ok }
|
|
||||||
found := func() bool { _, ok, _ := registry.Find(ctx, "meshboard", key); return ok }
|
|
||||||
|
|
||||||
if n, err := registry.Forget(ctx, aPull); err != nil || n != 1 {
|
|
||||||
t.Fatalf("forgot %d (%v)", n, err)
|
|
||||||
}
|
|
||||||
if !held() || !found() {
|
|
||||||
t.Fatalf("the build a later check names went with the check that built it: held %v, record %v", held(), found())
|
|
||||||
}
|
|
||||||
if n, err := registry.Forget(ctx, other); err != nil || n != 1 {
|
|
||||||
t.Fatalf("forgot %d (%v)", n, err)
|
|
||||||
}
|
|
||||||
if held() || found() {
|
|
||||||
t.Fatalf("the build stayed after the last check naming it went: held %v, record %v", held(), found())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only an earlier check of the same pull request at the same head is kept from: another head of it, or another pull
|
|
||||||
// request whose verdict names the same key and digests, builds (novox/hq issue 703).
|
|
||||||
func TestACheckKeepsNoBuildOfAnotherHeadOrPullRequest(t *testing.T) {
|
|
||||||
withVersion(t, "builder-1")
|
|
||||||
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
|
|
||||||
for name, c := range map[string]struct {
|
|
||||||
pull PullRef
|
|
||||||
head string
|
|
||||||
}{
|
|
||||||
"another head": {aPull, "999999999999"},
|
|
||||||
"another pull request": {other, "abcdefabcdef"},
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
scratch := newScratch()
|
|
||||||
first := aCheckBuilt(t, scratch, "tree-one")
|
|
||||||
leave := named(first)
|
|
||||||
leave[0].Pull = c.pull // as if its own verdict named it
|
|
||||||
again, r := aCheckOf(t, scratch, c.pull, c.head, "tree-one", leave)
|
|
||||||
if !dockerBuilt(r) || again.Kept != "" {
|
|
||||||
t.Fatalf("a build of %s was kept: %+v", name, again)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module moves only when its program changes (novox/hq issue 721). A merge check builds every module the delivery
|
|
||||||
// plan moves; a module whose every artifact comes out the same as the build the mesh holds of it — the same files,
|
|
||||||
// the same modes and owners, the same entrypoint and environment — is said unchanged, and the walk leaves it where it
|
|
||||||
// is. **The whole image is compared, never its main binary alone**: a module whose image differs in any file moves.
|
|
||||||
// What is ignored is only what a rebuild cannot help changing: each file's time, the image's labels and its creation
|
|
||||||
// time. **A comparison that cannot be made says nothing**, and the module is built and moved as before.
|
|
||||||
|
|
||||||
// sameAsHeld says whether every artifact made is the same as the build the mesh holds of the module: the sentence
|
|
||||||
// that says so and, per artifact, the held reference it was compared with. Empty when any artifact differs, is not
|
|
||||||
// an image, has no held build, or could not be compared.
|
|
||||||
func sameAsHeld(ctx context.Context, run Runner, dir, module string, made []catalogue.Built,
|
|
||||||
held map[string]string) (string, map[string]string, error) {
|
|
||||||
if len(made) == 0 {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
against := map[string]string{}
|
|
||||||
for _, m := range made {
|
|
||||||
if m.Kind != "image" || m.Reference == "" {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
was := held[module+"/"+m.Name]
|
|
||||||
if was == "" {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
if digestPart(was) != "" && digestPart(was) == digestPart(m.Reference) {
|
|
||||||
against[m.Name] = was
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
now, err := imageContent(ctx, run, dir, m.Reference)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, fmt.Errorf("%s's new image: %w", m.Name, err)
|
|
||||||
}
|
|
||||||
before, err := imageContent(ctx, run, dir, was)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, fmt.Errorf("%s's held image: %w", m.Name, err)
|
|
||||||
}
|
|
||||||
if now != before {
|
|
||||||
return "", nil, nil
|
|
||||||
}
|
|
||||||
against[m.Name] = was
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for name, ref := range against {
|
|
||||||
names = append(names, name+" "+shortRef(ref))
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return "unchanged: its program is the same as running build " + strings.Join(names, ", "), against, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// digestPart is a reference's "sha256:…", empty when it is not pinned.
|
|
||||||
func digestPart(ref string) string {
|
|
||||||
if i := strings.LastIndex(ref, "@"); i >= 0 {
|
|
||||||
return ref[i+1:]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// shortRef names a reference by its digest's first twelve characters when it has one.
|
|
||||||
func shortRef(ref string) string {
|
|
||||||
if d := strings.TrimPrefix(digestPart(ref), "sha256:"); d != "" {
|
|
||||||
return twelve(d)
|
|
||||||
}
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageContent is an image's content as one digest: its platform, its configuration without labels, and every file
|
|
||||||
// of its filesystem by path, type, mode, owner, link, extended attributes and bytes — never by time.
|
|
||||||
func imageContent(ctx context.Context, run Runner, dir, ref string) (string, error) {
|
|
||||||
if _, err := run(ctx, dir, "docker", "pull", "--quiet", ref); err != nil {
|
|
||||||
return "", fmt.Errorf("pulling %s: %w", ref, err)
|
|
||||||
}
|
|
||||||
config, err := run(ctx, dir, "docker", "image", "inspect", "--format",
|
|
||||||
"{{.Os}}/{{.Architecture}}/{{.Variant}} {{json .Config}}", ref)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("reading %s's configuration: %w", ref, err)
|
|
||||||
}
|
|
||||||
said, err := configContent(config)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("reading %s's configuration: %w", ref, err)
|
|
||||||
}
|
|
||||||
id, err := run(ctx, dir, "docker", "create", "--entrypoint", "/", ref)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("opening %s's filesystem: %w", ref, err)
|
|
||||||
}
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if id == "" {
|
|
||||||
return "", errors.New("the container engine named no container for " + ref)
|
|
||||||
}
|
|
||||||
defer func() { _, _ = run(context.WithoutCancel(ctx), dir, "docker", "rm", "--force", id) }()
|
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
file := filepath.Join(dir, "content-"+twelve(id)+".tar")
|
|
||||||
defer os.Remove(file)
|
|
||||||
if _, err := run(ctx, dir, "docker", "export", "--output", file, id); err != nil {
|
|
||||||
return "", fmt.Errorf("reading %s's filesystem: %w", ref, err)
|
|
||||||
}
|
|
||||||
f, err := os.Open(file)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
files, err := treeContent(f)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("reading %s's filesystem: %w", ref, err)
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(said + "\n" + files))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// configContent is an image's platform and configuration as compared: Labels, the parent's id and the hostname the
|
|
||||||
// builder ran under are left out — the first holds the commit, the others no part of what runs.
|
|
||||||
func configContent(inspected string) (string, error) {
|
|
||||||
platform, raw, ok := strings.Cut(strings.TrimSpace(inspected), " ")
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("unexpected inspection %q", inspected)
|
|
||||||
}
|
|
||||||
var config map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for _, k := range []string{"Labels", "Image", "Hostname", "Domainname"} {
|
|
||||||
delete(config, k)
|
|
||||||
}
|
|
||||||
out, err := json.Marshal(config) // map keys are sorted
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return platform + " " + string(out), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// treeContent is a filesystem tarball's entries, sorted by path, each with everything but its times.
|
|
||||||
func treeContent(r io.Reader) (string, error) {
|
|
||||||
tr := tar.NewReader(r)
|
|
||||||
var lines []string
|
|
||||||
for {
|
|
||||||
h, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sum := sha256.New()
|
|
||||||
if _, err := io.Copy(sum, tr); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var xattrs []string
|
|
||||||
for k, v := range h.PAXRecords {
|
|
||||||
if strings.HasPrefix(k, "SCHILY.xattr.") {
|
|
||||||
xattrs = append(xattrs, k+"="+hex.EncodeToString([]byte(v)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(xattrs)
|
|
||||||
lines = append(lines, fmt.Sprintf("%q %c %o %d:%d %q %d,%d %d %x %s",
|
|
||||||
strings.TrimPrefix(h.Name, "./"), h.Typeflag, h.Mode, h.Uid, h.Gid, h.Linkname, h.Devmajor, h.Devminor,
|
|
||||||
h.Size, sum.Sum(nil), strings.Join(xattrs, ";")))
|
|
||||||
}
|
|
||||||
sort.Strings(lines)
|
|
||||||
return strings.Join(lines, "\n"), nil
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// aFilesystem is a filesystem tarball of files, each written at the time given.
|
|
||||||
func aFilesystem(t *testing.T, at time.Time, files map[string]string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
tw := tar.NewWriter(&buf)
|
|
||||||
for _, name := range []string{"usr/local/bin/mesh-builder", "etc/apk/world"} {
|
|
||||||
body, ok := files[name]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), ModTime: at,
|
|
||||||
Typeflag: tar.TypeReg}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := tw.Write([]byte(body)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tw.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return buf.Bytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// anEngine answers the container engine's commands for images by reference: each image's filesystem and labels.
|
|
||||||
func anEngine(images map[string][]byte, labels map[string]string, failing string) Runner {
|
|
||||||
created := map[string]string{}
|
|
||||||
return func(_ context.Context, _ string, name string, args ...string) (string, error) {
|
|
||||||
joined := strings.Join(args, " ")
|
|
||||||
ref := args[len(args)-1]
|
|
||||||
if failing != "" && strings.Contains(joined, failing) {
|
|
||||||
return "", fmt.Errorf("the engine refused %s", joined)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case args[0] == "pull", args[0] == "rm":
|
|
||||||
return "", nil
|
|
||||||
case args[0] == "image":
|
|
||||||
return fmt.Sprintf(`linux/amd64/ {"Entrypoint":["/usr/local/bin/mesh-builder"],"Labels":{"commit":%q}}`,
|
|
||||||
labels[ref]), nil
|
|
||||||
case args[0] == "create":
|
|
||||||
id := fmt.Sprintf("container%04d", len(created))
|
|
||||||
created[id] = ref
|
|
||||||
return id + "\n", nil
|
|
||||||
case args[0] == "export":
|
|
||||||
return "", os.WriteFile(args[2], images[created[ref]], 0o600)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("unexpected %s %s", name, joined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAModuleWhoseProgramDidNotChangeIsSaidUnchanged is novox/hq issue 721: mesh-controller #335 changed one sentence
|
|
||||||
// in a package mesh-builder imports and never reaches, and build-agent moved. Built again, its image differs only in
|
|
||||||
// the files' times and its commit label, and the check says it is the same as the build the mesh holds.
|
|
||||||
func TestAModuleWhoseProgramDidNotChangeIsSaidUnchanged(t *testing.T) {
|
|
||||||
held := map[string]string{"build-agent/agent": "store/build-agent@sha256:aaaa"}
|
|
||||||
made := []catalogue.Built{{Name: "agent", Kind: "image", Reference: "scratch/build-agent@sha256:bbbb"}}
|
|
||||||
files := map[string]string{"usr/local/bin/mesh-builder": "the same program", "etc/apk/world": "docker-cli\ngit\n"}
|
|
||||||
images := map[string][]byte{
|
|
||||||
"store/build-agent@sha256:aaaa": aFilesystem(t, time.Unix(1000, 0), files),
|
|
||||||
"scratch/build-agent@sha256:bbbb": aFilesystem(t, time.Unix(2000, 0), files),
|
|
||||||
}
|
|
||||||
labels := map[string]string{"store/build-agent@sha256:aaaa": "a5500f73", "scratch/build-agent@sha256:bbbb": "c2ec0a60"}
|
|
||||||
same, against, err := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent",
|
|
||||||
made, held)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(same, "unchanged: its program is the same as running build agent") ||
|
|
||||||
against["agent"] != held["build-agent/agent"] {
|
|
||||||
t.Fatalf("an image the same but for its times and labels is not said unchanged: %q %v", same, against)
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Any file differing moves it**, not only its program.
|
|
||||||
changed := map[string]string{"usr/local/bin/mesh-builder": "the same program", "etc/apk/world": "docker-cli\ngit\ncurl\n"}
|
|
||||||
images["scratch/build-agent@sha256:bbbb"] = aFilesystem(t, time.Unix(2000, 0), changed)
|
|
||||||
if same, _, err := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent", made,
|
|
||||||
held); err != nil || same != "" {
|
|
||||||
t.Fatalf("an image with another file is said unchanged: %q %v", same, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A comparison that fails says nothing**, and the module moves.
|
|
||||||
images["scratch/build-agent@sha256:bbbb"] = aFilesystem(t, time.Unix(2000, 0), files)
|
|
||||||
if same, _, err := sameAsHeld(context.Background(), anEngine(images, labels, "export"), t.TempDir(), "build-agent",
|
|
||||||
made, held); err == nil || same != "" {
|
|
||||||
t.Fatalf("a comparison that failed said %q, %v", same, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No held build, or an artifact that is not an image: nothing compared.
|
|
||||||
if same, _, _ := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent", made,
|
|
||||||
nil); same != "" {
|
|
||||||
t.Fatalf("a module the mesh holds no build of is said unchanged: %q", same)
|
|
||||||
}
|
|
||||||
archive := []catalogue.Built{{Name: "agent", Kind: "archive", Reference: "scratch/build-agent@sha256:bbbb"}}
|
|
||||||
if same, _, _ := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent",
|
|
||||||
archive, held); same != "" {
|
|
||||||
t.Fatalf("an archive is said unchanged without being compared: %q", same)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAnImageWithAnotherEntrypointIsNotTheSame: the configuration counts, but for its labels.
|
|
||||||
func TestAnImageWithAnotherEntrypointIsNotTheSame(t *testing.T) {
|
|
||||||
a, err := configContent(`linux/amd64/ {"Entrypoint":["/a"],"Labels":{"commit":"1"}}`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
b, _ := configContent(`linux/amd64/ {"Labels":{"commit":"2"},"Entrypoint":["/a"]}`)
|
|
||||||
c, _ := configContent(`linux/amd64/ {"Entrypoint":["/b"],"Labels":{"commit":"1"}}`)
|
|
||||||
if a != b || a == c {
|
|
||||||
t.Fatalf("labels count, or the entrypoint does not: %q %q %q", a, b, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -58,6 +58,9 @@ type sourceInputs struct {
|
|||||||
// whole when an artifact's is not known (novox/hq ADR 0267).
|
// whole when an artifact's is not known (novox/hq ADR 0267).
|
||||||
own map[string]bool
|
own map[string]bool
|
||||||
ownIsAll bool
|
ownIsAll bool
|
||||||
|
// root is the module's build source in its own repository outside its directory, relative to the
|
||||||
|
// repository's root: the list of predefined keys a manifest naming one was judged against (ADR 0348 §7).
|
||||||
|
root map[string]bool
|
||||||
// read is, per context (repository and ref), the build source read there; nil for one read whole.
|
// read is, per context (repository and ref), the build source read there; nil for one read whole.
|
||||||
read map[string]map[string]bool
|
read map[string]map[string]bool
|
||||||
readAs map[string]catalogue.ArtifactContext
|
readAs map[string]catalogue.ArtifactContext
|
||||||
@@ -86,6 +89,9 @@ func (s *sourceInputs) copy() *sourceInputs {
|
|||||||
for k, v := range s.own {
|
for k, v := range s.own {
|
||||||
c.own[k] = v
|
c.own[k] = v
|
||||||
}
|
}
|
||||||
|
for k, v := range s.root {
|
||||||
|
c.root[k] = v
|
||||||
|
}
|
||||||
for k, v := range s.read {
|
for k, v := range s.read {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
c.read[k] = nil
|
c.read[k] = nil
|
||||||
@@ -112,7 +118,7 @@ func (s *sourceInputs) contextOffHead() {
|
|||||||
|
|
||||||
func newSourceInputs(module string) *sourceInputs {
|
func newSourceInputs(module string) *sourceInputs {
|
||||||
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{},
|
return &sourceInputs{module: module, contexts: map[string]string{}, toolchains: map[string]string{},
|
||||||
own: map[string]bool{}, read: map[string]map[string]bool{}, readAs: map[string]catalogue.ArtifactContext{},
|
own: map[string]bool{}, root: map[string]bool{}, read: map[string]map[string]bool{}, readAs: map[string]catalogue.ArtifactContext{},
|
||||||
contextsAtHead: true}
|
contextsAtHead: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +132,16 @@ func (s *sourceInputs) ownHas(entries ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rootHas adds entries, relative to the repository's root, to the module's build source in its own repository.
|
||||||
|
func (s *sourceInputs) rootHas(entries ...string) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
s.root[e] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ownWhole says an artifact's build source in the module's own repository is not known: the module's
|
// ownWhole says an artifact's build source in the module's own repository is not known: the module's
|
||||||
// whole directory is its source, as it was before.
|
// whole directory is its source, as it was before.
|
||||||
func (s *sourceInputs) ownWhole() {
|
func (s *sourceInputs) ownWhole() {
|
||||||
@@ -178,6 +194,9 @@ func (s *sourceInputs) buildSources() []BuildSource {
|
|||||||
for e := range s.own {
|
for e := range s.own {
|
||||||
own = append(own, withPrefix(s.prefix, e))
|
own = append(own, withPrefix(s.prefix, e))
|
||||||
}
|
}
|
||||||
|
for e := range s.root {
|
||||||
|
own = append(own, e)
|
||||||
|
}
|
||||||
sort.Strings(own)
|
sort.Strings(own)
|
||||||
out = append(out, BuildSource{Paths: compactSorted(own)})
|
out = append(out, BuildSource{Paths: compactSorted(own)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// withTemplateTexts is what was built with each template's text, for a module whose files name templates
|
||||||
|
// (novox/hq ADR 0349 §2): its templates archive gains the text of every file in it, read back from the packed
|
||||||
|
// bytes, by its path inside the archive. Resolve reads them into the files' content.
|
||||||
|
//
|
||||||
|
// **Read from the archive, proven by its digest.** The module's files/ is packed again, as the build packs it,
|
||||||
|
// and the bytes are read only when they hash to the digest the build made or took: a fresh build's own archive,
|
||||||
|
// or a check's build a walk copied by digest without building (novox/hq ADR 0350 §2). Packing is deterministic,
|
||||||
|
// so one source packs to one digest; a tree that packs to another is not the archive, and its text is refused.
|
||||||
|
func withTemplateTexts(manifest catalogue.Manifest, within string, built []catalogue.Built) ([]catalogue.Built, error) {
|
||||||
|
if !manifest.Templated() {
|
||||||
|
return built, nil
|
||||||
|
}
|
||||||
|
name := catalogue.TemplatesArtifact(manifest)
|
||||||
|
at := -1
|
||||||
|
for i, b := range built {
|
||||||
|
if b.Name == name {
|
||||||
|
at = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" || at < 0 {
|
||||||
|
return nil, fmt.Errorf("%s names templates and built no archive of %s/ to read them from",
|
||||||
|
manifest.Module, catalogue.TemplatesDir)
|
||||||
|
}
|
||||||
|
body, err := pack(filepath.Join(within, catalogue.TemplatesDir))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: packing %s/ to read its templates back failed: %w", manifest.Module,
|
||||||
|
catalogue.TemplatesDir, err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(body)
|
||||||
|
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
if built[at].Digest != digest {
|
||||||
|
return nil, fmt.Errorf("%s: %s/ packs to the digest %s, and the archive %q built or taken is %s: the "+
|
||||||
|
"templates are read only from the archive the mesh records", manifest.Module, catalogue.TemplatesDir,
|
||||||
|
digest, name, orNone(built[at].Digest))
|
||||||
|
}
|
||||||
|
texts, err := unpackTexts(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: reading the archive %q back failed: %w", manifest.Module, name, err)
|
||||||
|
}
|
||||||
|
out := append([]catalogue.Built(nil), built...)
|
||||||
|
out[at].Texts = texts
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unpackTexts is every file of a packed archive (pack), by its path inside it.
|
||||||
|
func unpackTexts(body []byte) (map[string]string, error) {
|
||||||
|
zipped, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer zipped.Close()
|
||||||
|
reader := tar.NewReader(zipped)
|
||||||
|
texts := map[string]string{}
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return texts, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if header.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
texts[header.Name] = string(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orNone(digest string) string {
|
||||||
|
if digest == "" {
|
||||||
|
return "no digest"
|
||||||
|
}
|
||||||
|
return digest
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module's files kept as template files in its repository (novox/hq ADR 0349 §2, design 57 package T1, issue
|
||||||
|
// 654): the build packs files/ into the module's templates archive and reads each template's text back from the
|
||||||
|
// packed bytes into the file's content, so the manifest the mesh holds is the one it would hold for the text inline.
|
||||||
|
|
||||||
|
const templatedModule = `{"module":"meshboard","version":"1",
|
||||||
|
"build":{"artifacts":[
|
||||||
|
{"name":"server","kind":"image","from":"Dockerfile"},
|
||||||
|
{"name":"files","kind":"archive","from":"files"}]},
|
||||||
|
"resources":[
|
||||||
|
{"id":"svc","type":"container","name":"meshboard","artifact":"server"},
|
||||||
|
{"id":"conf","type":"file","path":"/etc/meshboard/meshboard.conf","template":"files/meshboard.conf"},
|
||||||
|
{"id":"deeper","type":"file","path":"/etc/meshboard/deeper.conf","template":"files/a/deeper.conf"}]}`
|
||||||
|
|
||||||
|
var templatedFiles = map[string]string{
|
||||||
|
"Dockerfile": "FROM scratch", "files/meshboard.conf": "listen = 8080\n\"quoted\" é\n",
|
||||||
|
"files/a/deeper.conf": "deeper\n", "files/unused.conf": "packed, named by nothing",
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentOf(t *testing.T, m catalogue.Manifest, id string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
if _, has := r[catalogue.TemplateField]; has {
|
||||||
|
t.Fatalf("%s still names its template in the built manifest: %v", id, r)
|
||||||
|
}
|
||||||
|
text, ok := r["content"].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s carries no content: %v", id, r)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no resource %s in %v", id, m.Resources)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildReadsEachTemplateBackFromItsArchive(t *testing.T) {
|
||||||
|
r, workspace := aRepository(t, templatedModule, templatedFiles)
|
||||||
|
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil,
|
||||||
|
Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if text := contentOf(t, got.Manifest, "conf"); text != templatedFiles["files/meshboard.conf"] {
|
||||||
|
t.Fatalf("the template's text is %q, not the file's", text)
|
||||||
|
}
|
||||||
|
if text := contentOf(t, got.Manifest, "deeper"); text != templatedFiles["files/a/deeper.conf"] {
|
||||||
|
t.Fatalf("the template's text is %q, not the file's", text)
|
||||||
|
}
|
||||||
|
if r.archives["meshboard/files"] == "" {
|
||||||
|
t.Fatalf("the templates archive was not published: %v", r.archives)
|
||||||
|
}
|
||||||
|
if problem := catalogue.HeldTemplateProblem(got.Manifest); problem != "" {
|
||||||
|
t.Fatal(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABuildWhoseTemplateIsMissingFailsNamingIt(t *testing.T) {
|
||||||
|
files := map[string]string{}
|
||||||
|
for k, v := range templatedFiles {
|
||||||
|
files[k] = v
|
||||||
|
}
|
||||||
|
delete(files, "files/a/deeper.conf")
|
||||||
|
r, workspace := aRepository(t, templatedModule, files)
|
||||||
|
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil,
|
||||||
|
Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "files/a/deeper.conf") {
|
||||||
|
t.Fatalf("a build whose template is missing did not fail naming it: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk that takes its check's build copies the archive by digest and builds nothing: the templates are read back
|
||||||
|
// from the tree packed again, which is the archive only when it packs to the digest taken.
|
||||||
|
func TestAWalkTakingAChecksBuildReadsTheTemplatesBackToo(t *testing.T) {
|
||||||
|
withVersion(t, "builder-1")
|
||||||
|
contents := map[string]string{"mod/" + ManifestName: templatedModule}
|
||||||
|
for k, v := range templatedFiles {
|
||||||
|
contents["mod/"+k] = v
|
||||||
|
}
|
||||||
|
scratch := newScratch()
|
||||||
|
r, run, workspace := aModuleWith(t, "tree-one", contents)
|
||||||
|
checked := aCheckBuiltBy(t, scratch, r, run, workspace)
|
||||||
|
|
||||||
|
r, run, workspace = aModuleWith(t, "tree-one", contents)
|
||||||
|
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
|
||||||
|
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Reused == nil || len(r.archives) > 0 {
|
||||||
|
t.Fatalf("the walk built again: reused %+v, why %q, published %v", got.Reused, got.Why, r.archives)
|
||||||
|
}
|
||||||
|
if text := contentOf(t, got.Manifest, "conf"); text != templatedFiles["files/meshboard.conf"] {
|
||||||
|
t.Fatalf("the template's text is %q, not the file's", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree packed again must be the archive taken; text read from anything else is not the archive's.
|
||||||
|
func TestTemplateTextsAreRefusedWhenTheTreeDoesNotPackToTheDigestTaken(t *testing.T) {
|
||||||
|
m, err := catalogue.ParseManifest([]byte(templatedModule))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
within := writeTree(t, t.TempDir(), templatedFiles)
|
||||||
|
built := []catalogue.Built{{Name: "files", Kind: catalogue.ArtifactArchive, Digest: "sha256:" + strings.Repeat("0", 64)}}
|
||||||
|
if _, err := withTemplateTexts(m, within, built); err == nil || !strings.Contains(err.Error(), "digest") {
|
||||||
|
t.Fatalf("text was read from a tree that does not pack to the archive taken: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTree writes files under dir and answers dir.
|
||||||
|
func writeTree(t *testing.T, dir string, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
for name, body := range files {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A judge's build in a test starts from a warm Go cache** (novox/hq issue 717). The runtime these tests put on
|
|
||||||
// PATH (aRuntimeThatBuildsTheJudge) runs the check's `go build` and `go test` on this machine with the Go cache the
|
|
||||||
// check names — a fresh directory in every test — so each of them compiled the standard library from nothing: some
|
|
||||||
// ten CPU-seconds a build, under twenty tests, half of the CPU the whole suite spent. The runtime now fills an empty
|
|
||||||
// Go cache it is given from one this process warmed once, before it runs `go`; what the check names, mounts and
|
|
||||||
// keeps is unchanged, and so is what each test reads of it. A cache entry is keyed by everything the compile
|
|
||||||
// depends on, flags included, so a build with a planted -toolexec hook still misses it and still runs its hook.
|
|
||||||
//
|
|
||||||
// The warm cache lives beside the system's temporary directory as it was when the package started, never under a
|
|
||||||
// TMPDIR a test set and then asserts left empty, and is removed when the package's tests end.
|
|
||||||
var (
|
|
||||||
warmCacheBase = os.TempDir()
|
|
||||||
warmCacheOnce sync.Once
|
|
||||||
warmCacheDir string
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
code := m.Run()
|
|
||||||
if warmCacheDir != "" {
|
|
||||||
_ = os.RemoveAll(warmCacheDir)
|
|
||||||
}
|
|
||||||
os.Exit(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// warmGoCache is a Go cache holding what a judge's build and the replays' `go test` compile of the standard
|
|
||||||
// library, or "" when it cannot be warmed — a test then builds cold, as before, and is only slower.
|
|
||||||
func warmGoCache(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
warmCacheOnce.Do(func() {
|
|
||||||
dir, err := os.MkdirTemp(warmCacheBase, "judge-warm-go-cache-")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
src := filepath.Join(dir, "src")
|
|
||||||
files := map[string]string{
|
|
||||||
"go.mod": "module example.org/warm\n\ngo 1.22\n",
|
|
||||||
"main.go": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\nfunc main() { fmt.Fprintln(os.Stdout, len(os.Args)) }\n",
|
|
||||||
"main_test.go": "package main\n\nimport \"testing\"\n\nfunc TestWarm(t *testing.T) {}\n",
|
|
||||||
}
|
|
||||||
for name, body := range files {
|
|
||||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cache := filepath.Join(dir, "go-cache")
|
|
||||||
env := append(os.Environ(), "GOCACHE="+cache, "GOENV=off", "GOTOOLCHAIN=local", "GOPROXY=off", "GOWORK=off",
|
|
||||||
"GOFLAGS=-buildvcs=false", "TMPDIR="+warmCacheBase)
|
|
||||||
// As the check builds a judge (CGO off) and runs the replays (CGO as the toolchain has it).
|
|
||||||
for _, run := range [][]string{
|
|
||||||
{"env", "CGO_ENABLED=0", "go", "build", "-o", filepath.Join(dir, "judge"), "."},
|
|
||||||
{"go", "test", "-count=1", "."},
|
|
||||||
} {
|
|
||||||
cmd := exec.Command(run[0], run[1:]...)
|
|
||||||
cmd.Dir, cmd.Env = src, env
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
t.Logf("the warm Go cache could not be built, so judges build cold: %v\n%s", err, out)
|
|
||||||
_ = os.RemoveAll(dir)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
warmCacheDir = dir
|
|
||||||
})
|
|
||||||
if warmCacheDir == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(warmCacheDir, "go-cache")
|
|
||||||
}
|
|
||||||
@@ -35,6 +35,10 @@ type Built struct {
|
|||||||
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
|
// Launchers are, for a bundle in an interpreted language, the executable the build wrote beside
|
||||||
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
|
// each entrypoint, by entrypoint (novox/hq ADR 0193): what the node's runtime starts to serve it.
|
||||||
Launchers map[string]string
|
Launchers map[string]string
|
||||||
|
// Texts are, for the archive a module packs its templates into (TemplatesArtifact), each file's text as the
|
||||||
|
// build read it back from the packed archive, by its path inside it (novox/hq ADR 0349 §2). Never carried on
|
||||||
|
// the wire or kept: Resolve reads them into the files' content, which is what the mesh holds.
|
||||||
|
Texts map[string]string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve fills a manifest's resources in from what was built.
|
// Resolve fills a manifest's resources in from what was built.
|
||||||
@@ -71,6 +75,26 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
|||||||
m.Module, strings.Join(missing, " and "), oneOrOther(len(missing)))
|
m.Module, strings.Join(missing, " and "), oneOrOther(len(missing)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into
|
||||||
|
// the file's content, so what the mesh holds and composes is what the same text inline would be.
|
||||||
|
if m.Templated() {
|
||||||
|
name := TemplatesArtifact(m)
|
||||||
|
texts := by[name].Texts
|
||||||
|
if name == "" || texts == nil {
|
||||||
|
return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+
|
||||||
|
"archive it packs %s/ into (%q): the builder that built it does not read templates",
|
||||||
|
m.Module, TemplatesDir, name)
|
||||||
|
}
|
||||||
|
filled, err := fillTemplates(m.Module, m.Resources, func(template string) (string, bool) {
|
||||||
|
t, ok := texts[strings.TrimPrefix(template, TemplatesDir+"/")]
|
||||||
|
return t, ok
|
||||||
|
}, fmt.Sprintf("the archive %q", name))
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
m.Resources = filled
|
||||||
|
}
|
||||||
|
|
||||||
out := m
|
out := m
|
||||||
out.Build = nil
|
out.Build = nil
|
||||||
out.Resources = nil
|
out.Resources = nil
|
||||||
|
|||||||
@@ -21,16 +21,6 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
|||||||
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
|
||||||
// granted the module's events, so a bus module that ships one may declare them.
|
|
||||||
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
||||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
||||||
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
|
|
||||||
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
|
|
||||||
if _, err := ParseManifest(bundled); err != nil {
|
|
||||||
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||||
"tools":["bus_streams"]}`)
|
"tools":["bus_streams"]}`)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user