Delete the builder, the build asks, the build queue and the merge gate: the builder's keys are the only way a module is built (issues 1851, 1299, 1318; C2b-C5, C10)
The controller asks no build of anybody (ADR 0501 §5, ADR 0525 clause 6, ADR 0503 §3). Every module of a walk is taken from the builder's done key; a key not done yet waits, named in the plan, and never fails it. - C2b/C2c: internal/builder and cmd/mesh-controller's asks go (askABuild, onlyTheBuildersOwn, buildOneAsked, takeIn from an outcome, buildAgentsOwn, the builder's say and its fallback, the daemon hearing an outcome). The server follows the retired seats' built subjects only to let go of them, because the installer's carried user list names them. - C2d: replay, queue, cancel, clear, kill, pause, resume, delivery-check, check-here and merge-gate are refused by name. - C3: the check queues (CHECKS_READY, CHECKS_OTHER) and their workers, the checks-running and plans-building buckets and their grants, the build queue and its hold (plans_building.go, queue.go, link/holds.go, builds_nats.go, checks_nats.go). - C4: merge_gate*.go, the pull request's repo-check path, the Checked event and its layers; a pull request asks the controller nothing but to tell mesh-config what its head declares. - C5: the builds bucket is written only by key_takein. - C10 (help and refusals): the terminal's help lists only what is served. Integration tests of the plan's tiers, sends, stops and retries now run on a stand-in builder with a done key for every module (walk_keys_scaffold_integration_test.go); the tests of the asks, the queue, the merge gate and the replays are deleted.
This commit is contained in:
@@ -221,8 +221,4 @@ func TestOnANodeWithNoNetworkTheStoreIsReachedByLoopback(t *testing.T) {
|
||||
if image != "127.0.0.1:5100/postgres/runtime@"+aDigest {
|
||||
t.Fatalf("on the store's own node, off any network, the image is fetched as %v", image)
|
||||
}
|
||||
held := heldBy(ctx)
|
||||
if got := held["postgres/runtime"]; got != "127.0.0.1:5100/postgres/runtime@"+aDigest {
|
||||
t.Fatalf("a builder beside the store is handed the base %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// An address is read from the node's settings where it is used, never recorded with a port
|
||||
@@ -14,62 +11,6 @@ import (
|
||||
|
||||
var aDigest = "sha256:" + strings.Repeat("e", 64)
|
||||
|
||||
// **A build is recorded by digest and path**, whatever address the builder pushed to — what it
|
||||
// made and what it stood on both; an image the module runs from elsewhere is left where it says.
|
||||
func TestABuildIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||
manifest, _ := json.Marshal(map[string]any{
|
||||
"module": "gitea", "version": "1",
|
||||
"resources": []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "mesh-gitea",
|
||||
"image": "anchor.internal:5100/gitea/server@" + aDigest},
|
||||
{"id": "config", "type": "archive", "path": "/etc/gitea", "digest": aDigest,
|
||||
"source": "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest},
|
||||
{"id": "cache", "type": "container", "name": "mesh-gitea-cache",
|
||||
"image": "valkey/valkey@" + aDigest},
|
||||
},
|
||||
})
|
||||
kept := buildFrom(link.BuildResult{
|
||||
ID: "b1", Repository: "https://forge.example/gitea.git", Commit: "abc", On: "laptop",
|
||||
Manifest: manifest,
|
||||
Made: []link.MadeArtifact{
|
||||
{Name: "server", Kind: "image", Reference: "anchor.internal:5100/gitea/server@" + aDigest},
|
||||
{Name: "config", Kind: "archive", Reference: "http://anchor.internal:5100/v2/gitea/config/blobs/" + aDigest},
|
||||
},
|
||||
Against: []string{"anchor.internal:5100/mesh-tools/runtime@" + aDigest},
|
||||
})
|
||||
if kept.Module != "gitea" {
|
||||
t.Fatalf("the module was not read from the recorded manifest: %q", kept.Module)
|
||||
}
|
||||
if kept.Made[0].Reference != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest {
|
||||
t.Errorf("the image is recorded as %q, address and all", kept.Made[0].Reference)
|
||||
}
|
||||
if kept.Made[1].Reference != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest {
|
||||
t.Errorf("the archive is recorded as %q, address and all", kept.Made[1].Reference)
|
||||
}
|
||||
recorded, err := catalogue.ParseManifest(kept.Manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := recorded.Resources[0]["image"]; got != catalogue.ArtifactStoreScheme+"gitea/server@"+aDigest {
|
||||
t.Errorf("the recorded manifest's image is %v", got)
|
||||
}
|
||||
if got := recorded.Resources[1]["source"]; got != catalogue.ArtifactStoreScheme+"gitea/config/blobs/"+aDigest {
|
||||
t.Errorf("the recorded manifest's archive is %v", got)
|
||||
}
|
||||
if got := recorded.Resources[2]["image"]; got != "valkey/valkey@"+aDigest {
|
||||
t.Errorf("an image the build did not make was rewritten: %v", got)
|
||||
}
|
||||
if strings.Contains(string(kept.Manifest), "anchor.internal:5100") {
|
||||
t.Errorf("the recorded manifest still carries the store's address:\n%s", kept.Manifest)
|
||||
}
|
||||
// What the build stood on is an edge to another module's artifact, and it is recorded the way
|
||||
// that artifact is: by path in the store, so the edge still names the same thing when the
|
||||
// store answers at another address.
|
||||
if kept.Against[0] != catalogue.ArtifactStoreScheme+"mesh-tools/runtime@"+aDigest {
|
||||
t.Errorf("what the build stood on was recorded by address: %v", kept.Against)
|
||||
}
|
||||
}
|
||||
|
||||
// aStore is a module offering the artifact store on 5000, published the long way as the
|
||||
// distribution module does, so a node may be given another number for it.
|
||||
func aStore() catalogue.Manifest {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Issue 1334, the look: when the bus did not answer an ask of any module of a tier, no build is in flight and no event follows,
|
||||
// so the look itself comes back (it fails, and the looker retries it), asks the modules again, and past the bound fails the plan.
|
||||
func TestALookComesBackForATierTheBusRefusedWhole(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
for _, name := range []string{"a", "b"} {
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: name, Version: "1"},
|
||||
inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p := inventory.Plan{ID: "plan-bus", Repository: "novox/a", Branch: "main", Commit: "c1", Created: time.Now().UTC(),
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b"}}, Modules: map[string]*inventory.PlanModule{"a": {}, "b": {}}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus := false
|
||||
asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||
if !bus {
|
||||
return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain)
|
||||
}
|
||||
return "build-" + source.Repository, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
look := func() bool {
|
||||
t.Helper()
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
return advanceHeld(ctx, open)
|
||||
}
|
||||
read := func() inventory.Plan {
|
||||
t.Helper()
|
||||
got, err := inv.PlanByID(ctx, "plan-bus")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// Every module refused: the look says it failed, which has the looker come back; the plan is building and says why.
|
||||
if !look() {
|
||||
t.Fatal("a look that left the whole tier unasked for the bus did not fail, so nothing would come back to it")
|
||||
}
|
||||
if got := read(); got.State != inventory.PlanBuilding || !strings.Contains(got.Note, "bus did not answer") ||
|
||||
got.Modules["a"].State != "" || got.Modules["b"].State != "" {
|
||||
t.Fatalf("the plan after a refused tier: %s %q %+v %+v", got.State, got.Note, got.Modules["a"], got.Modules["b"])
|
||||
}
|
||||
// Again, still refused: the same fact is not saved again, and the look still comes back.
|
||||
before := read().Revision
|
||||
if !look() || read().Revision != before {
|
||||
t.Fatalf("a second refused look failed to come back or saved the plan again (revision %d, was %d)", read().Revision, before)
|
||||
}
|
||||
|
||||
// The bus answers at the next look: both are asked, and nothing waits any more.
|
||||
bus = true
|
||||
if look() {
|
||||
t.Fatal("a look that asked the whole tier failed")
|
||||
}
|
||||
if got := read(); got.Modules["a"].State != "asked" || got.Modules["b"].State != "asked" || got.Modules["a"].BusWaitFrom != nil {
|
||||
t.Fatalf("the tier was not asked once the bus answered: %+v %+v", got.Modules["a"], got.Modules["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestALookFailsTheTierTheBusRefusedPastTheBound(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "a", Version: "1"},
|
||||
inventory.Source{Repository: "novox/a", Seat: "git", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := time.Now().UTC().Add(-busWaitBound - time.Minute)
|
||||
p := inventory.Plan{ID: "plan-bus", Repository: "novox/a", Branch: "main", Commit: "c1", Created: time.Now().UTC(),
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{"a": {BusWaitFrom: &long}}}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it
|
||||
was := askABuild
|
||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||
return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain)
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
release, err := inv.HoldPlans(ctx, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advanceHeld(ctx, open)
|
||||
release()
|
||||
got, err := inv.PlanByID(ctx, "plan-bus")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != inventory.PlanFailed || !strings.Contains(got.Note, "the bus did not answer an ask for 10m0s (since ") {
|
||||
t.Fatalf("a bus silent past its bound did not fail the plan in words: %s %q", got.State, got.Note)
|
||||
}
|
||||
|
||||
// And retried by hand: the module is named as waiting, not as asked again.
|
||||
got, _ = inv.PlanByID(ctx, "plan-bus")
|
||||
if got.State == inventory.PlanFailed {
|
||||
got.Modules["a"].State, got.Modules["a"].Why = "failed", "x"
|
||||
if err := inv.SavePlan(ctx, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := retryPlan(ctx, open, "plan-bus")
|
||||
if err != nil || !strings.Contains(said, "not asked yet: the bus did not answer") {
|
||||
t.Fatalf("a retry left for the bus is said as %q %v", said, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Issue 1334: a bus that does not answer for a moment (it reloads its user list, and JetStream's API has no responder for a few
|
||||
// seconds) is not a failed build. The module is left unasked and the plan building, and the next look asks it again; only a
|
||||
// module's own fault, or a bus silent for ten minutes, fails the plan.
|
||||
|
||||
// asksOfEveryModule makes every module a test names the builder's own, the one module a walk still asks build-agent
|
||||
// for (ADR 0453 §5): what a test of that ask's waits reads.
|
||||
func asksOfEveryModule(t *testing.T) {
|
||||
t.Helper()
|
||||
was := buildersOwn
|
||||
buildersOwn = func(string) bool { return true }
|
||||
t.Cleanup(func() { buildersOwn = was })
|
||||
}
|
||||
|
||||
func aTierOfThree() (*inventory.Plan, map[string]inventory.Entry) {
|
||||
p := &inventory.Plan{ID: "plan-1", State: inventory.PlanBuilding, Tiers: [][]string{{"a", "b", "c"}},
|
||||
Modules: map[string]*inventory.PlanModule{}}
|
||||
byName := map[string]inventory.Entry{}
|
||||
for _, m := range []string{"a", "b", "c"} {
|
||||
byName[m] = inventory.Entry{Source: inventory.Source{Repository: "novox/" + m, Seat: "git", Ref: "main"}}
|
||||
}
|
||||
return p, byName
|
||||
}
|
||||
|
||||
func TestABusThatDoesNotAnswerForAMomentDoesNotFailTheTier(t *testing.T) {
|
||||
asksOfEveryModule(t)
|
||||
p, byName := aTierOfThree()
|
||||
bus := false
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||
if source.Repository == "novox/b" && !bus {
|
||||
return "", fmt.Errorf("%w: the bucket mesh-controller_builds is not on the bus: nats: no responders available for request",
|
||||
inventory.ErrNoChain)
|
||||
}
|
||||
return "build-" + source.Repository, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
|
||||
for _, m := range []string{"a", "b", "c"} {
|
||||
askModule(t.Context(), p, m, byName)
|
||||
}
|
||||
if p.State != inventory.PlanBuilding {
|
||||
t.Fatalf("a bus that did not answer for a moment failed the plan: %s, %q", p.State, p.Note)
|
||||
}
|
||||
if p.Modules["a"].State != "asked" || p.Modules["c"].State != "asked" {
|
||||
t.Fatalf("the modules the bus did answer for were not asked: %+v %+v", p.Modules["a"], p.Modules["c"])
|
||||
}
|
||||
if s := p.Modules["b"]; s.State != "" {
|
||||
t.Fatalf("the module the bus did not answer for is %q, not left unasked", s.State)
|
||||
}
|
||||
if got := unaskedOf(*p); len(got) != 1 || got[0] != "b" {
|
||||
t.Fatalf("the next look would ask %v, not b", got)
|
||||
}
|
||||
if !strings.Contains(p.Note, "bus did not answer") {
|
||||
t.Fatalf("the plan says nothing of the wait: %q", p.Note)
|
||||
}
|
||||
|
||||
// The next look, the bus answering: b is asked and the plan goes on.
|
||||
bus = true
|
||||
for _, m := range unaskedOf(*p) {
|
||||
askModule(t.Context(), p, m, byName)
|
||||
}
|
||||
if p.State != inventory.PlanBuilding || p.Modules["b"].State != "asked" || p.Modules["b"].Build == "" {
|
||||
t.Fatalf("b was not asked once the bus answered: %s %+v", p.State, p.Modules["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestABusSilentForTenMinutesFailsTheTierAsBefore(t *testing.T) {
|
||||
asksOfEveryModule(t)
|
||||
p, byName := aTierOfThree()
|
||||
was := askABuild
|
||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||
return "", fmt.Errorf("%w: nats: no responders available for request", inventory.ErrNoChain)
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
askModule(t.Context(), p, "a", byName)
|
||||
if p.State != inventory.PlanBuilding {
|
||||
t.Fatalf("the first refusal failed the plan: %q", p.Note)
|
||||
}
|
||||
long := time.Now().Add(-busWaitBound - time.Minute)
|
||||
p.Modules["a"].BusWaitFrom = &long
|
||||
askModule(t.Context(), p, "a", byName)
|
||||
if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" {
|
||||
t.Fatalf("a bus silent past its bound did not fail the plan: %s %+v", p.State, p.Modules["a"])
|
||||
}
|
||||
// Said in plain words: the bus, the wait, and since when.
|
||||
want := "a could not be asked for: the bus did not answer an ask for 10m0s (since " + long.UTC().Format(time.RFC3339) +
|
||||
"): this kind of record lives on the bus"
|
||||
if !strings.HasPrefix(p.Note, want) || p.Modules["a"].Why != strings.TrimPrefix(want, "a could not be asked for: ")+p.Note[len(want):] {
|
||||
t.Fatalf("the plan says %q, not words starting %q", p.Note, want)
|
||||
}
|
||||
// The wait has ended: a Retry made while the bus still reloads waits again, and does not fail at its first refusal.
|
||||
if p.Modules["a"].BusWaitFrom != nil {
|
||||
t.Fatalf("a failed module still holds the start of its wait: %v", p.Modules["a"].BusWaitFrom)
|
||||
}
|
||||
p.State = inventory.PlanBuilding
|
||||
askModule(t.Context(), p, "a", byName)
|
||||
if p.State != inventory.PlanBuilding || p.Modules["a"].State != "" || p.Modules["a"].BusWaitFrom == nil {
|
||||
t.Fatalf("a retry made while the bus is silent did not wait afresh: %s %+v", p.State, p.Modules["a"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanWithAModuleWaitingForTheBusIsOneTheLookComesBackTo(t *testing.T) {
|
||||
p, _ := aTierOfThree()
|
||||
if busWaiting(*p) {
|
||||
t.Fatal("a plan whose modules are not asked yet waits for the bus")
|
||||
}
|
||||
now := time.Now()
|
||||
p.Modules["b"] = &inventory.PlanModule{BusWaitFrom: &now}
|
||||
p.Modules["a"] = &inventory.PlanModule{State: "asked"}
|
||||
if got := busWaitingIn(*p); len(got) != 1 || got[0] != "b" {
|
||||
t.Fatalf("the modules waiting for the bus are %v, not b", got)
|
||||
}
|
||||
p.State = inventory.PlanFailed
|
||||
if busWaiting(*p) {
|
||||
t.Fatal("a plan that is not open is waited for")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModulesOwnFaultStillFailsThePlan(t *testing.T) {
|
||||
asksOfEveryModule(t)
|
||||
p, byName := aTierOfThree()
|
||||
delete(byName, "a")
|
||||
askModule(t.Context(), p, "a", byName)
|
||||
if p.State != inventory.PlanFailed || !strings.Contains(p.Note, "no longer in the catalogue") {
|
||||
t.Fatalf("a module gone from the catalogue did not fail the plan: %s %q", p.State, p.Note)
|
||||
}
|
||||
|
||||
p, byName = aTierOfThree()
|
||||
was := askABuild
|
||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||
return "", errors.New("git.example/novox/a is no repository the catalogue builds a module from")
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
askModule(t.Context(), p, "a", byName)
|
||||
if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" {
|
||||
t.Fatalf("a module's own refusal did not fail the plan: %s %+v", p.State, p.Modules["a"])
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
@@ -18,37 +14,6 @@ import (
|
||||
// (issue 1334).
|
||||
const busWaitBound = 10 * time.Minute
|
||||
|
||||
// busDidNotAnswer says an ask failed because the bus's JetStream had no responder at that moment, as it has for a few seconds
|
||||
// when the bus reloads its user list (novox/hq issue 353), and not because of the module: the same ask is made again.
|
||||
func busDidNotAnswer(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, nats.ErrJetStreamNotEnabled) ||
|
||||
errors.Is(err, jetstream.ErrJetStreamNotEnabled) || errors.Is(err, inventory.ErrNoChain) {
|
||||
return true
|
||||
}
|
||||
// An error that crossed a boundary as text only.
|
||||
text := err.Error()
|
||||
return strings.Contains(text, "no responders available") || strings.Contains(text, "jetstream not enabled")
|
||||
}
|
||||
|
||||
// waitForTheBus leaves a module whose ask the bus did not answer unasked, the plan building, and says so in the plan's note: the
|
||||
// next look asks the tier's unasked modules again. It says false, so the caller fails the plan as for any refusal, once the bus
|
||||
// has been silent for busWaitBound.
|
||||
func waitForTheBus(p *inventory.Plan, state *inventory.PlanModule, name string, err error, now time.Time) bool {
|
||||
if state.BusWaitFrom == nil {
|
||||
state.BusWaitFrom = &now
|
||||
}
|
||||
if now.Sub(*state.BusWaitFrom) > busWaitBound {
|
||||
return false
|
||||
}
|
||||
state.State, state.Why = "", ""
|
||||
p.Note = fmt.Sprintf("tier %d: %s not asked: the bus did not answer (%v); asked again at the next look, failing after %s",
|
||||
p.Tier, name, firstLine(err.Error()), busWaitBound)
|
||||
return true
|
||||
}
|
||||
|
||||
// busWaiting says an open plan has a module of its current tier that the bus did not answer an ask of: the look must come back
|
||||
// for it, as nothing else will when no build of the tier is in flight (issue 1334).
|
||||
func busWaiting(p inventory.Plan) bool {
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestAWarrantForRetryRetriesAFailedPlanInTheStoreAndEndsItsCondition(t *test
|
||||
Note: "a failed to build in tier 0",
|
||||
Delivery: &inventory.PlanDelivery{},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1",
|
||||
Why: link.KilledByHand}},
|
||||
Why: "killed by hand"}},
|
||||
Times: &inventory.PlanTimes{Cut: &before, Started: &before, Ended: &ended, Class: inventory.ClassLeaf}}
|
||||
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -66,18 +66,8 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) {
|
||||
|
||||
// The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept.
|
||||
var mu sync.Mutex
|
||||
var asks []string
|
||||
var sends [][]string
|
||||
asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it
|
||||
wasAsk := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond))
|
||||
asks = append(asks, source.Repository+"#"+id)
|
||||
return id, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = wasAsk })
|
||||
everyKeyDone(t) // the builds of the tiers: each module is taken from a done key as soon as the walk reaches it
|
||||
digests := 0
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
@@ -199,13 +189,7 @@ func TestAPlanGoesOnWithoutAMachineAwayWhenItsTierIsSent(t *testing.T) {
|
||||
if !strings.Contains(moved, "laptop was away: sent when it is heard again") || strings.Contains(moved, "anchor") {
|
||||
t.Errorf("1161/AC2: the plan says %q; want laptop named away and not anchor", moved)
|
||||
}
|
||||
// The ask for tier 1's build is the next step, and records its build id on the module.
|
||||
eventually(t, "tier 1 asked", func() bool { m := plan().Modules["b"]; return m != nil && m.Build != "" })
|
||||
b := plan().Modules["b"]
|
||||
askedAt, _ := link.BuildAskedAt(b.Build)
|
||||
record(b.Build, "b", "c2", askedAt)
|
||||
register("b", "c2", askedAt)
|
||||
planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build)
|
||||
// Tier 1's module is taken from its key, which the walk's look does by itself.
|
||||
eventually(t, "tier 1 sent to anchor alone", func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestAPlanLineNamesWhatItMoves(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if got, want := planLineOn(p, now, pauseView{}, tierAtLeast, running),
|
||||
if got, want := planLineOn(p, now, tierAtLeast, running),
|
||||
"mesh-catalog #175 a tap shows its outcome · messenger, telegram → novox · tier 1 of 1, building for 2m0s"; got != want {
|
||||
t.Fatalf("the line reads %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -2,24 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// asking a build machine for a module, and what came back.
|
||||
@@ -28,80 +19,14 @@ import (
|
||||
// cheapest next step. That is how novox/hq ADR 0001 records `hal/sdk` reaching 34,636:
|
||||
// nothing in it was wrong, and no one edit was the one that should have been a new file.
|
||||
|
||||
// buildFrom turns what a builder said into what the mesh keeps.
|
||||
//
|
||||
// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder
|
||||
// says `<registry>:<port>/<module>/<artifact>@sha256:…`; the mesh records the artifact-store
|
||||
// reference and composes the store's address back in where a reference is used. `against` — what
|
||||
// the build stood on, the catalogue's edge — is recorded the same way, so an edge names a module's
|
||||
// artifact and not the machine it was pulled from.
|
||||
func buildFrom(result link.BuildResult) inventory.Build {
|
||||
kept := inventory.Build{
|
||||
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
||||
Commit: result.Commit, On: result.On, Failed: result.Failed,
|
||||
// **What the announcement carries, kept rather than discarded** (novox/hq 04-ISSUES/050).
|
||||
// The catalogue turns the manifest into requires/provides edges and `against` into build
|
||||
// edges, and it is not always listening when a build happens — on a fresh mesh it cannot
|
||||
// be, for exactly the modules it needs most. Keeping them is what makes a replay able to
|
||||
// rebuild the graph rather than a list of names.
|
||||
Path: result.Path,
|
||||
// What it was made from (novox/hq issue 280): two builds with one are one build.
|
||||
SourceFingerprint: result.SourceFingerprint,
|
||||
SourceInputs: result.SourceInputs,
|
||||
// And its reuse key (novox/hq ADR 0350 §1), which the chain names (ADR 0408 §1).
|
||||
ReuseKey: result.ReuseKey,
|
||||
}
|
||||
// When it was asked, which is what orders it against another build of the same module
|
||||
// (novox/hq 04-ISSUES/219) — not when it was heard.
|
||||
if asked, ok := link.BuildAskedAt(result.ID); ok {
|
||||
kept.Asked = asked
|
||||
}
|
||||
for _, ref := range result.Against {
|
||||
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
||||
}
|
||||
for _, ref := range result.Mirrored {
|
||||
kept.Mirrored = append(kept.Mirrored, catalogue.Recorded(ref))
|
||||
}
|
||||
for _, r := range result.Read {
|
||||
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||
}
|
||||
// What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it.
|
||||
for _, s := range result.Sources {
|
||||
kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
||||
}
|
||||
var announced []inventory.Artifact
|
||||
for _, made := range result.Made {
|
||||
announced = append(announced, inventory.Artifact{
|
||||
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
||||
})
|
||||
kept.Made = append(kept.Made, inventory.Artifact{
|
||||
Name: made.Name, Kind: made.Kind, Reference: catalogue.Recorded(made.Reference),
|
||||
})
|
||||
}
|
||||
kept.Manifest = recordedManifest(result.Manifest, announced)
|
||||
// The module name comes from the manifest, which only exists when the build got that far.
|
||||
if len(kept.Manifest) > 0 {
|
||||
if list, err := judgedWith(result); err == nil {
|
||||
if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil {
|
||||
kept.Module = m.Module
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// buildsCommand says what has been built lately.
|
||||
func buildsCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("builds", flag.ContinueOnError)
|
||||
limit := set.Int("n", 20, "how many to show")
|
||||
logOf := set.String("log", "", "a build's id: print what the build machine said, line by line")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *logOf != "" {
|
||||
return buildLog(ctx, *logOf)
|
||||
}
|
||||
module := ""
|
||||
if len(positionals) == 1 {
|
||||
module = positionals[0]
|
||||
@@ -182,365 +107,6 @@ func printBuild(w io.Writer, b inventory.Build) {
|
||||
}
|
||||
}
|
||||
|
||||
// builderCommand issues a build machine its own broker credential.
|
||||
//
|
||||
// **A build machine is not a node**, and giving it a node's account would let it read another
|
||||
// machine's declarations. This is narrower and different: read the build queue, write the
|
||||
// exchange and an asker's reply queue, and nothing else.
|
||||
//
|
||||
// Issued rather than assumed, because until this the builder used whatever credential it was
|
||||
// handed — which in practice meant the broker's own administrative one. A program documented as
|
||||
// holding its own credential and given somebody else's is worse than one with no story at all.
|
||||
func builderCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("builder issue", flag.ContinueOnError)
|
||||
// Which machine will use it. Given, the credential is delivered by the mesh rather than
|
||||
// printed for somebody to carry — which is the difference between the builder being a module
|
||||
// and being a program somebody configures.
|
||||
forNode := set.String("node", "",
|
||||
"the machine that will run it, so the mesh delivers the credential instead of printing it")
|
||||
module := set.String("module", "builder", "the module on that machine that will read it")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(positionals) != 2 || positionals[0] != "issue" {
|
||||
return errors.New("builder issue <name> [--node <machine>]")
|
||||
}
|
||||
name := positionals[1]
|
||||
|
||||
// **The build machine's credential is a module's credential** (novox/hq ADR 0131, design 28
|
||||
// task 5.5): minted into the mesh's records and sealed to the machine as the builder module's
|
||||
// broker secret, usable at the next push — the same act `module issue` performs, and the same
|
||||
// account the composed user list carries. Nothing is created on a server; the bus reads the list.
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, known := shelf[*module]
|
||||
if !known {
|
||||
return fmt.Errorf("%s is not in the catalogue; `module add` it first", *module)
|
||||
}
|
||||
fmt.Printf("build machine %s: ", name)
|
||||
node := *forNode
|
||||
if node == "" {
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Manifest.Module == *module && len(e.On) > 0 {
|
||||
node = e.On[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if node == "" {
|
||||
return fmt.Errorf("%s is assigned nowhere; `assign <machine> %s` first, or say --node", *module, *module)
|
||||
}
|
||||
address, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return issueOnTheNewBus(ctx, inv, m, node, address)
|
||||
}
|
||||
|
||||
// recordAsked keeps a build request once it is asked (novox/hq issue 325), with who asked it, in a store
|
||||
// opened for the purpose: the asks reach here from processes that hold none.
|
||||
func recordAsked(ctx context.Context, r inventory.BuildRequest) {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build %s is asked and not kept as a build request: %v\n", r.ID, err)
|
||||
return
|
||||
}
|
||||
defer open.Close()
|
||||
recordBuildRequest(ctx, open.inventory, r)
|
||||
}
|
||||
|
||||
// markWaitFailed says what became of a kept build request whose waited ask failed: never handed over, so
|
||||
// nothing runs; or handed over and no longer waited for, so asked with its outcome unknown — still read as in
|
||||
// flight until its outcome or its bound (novox/hq issue 325).
|
||||
func markWaitFailed(ctx context.Context, id string, why error) {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err)
|
||||
return
|
||||
}
|
||||
defer open.Close()
|
||||
mark := open.inventory.MarkOutcomeUnknown
|
||||
if errors.Is(why, link.ErrNotHandedOver) {
|
||||
mark = open.inventory.MarkNotAsked
|
||||
}
|
||||
if err := mark(ctx, id, why.Error()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build %s could not be marked: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildOneAsked is buildOne answering the id it asked with — what a plan keeps to match the outcome
|
||||
// by (novox/hq ADR 0219) — and, for an ask not waited for, optionally a dry run: built and looked
|
||||
// at, never taken in (issue 240), which is what `replay` asks unless told to register.
|
||||
//
|
||||
// asker is who asked, for the build request kept once the ask is made (novox/hq issue 325); empty for an
|
||||
// asker that keeps the request itself, with its own name, once it knows the ask was made.
|
||||
func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wait time.Duration,
|
||||
dryRun bool, asker string) (string, error) {
|
||||
if dryRun && wait != 0 {
|
||||
return "", errors.New("a dry run waited for is `build --dry-run`")
|
||||
}
|
||||
// Before anything is asked of a builder: a source on a seat nobody holds is refused here, with
|
||||
// the reason, rather than sent to a machine to fail at `git clone`.
|
||||
repository, err := cloneFrom(ctx, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Through a verb, only a repository the catalogue builds from (novox/hq ADR 0266).
|
||||
if err := verbMayAsk(ctx, source, repository, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ident, err := openIdentity(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer ident.Close()
|
||||
|
||||
server, err := connectLink(ctx, nil, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
// Correlated by something the control plane makes, not by the module's name: two builds of one
|
||||
// module can be in flight, and the second answer is not the first one's.
|
||||
request := link.BuildRequest{
|
||||
ID: link.NewBuildID(time.Now()),
|
||||
Repository: repository,
|
||||
Path: path,
|
||||
Ref: ref,
|
||||
Held: heldBy(ctx),
|
||||
Seats: seatBases(ctx),
|
||||
DryRun: dryRun,
|
||||
}
|
||||
fmt.Printf("asked for %s", source)
|
||||
if source.Seat != "" {
|
||||
fmt.Printf(" (%s)", repository)
|
||||
}
|
||||
// The id is how a person follows this build while it runs: `builds --log <id>`.
|
||||
fmt.Printf(" as %s", request.ID)
|
||||
if path != "" {
|
||||
fmt.Printf(" at %s", path)
|
||||
}
|
||||
if ref != "" {
|
||||
fmt.Printf(" on %s", ref)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
seat := buildSeatHeld(ctx)
|
||||
ask, err := askOverOn(seat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer ask.Close()
|
||||
fmt.Printf(" of %s\n", seat)
|
||||
// Kept once it is asked, so `assign` knows a module is coming while its build runs (novox/hq issue 325);
|
||||
// never before, so an ask that failed never reads as a build in flight. A dry run registers nothing, and
|
||||
// is not kept.
|
||||
keep := !dryRun && asker != ""
|
||||
// Asked at the terminal is what lets its outcome register a module from a repository the catalogue does
|
||||
// not build it from (novox/hq ADR 0266); never through a verb.
|
||||
asked := inventory.BuildRequest{ID: request.ID, Repository: source.Repository, Seat: source.Seat, Path: path,
|
||||
Ref: ref, For: asker, AtTerminal: startedAtTheTerminal()}
|
||||
|
||||
if wait == 0 {
|
||||
// Asked and not waited for (novox/hq issue 176): the outcome is the role's event, and the
|
||||
// controller takes it in — records the build, registers the module — whether or not anybody
|
||||
// is still here. A tool call cannot hold a connection for the minutes a build takes; it
|
||||
// follows the build by its id instead.
|
||||
if err := ask.Ask(ctx, request); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if keep {
|
||||
recordAsked(ctx, asked)
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Printf("asked as a dry run, not waited for: `builds --log %s` follows it as it runs; "+
|
||||
"its outcome is not taken in\n", request.ID)
|
||||
return request.ID, nil
|
||||
}
|
||||
fmt.Printf("asked, not waited for: `builds --log %s` follows it as it runs, and `builds` "+
|
||||
"shows what came of it; the module is registered when the outcome comes\n", request.ID)
|
||||
return request.ID, nil
|
||||
}
|
||||
|
||||
// Waited for: kept while it is waited for, since that can take minutes, and marked when the hand-over
|
||||
// failed (not asked) or the wait did (asked, outcome unknown) — an outcome heard later is the last word.
|
||||
if keep {
|
||||
recordAsked(ctx, asked)
|
||||
}
|
||||
result, err := ask.Submit(ctx, request, wait)
|
||||
if err != nil {
|
||||
if keep {
|
||||
markWaitFailed(ctx, request.ID, err)
|
||||
}
|
||||
return request.ID, err
|
||||
}
|
||||
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return request.ID, err
|
||||
}
|
||||
defer open.Close()
|
||||
manifest, kept, err := takeIn(ctx, open.inventory, result)
|
||||
// The assignments pending on this build, made or ended (novox/hq issue 325).
|
||||
for _, line := range settlePendingOnBuild(ctx, open, result, manifest.Module, err) {
|
||||
fmt.Printf(" %s\n", line)
|
||||
}
|
||||
if err != nil {
|
||||
return request.ID, err
|
||||
}
|
||||
// Said as recorded: what each artifact is, not where this builder happened to push it.
|
||||
for _, made := range kept.Made {
|
||||
fmt.Printf(" %-12s %s %s\n", made.Name, made.Kind, made.Reference)
|
||||
}
|
||||
fmt.Printf("\n%s %s, built on %s from %s\n",
|
||||
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||
saysWhenThePolicyActs(ctx, open.inventory, manifest.Module)
|
||||
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
||||
return request.ID, nil
|
||||
}
|
||||
|
||||
// saysWhenThePolicyActs tells whoever built a module that its upgrade policy will send the
|
||||
// result on at once (novox/hq issue 126, ADR 0163): a person choreographing a data move must
|
||||
// know which module will not wait for them.
|
||||
func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module string) {
|
||||
if u, err := inv.UpgradeOf(ctx, module); err == nil && u.RollOut {
|
||||
how := "one machine at a time"
|
||||
if u.Together {
|
||||
how = "every machine at once"
|
||||
}
|
||||
fmt.Printf(" %s rolls out on build: the machines running it are sent this now, %s — "+
|
||||
"`upgrade %s record` first if something must move before it does\n", module, how, module)
|
||||
}
|
||||
}
|
||||
|
||||
// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and
|
||||
// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's
|
||||
// result reaches the catalogue whether or not the asker was still listening.
|
||||
//
|
||||
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
|
||||
// nobody asked for, and the difference is the whole of whether somebody should be looking at
|
||||
// something. Then parsed with the same parser a hand-written manifest goes through — a second path
|
||||
// would be a second thing to disagree about what a manifest is — and registered with where it came
|
||||
// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111),
|
||||
// which the request carried and the outcome echoes. A definition naming an installation is refused
|
||||
// here, where it would enter the catalogue; the build stays recorded and the refusal says which.
|
||||
//
|
||||
// Idempotent: the same outcome taken in twice registers the same module twice, which is one row
|
||||
// written with the same values.
|
||||
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
||||
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)
|
||||
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||
return catalogue.Manifest{}, kept, err
|
||||
}
|
||||
if result.Failed != "" {
|
||||
// The builder's own words. Wrapping them in something about the control plane would put
|
||||
// two explanations between a person and a build log.
|
||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
||||
result.On, result.Repository, result.Failed)
|
||||
}
|
||||
// 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 {
|
||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||
result.On, result.Repository, err)
|
||||
}
|
||||
recorded := inventory.Source{
|
||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||
BuiltFrom: result.Commit, Head: result.Commit,
|
||||
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
||||
Against: kept.Against,
|
||||
// When it was asked, so an older request heard later does not replace a newer one
|
||||
// (novox/hq 04-ISSUES/219).
|
||||
Asked: kept.Asked,
|
||||
}
|
||||
if result.Source != nil && result.Source.Seat != "" {
|
||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||
}
|
||||
// **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215):
|
||||
// the commit is built and recorded as what it was built from, and the module keeps following
|
||||
// what it followed before — the repository's default branch for one new to the catalogue.
|
||||
if followedBranch(result.Ref) == "" && result.Ref != "" {
|
||||
recorded.Ref = ""
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||
recorded.Ref = followedBranch(was.Ref)
|
||||
}
|
||||
}
|
||||
trunk := result.Trunk
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" {
|
||||
trunk = followedBranch(was.Ref)
|
||||
}
|
||||
if trunk == "" {
|
||||
trunk = "main"
|
||||
}
|
||||
j := takenBuild{kept: kept, manifest: manifest, recorded: recorded, ask: byBuild(result.ID), path: result.Path,
|
||||
trunk: trunk, by: result.On, what: result.Repository, commit: result.Commit}
|
||||
// The checks only an outcome of build-agent needs: a key is a trunk build by construction (ADR 0501 §2), in no
|
||||
// scratch namespace, and carries no list of its own.
|
||||
j.published = func() error {
|
||||
// **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's
|
||||
// default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay
|
||||
// --register` of one — is for checking, and is never a module's version; nothing could then send it.
|
||||
// The branch the module already follows is its trunk — never the branch a build was asked at, or a
|
||||
// build of a feature branch by name would make that branch its trunk.
|
||||
follows := ""
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||
follows = followedBranch(was.Ref)
|
||||
}
|
||||
if err := publishable(result, follows); err != nil {
|
||||
return err
|
||||
}
|
||||
// **Never a build in the scratch namespace** (novox/hq ADR 0239 §9, ADR 0350 §2): what a check builds is
|
||||
// registered only as the walk's copy in the module's own namespace.
|
||||
if scratch := inScratch(result.Made); scratch != "" {
|
||||
return fmt.Errorf("%s is in the scratch namespace, which only a walk's copy leaves (ADR 0350)", scratch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
j.carried = func() error {
|
||||
// **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.
|
||||
carried, err := carriedList(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
manifest, err = registerJudged(ctx, inv, j)
|
||||
return manifest, kept, err
|
||||
}
|
||||
|
||||
// takenBuild is a build the controller judges and registers, whatever told it of the build: build-agent's outcome (the
|
||||
// builder's own module's, until R4g, ADR 0453 §5) or the builder's done key (ADR 0501 §1). **One judging for both**:
|
||||
// the rules of who may register, the gate a build failed, the predefined keys and the rule that an unchanged build
|
||||
@@ -643,44 +209,6 @@ func registerJudged(ctx context.Context, inv *inventory.Inventory, j takenBuild)
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
// inScratch is the first artifact a build made into the scratch namespace; empty when none is.
|
||||
func inScratch(made []link.MadeArtifact) string {
|
||||
for _, m := range made {
|
||||
if path, kept := catalogue.InArtifactStore(catalogue.Recorded(m.Reference)); kept && strings.HasPrefix(path, builder.ScratchPrefix) {
|
||||
return m.Reference
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published.
|
||||
var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " +
|
||||
"never published (ADR 0238) — merge it, and the merge builds it")
|
||||
|
||||
// publishable says whether a build's outcome may become a module's version: its commit on the module's
|
||||
// trunk, as the build seat read the forge at the build — the branch the module follows when its source
|
||||
// names one, else its repository's default branch. A build seat that could not say — one older than the
|
||||
// rule, building its own successor — is let through and said, so the rule can reach the mesh.
|
||||
func publishable(result link.BuildResult, follows string) error {
|
||||
if result.Check != nil || result.Checked != nil || result.DryRun {
|
||||
return errors.New("a check or a dry run is never published")
|
||||
}
|
||||
if result.Trunk == "" {
|
||||
fmt.Printf("%s: the build seat did not say whether %s is on its repository's trunk (it predates ADR 0238); "+
|
||||
"registered as before\n", result.ID, short(result.Commit))
|
||||
return nil
|
||||
}
|
||||
trunk := result.Trunk
|
||||
if follows != "" {
|
||||
trunk = follows
|
||||
}
|
||||
on := slices.Contains(result.Branches, trunk) || (trunk == result.Trunk && result.OnTrunk)
|
||||
if !on {
|
||||
return fmt.Errorf("%w (%s is not on %s)", errOffTheTrunk, short(result.Commit), trunk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// answers is what the three questions came back with, read once.
|
||||
type answers struct {
|
||||
// notAnnounced are the owners some module reads the state of that have announced nothing yet (novox/hq ADR 0495,
|
||||
@@ -703,8 +231,6 @@ type answers struct {
|
||||
reported []inventory.Reported
|
||||
// plans is what the last merges produced and where each stands (novox/hq ADR 0162).
|
||||
plans []inventory.Plan
|
||||
// paused is whether the build seat takes work, which a plan waiting on it says (ADR 0219).
|
||||
paused pauseView
|
||||
// tierBounds is how long each repository's plan tier may take before it is late (novox/hq issue 296).
|
||||
tierBounds tierBounds
|
||||
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
||||
@@ -766,185 +292,3 @@ type answers struct {
|
||||
lastSent map[string]inventory.Send
|
||||
lastSentUnread string
|
||||
}
|
||||
|
||||
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||
//
|
||||
// **A failure here is not a failure to build.** A module that names no base does not need this at
|
||||
// all, and one that does gets a refusal naming exactly what is missing — which is a better sentence
|
||||
// than a build command refusing to start because a query did not run. So the store not opening is
|
||||
// reported and the build goes ahead without it.
|
||||
//
|
||||
// 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.
|
||||
// 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 {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read what this mesh has built, so a module naming a "+
|
||||
"base will be told that base is missing: %v\n", err)
|
||||
// empty-on-error: said above; a build that names a base is refused by name for want of it
|
||||
return nil
|
||||
}
|
||||
defer open.Close()
|
||||
held, err := open.inventory.Held(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read what this mesh has built: %v\n", err)
|
||||
// empty-on-error: said above; a build that names a base is refused by name for want of it
|
||||
return nil
|
||||
}
|
||||
address, err := whereABuilderReachesTheStore(ctx, open.inventory)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not find the artifact store on this mesh's network, so a "+
|
||||
"module naming a base will be handed a reference nothing can fetch: %v\n", err)
|
||||
return held
|
||||
}
|
||||
if address == "" {
|
||||
return held
|
||||
}
|
||||
routed := make(map[string]string, len(held))
|
||||
for repository, reference := range held {
|
||||
routed[repository] = catalogue.Rerouted(reference, address)
|
||||
}
|
||||
return routed
|
||||
}
|
||||
|
||||
// askOver opens the way a build is asked for, on whichever bus the mesh is on.
|
||||
//
|
||||
// **One place chooses**, as everywhere else the bus change went (novox/hq ADR 0116 step 5). On the bus
|
||||
// the mesh runs on today this needs the controller's own connection, so it is handed one; on the bus
|
||||
// being built it dials, because a build request is a one-shot and holds nothing else.
|
||||
func askOverOn(seat string) (link.Builders, error) {
|
||||
// The serving controller asks on its own connection (novox/hq issue 327).
|
||||
if serving := servingBus.Load(); serving != nil {
|
||||
return link.BuildsOn(serving, seat), nil
|
||||
}
|
||||
address, err := broker.BusAddress()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return link.BuildsOverNATSOn(address, seat)
|
||||
}
|
||||
|
||||
// buildSeatHeld is the build role to ask: the one some assigned module claims (novox/hq ADR 0190,
|
||||
// the handover). Read from the catalogue at ask time, because the answer changes exactly once, the
|
||||
// moment the first build-agent is assigned — and a controller that asked the new role before then
|
||||
// would queue work nothing takes, while the outcome that registers build-agent itself has to come
|
||||
// from the old builder. When the catalogue cannot be read the current role is asked, said aloud.
|
||||
func buildSeatHeld(ctx context.Context) string {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read what is assigned, so the build is asked of %s: %v\n",
|
||||
link.TheBuildMachine, err)
|
||||
return link.TheBuildMachine
|
||||
}
|
||||
defer open.Close()
|
||||
entries, err := open.inventory.Catalogued(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read the catalogue, so the build is asked of %s: %v\n",
|
||||
link.TheBuildMachine, err)
|
||||
return link.TheBuildMachine
|
||||
}
|
||||
return buildSeatAmong(entries)
|
||||
}
|
||||
|
||||
// buildSeatAmong is the rule, over what the catalogue holds: the current build role when any
|
||||
// assigned module claims it; else the retired role while an assigned module still claims that; else
|
||||
// the current role, which is where every ask goes once the handover is done.
|
||||
func buildSeatAmong(entries []inventory.Entry) string {
|
||||
heldBefore := false
|
||||
for _, e := range entries {
|
||||
if len(e.On) == 0 {
|
||||
continue
|
||||
}
|
||||
if e.Manifest.ClaimsSeat(link.TheBuildMachine) {
|
||||
return link.TheBuildMachine
|
||||
}
|
||||
if e.Manifest.ClaimsSeat(link.TheBuildMachineBefore) {
|
||||
heldBefore = true
|
||||
}
|
||||
}
|
||||
if heldBefore {
|
||||
return link.TheBuildMachineBefore
|
||||
}
|
||||
return link.TheBuildMachine
|
||||
}
|
||||
|
||||
// buildLog prints everything a build machine said about one build, read back from the bus.
|
||||
//
|
||||
// **From the stream, not from a record** (novox/hq ADR 0157). A build's lines are the role's own
|
||||
// events under the build's id, retained with every other event; the mesh keeps no second copy. Read
|
||||
// with a consumer of its own that is gone when this returns, so nothing accumulates in the server
|
||||
// for the reading, and filtered by subject, so one build's lines are all that travel.
|
||||
func buildLog(ctx context.Context, id string) error {
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reach the bus to read a build's log: %w", err)
|
||||
}
|
||||
defer js.Close()
|
||||
|
||||
// Under whichever build role did it: a build asked of the retired role during the handover
|
||||
// (ADR 0190) said its lines as that role's events, and a reader should not have to know which.
|
||||
lines := link.BuildLogOf("*", id)
|
||||
sub, err := js.Context().PullSubscribe(lines, "",
|
||||
nats.BindStream(broker.EventsStream), nats.DeliverAll(), nats.AckNone())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read %s from the bus: %w", lines, err)
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
printed := 0
|
||||
for {
|
||||
batch, err := sub.Fetch(200, nats.MaxWait(2*time.Second))
|
||||
if err != nil && !errors.Is(err, nats.ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("reading a build's log: %w", err)
|
||||
}
|
||||
for _, msg := range batch {
|
||||
var line link.BuildLine
|
||||
if err := json.Unmarshal(msg.Data, &line); err != nil {
|
||||
fmt.Printf(" ? %s\n", string(msg.Data))
|
||||
continue
|
||||
}
|
||||
at := line.At
|
||||
if t, err := time.Parse(time.RFC3339Nano, line.At); err == nil {
|
||||
at = t.Local().Format("15:04:05")
|
||||
}
|
||||
fmt.Printf("%s %4d [%s] %s\n", at, line.Seq, line.Step, line.Message)
|
||||
printed++
|
||||
}
|
||||
if len(batch) < 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if printed == 0 {
|
||||
fmt.Printf("nothing on the bus for build %s: no build by that id in the last week, or a build "+
|
||||
"machine older than this that said nothing while building\n", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
func claiming(module, seat string, on ...string) inventory.Entry {
|
||||
return inventory.Entry{
|
||||
Manifest: catalogue.Manifest{Module: module, Claims: []catalogue.Claim{{Name: seat}}},
|
||||
On: on,
|
||||
}
|
||||
}
|
||||
|
||||
// The controller asks the build role that has a holder (novox/hq ADR 0190 handover): the retired
|
||||
// one while only the builder is assigned, the current one from the first build-agent on, and the
|
||||
// current one when nothing holds either — where every ask goes once the handover is done.
|
||||
func TestTheControllerAsksTheBuildRoleThatHasAHolder(t *testing.T) {
|
||||
onlyTheBuilder := []inventory.Entry{
|
||||
claiming("builder", link.TheBuildMachineBefore, "anchor"),
|
||||
claiming("build-agent", link.TheBuildMachine), // registered, assigned nowhere yet
|
||||
}
|
||||
if got := buildSeatAmong(onlyTheBuilder); got != link.TheBuildMachineBefore {
|
||||
t.Errorf("with only the builder assigned, asked %q", got)
|
||||
}
|
||||
bothHeld := []inventory.Entry{
|
||||
claiming("builder", link.TheBuildMachineBefore, "anchor"),
|
||||
claiming("build-agent", link.TheBuildMachine, "home-server"),
|
||||
}
|
||||
if got := buildSeatAmong(bothHeld); got != link.TheBuildMachine {
|
||||
t.Errorf("with a build-agent assigned anywhere, asked %q", got)
|
||||
}
|
||||
neither := []inventory.Entry{claiming("builder", link.TheBuildMachineBefore)}
|
||||
if got := buildSeatAmong(neither); got != link.TheBuildMachine {
|
||||
t.Errorf("with no holder of either, asked %q, want the current role", got)
|
||||
}
|
||||
if got := buildSeatAmong(nil); got != link.TheBuildMachine {
|
||||
t.Errorf("an empty catalogue asks %q", got)
|
||||
}
|
||||
}
|
||||
@@ -188,19 +188,6 @@ func (f *sourceForms) same(aRepository, aSeat, bRepository, bSeat string) bool {
|
||||
return f.canonical(aRepository, aSeat) == f.canonical(bRepository, bSeat)
|
||||
}
|
||||
|
||||
// buildsFrom says the catalogue builds some module from this repository.
|
||||
func (f *sourceForms) buildsFrom(entries []inventory.Entry, repository, seat string) bool {
|
||||
for _, e := range entries {
|
||||
if e.Provided || e.Source.Repository == "" {
|
||||
continue
|
||||
}
|
||||
if f.same(e.Source.Repository, e.Source.Seat, repository, seat) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// forgeFacts is what the mesh's forge says of a repository a module is registered from (novox/hq ADR 0266).
|
||||
type forgeFacts struct {
|
||||
// ID is the forge's own id of the repository: what tells it from one deleted and made again by its name.
|
||||
@@ -489,9 +476,6 @@ type registrationAsk struct {
|
||||
Terminal bool
|
||||
}
|
||||
|
||||
// byBuild is the warrant of a build's outcome: read from the request kept under its id.
|
||||
func byBuild(id string) registrationAsk { return registrationAsk{Build: id} }
|
||||
|
||||
// String is the ask as the registration's words name it.
|
||||
func (a registrationAsk) String() string {
|
||||
switch {
|
||||
@@ -697,29 +681,3 @@ func sourceWords(repository, seat string) string {
|
||||
}
|
||||
return buildSource{Repository: repository, Seat: seat}.String()
|
||||
}
|
||||
|
||||
// verbMayAsk refuses, for a call through a verb, a build of a repository the catalogue builds no module from
|
||||
// (novox/hq ADR 0266): the build node would run what an agent wrote, and its outcome could never be
|
||||
// registered anyway. url is the repository as it is cloned. At the terminal anything may be asked.
|
||||
func verbMayAsk(ctx context.Context, source buildSource, url, path string) error {
|
||||
if startedAtTheTerminal() {
|
||||
return nil
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
entries, err := open.inventory.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forms := newSourceForms(ctx, open.inventory)
|
||||
if forms.buildsFrom(entries, source.Repository, source.Seat) || forms.buildsFrom(entries, url, "") {
|
||||
return nil
|
||||
}
|
||||
return terminalRefusal("%s is no repository the catalogue builds a module from, and a build of any other is "+
|
||||
"asked at the controller's terminal only, never through a verb: a build node runs what the repository "+
|
||||
"says, and its outcome would register a module the next push sends — whoever may call a verb includes "+
|
||||
"agents (novox/hq ADR 0266). Nothing was asked", source)
|
||||
}
|
||||
|
||||
@@ -129,27 +129,6 @@ func TestADeliveryFromTheRegisteredRepositoryIsRegistered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVerbAsksNoBuildOfARepositoryTheCatalogueDoesNotBuildFrom(t *testing.T) {
|
||||
theCatalogue(t)
|
||||
ctx := t.Context()
|
||||
t.Setenv(verbVar, "build")
|
||||
t.Setenv(link.CallerVar, "node-tools.anchor, through the mesh-controller seat")
|
||||
err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "http://forge.internal:20000/agent/sudo.git", "")
|
||||
var policy *heldAtTheTerminal
|
||||
if !errors.As(err, &policy) {
|
||||
t.Fatalf("a verb's build of an agent's repository was asked: %v", err)
|
||||
}
|
||||
if err := verbMayAsk(ctx, buildSource{Repository: "novox/mesh-catalog", Seat: "git"},
|
||||
"http://forge.internal:20000/novox/mesh-catalog.git", ""); err != nil {
|
||||
t.Fatalf("a verb's build of the catalogue repository was refused: %v", err)
|
||||
}
|
||||
t.Setenv(verbVar, "")
|
||||
t.Setenv(link.CallerVar, "")
|
||||
if err := verbMayAsk(ctx, buildSource{Repository: "agent/sudo", Seat: "git"}, "", ""); err != nil {
|
||||
t.Fatalf("the terminal was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARollbackNeverPutsBackABuildFromAnotherRepository(t *testing.T) {
|
||||
open := theCatalogue(t)
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -2,12 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -68,12 +66,3 @@ func TestBuildsSaysTheSourceFingerprint(t *testing.T) {
|
||||
t.Errorf("the other lines changed:\n%s\nwant\n%s", plain.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1215/AC4: what the builder said of the inputs is what the controller keeps of the build.
|
||||
func TestTheControllerKeepsTheInputsABuilderSaid(t *testing.T) {
|
||||
in := &sdkdelivery.SourceInputs{Module: "mailu", Tree: "t", Toolchains: map[string]string{"code": "ts sha256:aa 01"}}
|
||||
kept := buildFrom(link.BuildResult{ID: "build-1", Repository: "r", Commit: "c", SourceFingerprint: "src1:x", SourceInputs: in})
|
||||
if !reflect.DeepEqual(kept.SourceInputs, in) {
|
||||
t.Errorf("the controller keeps %+v, the builder said %+v", kept.SourceInputs, in)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -106,34 +105,5 @@ func (b builderOnTheBus) built(t *testing.T, module, version, fp, manifest, stat
|
||||
}
|
||||
}
|
||||
|
||||
// noBuildAsked fails the test if anything asks build-agent for a build (ADR 0501 §6, ADR 0503 §6).
|
||||
func noBuildAsked(t *testing.T) {
|
||||
t.Helper()
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) {
|
||||
t.Errorf("a build was asked of build-agent, at %q", path)
|
||||
return "", context.Canceled
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
}
|
||||
|
||||
// asksWithPaths records every build a merge asks for, as repository, path and ref. The merge window's goroutine and
|
||||
// the test's own may ask at once, so the record is written under a lock (issue 1543).
|
||||
func asksWithPaths(t *testing.T) *[][3]string {
|
||||
t.Helper()
|
||||
asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): these tests are of what a walk does around it
|
||||
var asked [][3]string
|
||||
var mu sync.Mutex
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
asked = append(asked, [3]string{source.Repository, path, ref})
|
||||
return "b-" + path, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
return &asked
|
||||
}
|
||||
|
||||
// theIndexedModule is the look at the indexer as the controller makes it, kept before any test replaces it.
|
||||
var theIndexedModule = indexedModule
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
import "context"
|
||||
|
||||
// A test's builder says it writes references, as R4e's does, unless the test says otherwise (issue 1886): no test reads
|
||||
// the bus for it.
|
||||
func init() {
|
||||
builderSaysReferences = func(context.Context) (bool, error) { return true, nil }
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// theBuilderSays sets what the running builder says for one test.
|
||||
func theBuilderSays(t *testing.T, says bool, err error) {
|
||||
t.Helper()
|
||||
was := builderSaysReferences
|
||||
builderSaysReferences = func(context.Context) (bool, error) { return says, err }
|
||||
t.Cleanup(func() { builderSaysReferences = was })
|
||||
}
|
||||
|
||||
// askingBuildAgent records the modules build-agent is asked for, by path.
|
||||
func askingBuildAgent(t *testing.T) *[]string {
|
||||
t.Helper()
|
||||
var asked []string
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) {
|
||||
asked = append(asked, path)
|
||||
return "build-9", nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
return &asked
|
||||
}
|
||||
|
||||
// 1886/AC2: while no running builder says it writes references, a walk asks build-agent for a module of its tier, as
|
||||
// before R4e, and takes nothing from the builder's key.
|
||||
func TestAWalkAsksBuildAgentWhileTheBuilderDoesNotSayItWritesReferences(t *testing.T) {
|
||||
theBuilderSays(t, false, nil)
|
||||
asked := askingBuildAgent(t)
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
askModule(t.Context(), p, "sudo", byName)
|
||||
s := p.Modules["sudo"]
|
||||
if s.Key || s.Build != "build-9" || s.State != "asked" || len(*asked) != 1 {
|
||||
t.Fatalf("the module is not asked of build-agent: %+v, asked %v", s, *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// 1886/AC3: once a running builder says it writes references, a walk takes the module from the builder's key (ADR
|
||||
// 0498 §8) and asks build-agent for nothing.
|
||||
func TestAWalkTakesTheKeyOnceTheBuilderSaysItWritesReferences(t *testing.T) {
|
||||
theBuilderSays(t, true, nil)
|
||||
asked := askingBuildAgent(t)
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
askModule(t.Context(), p, "sudo", byName)
|
||||
if s := p.Modules["sudo"]; !s.Key || s.Build != "" || len(*asked) != 0 {
|
||||
t.Fatalf("the module is not taken from its key: %+v, asked %v", s, *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// 1886/AC2: a say that cannot be read leaves the module unasked and says why, so the next look asks it: neither path is
|
||||
// guessed.
|
||||
func TestASayNotReadLeavesTheModuleUnasked(t *testing.T) {
|
||||
theBuilderSays(t, false, errors.New("the bus did not answer"))
|
||||
asked := askingBuildAgent(t)
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
askModule(t.Context(), p, "sudo", byName)
|
||||
s := p.Modules["sudo"]
|
||||
if s.State != "" || s.Key || len(*asked) != 0 || !strings.Contains(s.Why, "the bus did not answer") {
|
||||
t.Fatalf("a say not read: %+v, asked %v", s, *asked)
|
||||
}
|
||||
if got := unaskedOf(*p); len(got) != 1 {
|
||||
t.Fatalf("the module is not left for the next look to ask: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1886/AC2: the one function every ask goes through lets build-agent build any module while the builder does not say
|
||||
// it writes references, and refuses it again once it does.
|
||||
func TestBuildAgentIsAskedForAnyModuleWhileTheBuilderDoesNotSay(t *testing.T) {
|
||||
was := askedModuleOf
|
||||
t.Cleanup(func() { askedModuleOf = was })
|
||||
askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) {
|
||||
return strings.TrimPrefix(path, "modules/"), nil
|
||||
}
|
||||
theBuilderSays(t, false, nil)
|
||||
if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo"); err != nil {
|
||||
t.Fatalf("an ask while the builder does not say is refused: %v", err)
|
||||
}
|
||||
theBuilderSays(t, true, nil)
|
||||
if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo"); !errors.Is(err, errNotTheBuildersOwn) {
|
||||
t.Fatalf("an ask once the builder says is not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1886/AC4: an open walk that waits on a key the running builder cannot reference (done without a reference, as R4e's
|
||||
// own walk did in plan-1792707174627079192) is judged again at the next look: the module is handed back unasked, and
|
||||
// that look's ask goes to build-agent. With the builder saying it writes references, the same walk keeps waiting.
|
||||
func TestAnOpenWalkWaitingOnAnUnreferencedKeyIsAskedOfBuildAgent(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
keyTakes.Lock()
|
||||
keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{}
|
||||
keyTakes.Unlock()
|
||||
})
|
||||
fp := "src2:" + strings.Repeat("e", 64)
|
||||
unreferenced := builderkeys.Key{Module: "sudo", Fingerprint: fp, Found: true, Revision: 1,
|
||||
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildDone, Manifest: strings.Repeat("b", 64),
|
||||
Artifacts: []sdkdelivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: "sha256:1"}}}}
|
||||
walk := func() (*inventory.Plan, map[string]inventory.Entry, planKeysOf) {
|
||||
theBuilderSays(t, true, nil)
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
askModule(t.Context(), p, "sudo", byName)
|
||||
keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}},
|
||||
keys: map[string]builderkeys.Key{"sudo": unreferenced}}
|
||||
return p, byName, keys
|
||||
}
|
||||
take := func(string, sdkdelivery.Module, string) { t.Fatal("an unreferenced key was taken in") }
|
||||
records := recordsOf{builds: map[string]inventory.Build{}}
|
||||
|
||||
// Said: it waits for the reference, as ADR 0498 §8 has it.
|
||||
p, _, keys := walk()
|
||||
if _, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := p.Modules["sudo"]; s.State != "asked" || !s.Key || !strings.Contains(s.Why, "waits for the reference") {
|
||||
t.Fatalf("with the builder saying, the walk does not wait for the reference: %+v", s)
|
||||
}
|
||||
|
||||
// Not said: handed back, and the next look asks build-agent.
|
||||
p, byName, keys := walk()
|
||||
theBuilderSays(t, false, nil)
|
||||
asked := askingBuildAgent(t)
|
||||
changed, err := settleKeys(t.Context(), records, keys, p, p.Tiers[0], take)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("the walk was not judged again: %v %v", changed, err)
|
||||
}
|
||||
s := p.Modules["sudo"]
|
||||
if s.Key || s.State != "" || p.State != inventory.PlanBuilding || !strings.Contains(s.Why, "does not say") {
|
||||
t.Fatalf("the module is not handed back to be asked of build-agent: %+v %s", s, p.State)
|
||||
}
|
||||
for _, m := range unaskedOf(*p) {
|
||||
askModule(t.Context(), p, m, byName)
|
||||
}
|
||||
if s := p.Modules["sudo"]; s.Key || s.Build != "build-9" || len(*asked) != 1 {
|
||||
t.Fatalf("the next look did not ask build-agent: %+v, asked %v", s, *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// 1886/AC4: a key the running builder failed while it does not say it writes references fails no walk: the module is
|
||||
// asked of build-agent, whose build is the one delivered.
|
||||
func TestAFailedKeyOfABuilderThatDoesNotSayFailsNoWalk(t *testing.T) {
|
||||
theBuilderSays(t, true, nil)
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
askModule(t.Context(), p, "sudo", byName)
|
||||
_ = byName
|
||||
fp := "src2:" + strings.Repeat("f", 64)
|
||||
theBuilderSays(t, false, nil)
|
||||
keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}},
|
||||
keys: map[string]builderkeys.Key{"sudo": {Module: "sudo", Fingerprint: fp, Found: true,
|
||||
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildFailed, Error: "not stored: tools: no reference"}}}}
|
||||
if _, err := settleKeys(t.Context(), recordsOf{}, keys, p, p.Tiers[0], func(string, sdkdelivery.Module, string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := p.Modules["sudo"]; p.State != inventory.PlanBuilding || s.State != "" || s.Key {
|
||||
t.Fatalf("a failed key of a builder that does not say failed the walk: %s %q %+v", p.State, p.Note, s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1886/AC6 (ADR 0522 §6): the parts that write references, the builder and the build modules, are build-agent's own until
|
||||
// R4g: asked of build-agent and never taken from keys they write themselves, whatever the builder says.
|
||||
func TestTheReferenceWritersAreAskedOfBuildAgentWhateverTheBuilderSays(t *testing.T) {
|
||||
theBuilderSays(t, true, nil)
|
||||
asked := askingBuildAgent(t)
|
||||
for _, m := range []string{"mesh-build-go", "mesh-build-node", "mesh-build-image", "mesh-delivery-builder"} {
|
||||
p, byName := aWalkOfOne(m)
|
||||
askModule(t.Context(), p, m, byName)
|
||||
if s := p.Modules[m]; s.Key || s.Build != "build-9" {
|
||||
t.Errorf("%s is not asked of build-agent: %+v", m, s)
|
||||
}
|
||||
}
|
||||
if len(*asked) != 4 {
|
||||
t.Fatalf("build-agent was asked %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// 1886 review round 1 (ADR 0522 §2): the guard of the one ask function judges by the say askModule read, never a second
|
||||
// read: a second read that fails, or that says otherwise, fails no plan.
|
||||
func TestTheAskGuardUsesTheSayTheWalkRead(t *testing.T) {
|
||||
was := askedModuleOf
|
||||
t.Cleanup(func() { askedModuleOf = was })
|
||||
askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) {
|
||||
return strings.TrimPrefix(path, "modules/"), nil
|
||||
}
|
||||
reads := 0
|
||||
wasSays := builderSaysReferences
|
||||
t.Cleanup(func() { builderSaysReferences = wasSays })
|
||||
for _, second := range []struct {
|
||||
name string
|
||||
says bool
|
||||
err error
|
||||
}{{"a second read that fails", false, errors.New("the bus did not answer")}, {"a say that turned true", true, nil}} {
|
||||
reads = 0
|
||||
builderSaysReferences = func(context.Context) (bool, error) {
|
||||
reads++
|
||||
if reads == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return second.says, second.err
|
||||
}
|
||||
wasAsk := askABuild
|
||||
askABuild = func(ctx context.Context, source buildSource, path, _ string) (string, error) {
|
||||
if err := onlyTheBuildersOwn(ctx, source, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "build-9", nil
|
||||
}
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
askModule(t.Context(), p, "sudo", byName)
|
||||
askABuild = wasAsk
|
||||
if s := p.Modules["sudo"]; p.State == inventory.PlanFailed || s.Build != "build-9" || s.Key {
|
||||
t.Errorf("%s: the walk %s, the module %+v", second.name, p.State, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1886 merge check hang: the look must judge an ask and a settle by one say. A settle that read the say from another
|
||||
// source than the ask (the bus's bucket, against the walk's read) handed a module back, the next ask took it from its key
|
||||
// again, and the look's loop never ended: walk_from_keys_integration_test hung.
|
||||
func TestTheSettleJudgesByTheSameSayAsTheAsk(t *testing.T) {
|
||||
theBuilderSays(t, true, nil)
|
||||
p, _ := aWalkOfOne("sudo")
|
||||
p.Modules["sudo"] = &inventory.PlanModule{}
|
||||
askModule(t.Context(), p, "sudo", map[string]inventory.Entry{"sudo": {Source: inventory.Source{Repository: "novox/mesh-catalog", Path: "modules/sudo", Ref: "main"}}})
|
||||
fp := "src2:" + strings.Repeat("1", 64)
|
||||
keys := planKeysOf{values: map[string]sdkdelivery.Module{"sudo@c0ffee0123456789": {FP2: fp}},
|
||||
keys: map[string]builderkeys.Key{"sudo": {Module: "sudo", Fingerprint: fp, Found: true,
|
||||
Build: sdkdelivery.BuilderBuild{State: sdkdelivery.BuildBuilding, By: "shanks"}}}}
|
||||
if _, err := settleKeys(t.Context(), recordsOf{}, keys, p, p.Tiers[0], func(string, sdkdelivery.Module, string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := p.Modules["sudo"]; !s.Key || s.State != "asked" {
|
||||
t.Fatalf("the settle judged by another say than the ask's: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A12 (ADR 0453 §5, ADR 0498 §7; issue 1851): the one function every ask goes through refuses any module but the
|
||||
// builder's own, naming the builder's key it is taken from instead; the builder's own module passes the refusal.
|
||||
func TestBuildAgentIsAskedForTheBuildersOwnModuleAlone(t *testing.T) {
|
||||
was := askedModuleOf
|
||||
t.Cleanup(func() { askedModuleOf = was })
|
||||
askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) {
|
||||
return strings.TrimPrefix(path, "modules/"), nil
|
||||
}
|
||||
_, err := askABuild(t.Context(), buildSource{Repository: "novox/mesh-catalog", Seat: "git"}, "modules/sudo", "main")
|
||||
if !errors.Is(err, errNotTheBuildersOwn) || !strings.Contains(err.Error(), "sudo is taken from the builder's done key") ||
|
||||
!strings.Contains(err.Error(), "mesh-delivery-builder_builds") {
|
||||
t.Fatalf("an ask of another module: %v", err)
|
||||
}
|
||||
if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/mesh-delivery", Seat: "git"},
|
||||
"modules/mesh-delivery-builder"); err != nil {
|
||||
t.Fatalf("the builder's own module is refused: %v", err)
|
||||
}
|
||||
askedModuleOf = func(context.Context, buildSource, string) (string, error) { return "", errors.New("no store") }
|
||||
if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x"}, ""); !errors.Is(err, errNotTheBuildersOwn) {
|
||||
t.Fatalf("an ask whose module cannot be read went on: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A12: a walk of the builder's own module and one other asks build-agent for the first alone; the other is taken from
|
||||
// its key.
|
||||
func TestAWalkAsksBuildAgentForTheBuildersOwnModuleAlone(t *testing.T) {
|
||||
var asked []string
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) {
|
||||
asked = append(asked, path)
|
||||
return "build-1", nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
p.Tiers = [][]string{{"mesh-delivery-builder", "sudo"}}
|
||||
byName["mesh-delivery-builder"] = byName["sudo"]
|
||||
e := byName["mesh-delivery-builder"]
|
||||
e.Source.Repository, e.Source.Path = "novox/mesh-delivery", "modules/mesh-delivery-builder"
|
||||
byName["mesh-delivery-builder"] = e
|
||||
for _, m := range p.Tiers[0] {
|
||||
askModule(t.Context(), p, m, byName)
|
||||
}
|
||||
if len(asked) != 1 || asked[0] != "modules/mesh-delivery-builder" {
|
||||
t.Fatalf("build-agent was asked for %v, want the builder's own module alone", asked)
|
||||
}
|
||||
if s := p.Modules["mesh-delivery-builder"]; s.Key || s.Build != "build-1" {
|
||||
t.Fatalf("the builder's own module is not asked: %+v", s)
|
||||
}
|
||||
if s := p.Modules["sudo"]; !s.Key || s.Build != "" {
|
||||
t.Fatalf("the other module is not taken from its key: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0520 §1 (issue 1851): until R4g build-agent and mesh-tools, the toolchain image its builds stand on, are build-agent's
|
||||
// own, as the builder's module is: the builder writes no key of them, so a walk asks build-agent for them and reads no key;
|
||||
// mesh-tools-go (its key from mesh-build-image) and any other module of the same tier are still taken from their keys.
|
||||
func TestAWalkAsksBuildAgentForItselfAndMeshToolsAndReadsNoKeyOfThem(t *testing.T) {
|
||||
own := []string{"build-agent", "mesh-tools"}
|
||||
keyed := []string{"mesh-tools-go", "sudo"}
|
||||
wasModule := askedModuleOf
|
||||
askedModuleOf = func(_ context.Context, _ buildSource, path string) (string, error) {
|
||||
return strings.TrimPrefix(path, "modules/"), nil
|
||||
}
|
||||
t.Cleanup(func() { askedModuleOf = wasModule })
|
||||
for _, m := range own {
|
||||
if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x", Seat: "git"}, "modules/"+m); err != nil {
|
||||
t.Fatalf("an ask of %s is refused: %v", m, err)
|
||||
}
|
||||
}
|
||||
if err := onlyTheBuildersOwn(t.Context(), buildSource{Repository: "novox/x", Seat: "git"}, "modules/mesh-tools-go"); !errors.Is(err, errNotTheBuildersOwn) {
|
||||
t.Fatalf("an ask of mesh-tools-go went on: %v", err)
|
||||
}
|
||||
var asked []string
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, _ buildSource, path, _ string) (string, error) {
|
||||
asked = append(asked, path)
|
||||
return "build-" + strings.TrimPrefix(path, "modules/"), nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
p.Tiers = [][]string{append(append([]string{}, own...), keyed...)}
|
||||
for _, m := range append(append([]string{}, own...), keyed...) {
|
||||
e := byName["sudo"]
|
||||
e.Source.Path = "modules/" + m
|
||||
byName[m] = e
|
||||
}
|
||||
for _, m := range p.Tiers[0] {
|
||||
askModule(t.Context(), p, m, byName)
|
||||
}
|
||||
if strings.Join(asked, " ") != "modules/build-agent modules/mesh-tools" {
|
||||
t.Fatalf("build-agent was asked for %v", asked)
|
||||
}
|
||||
for _, m := range own {
|
||||
if s := p.Modules[m]; s.Key || s.Build != "build-"+m || s.State != "asked" {
|
||||
t.Fatalf("%s is not asked of build-agent, or is read from a key: %+v", m, s)
|
||||
}
|
||||
}
|
||||
for _, m := range keyed {
|
||||
if s := p.Modules[m]; !s.Key || s.Build != "" {
|
||||
t.Fatalf("%s is not taken from its key: %+v", m, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/chain"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// **Acceptance 10 of design 47: one read of an assignment names the commit, the build and the rollout that put it there,
|
||||
@@ -80,7 +79,7 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ {
|
||||
for try := 0; try < 6 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == "" || asking.Modules["notes"].State == "asked"); try++ {
|
||||
advancePlans(ctx, open)
|
||||
if asking, err = inv.PlanByID(ctx, rollout); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -90,13 +89,6 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou
|
||||
if buildID == "" || len(*asked) != 1 || !strings.HasSuffix((*asked)[0], "#"+buildID) {
|
||||
t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID)
|
||||
}
|
||||
digest := "sha256:" + strings.Repeat("a", 64)
|
||||
result := outcome(buildID, "modules/notes", "c-notes", "notes", "")
|
||||
result.ReuseKey = "rk-notes"
|
||||
result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}}
|
||||
if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The walk sends the tier to its first machine.
|
||||
for try := 0; try < 3 && len(sent) == 0; try++ {
|
||||
@@ -116,9 +108,9 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou
|
||||
if wireguard, pulled := assignmentOf(on, "mesh-wireguard"); pulled && (wireguard.Build != "" || wireguard.Rollout != "") {
|
||||
t.Fatalf("a module this rollout did not send names a build or a rollout: %+v", wireguard)
|
||||
}
|
||||
if a.Module != "notes" || a.Commit != "c-notes" || a.Build != buildID || a.Rollout != rollout || a.Artifact != digest ||
|
||||
if a.Module != "notes" || a.Commit != "c-notes" || a.Build != buildID || a.Rollout != rollout ||
|
||||
a.Since.IsZero() || a.Assigned.IsZero() || !a.Stands() {
|
||||
t.Fatalf("the assignment names %+v, not commit c-notes, build %s, artifact %s, rollout %s", a, buildID, digest, rollout)
|
||||
t.Fatalf("the assignment names %+v, not commit c-notes, build %s, rollout %s", a, buildID, rollout)
|
||||
}
|
||||
// One read of the rollout.
|
||||
p, err := inv.PlanByID(ctx, rollout)
|
||||
@@ -130,16 +122,11 @@ func TestOneReadOfAnAssignmentNamesItsCommitBuildAndRolloutAndOneReadOfTheRollou
|
||||
p.Steps[0].Node != "anchor" || p.Steps[0].Sent == nil {
|
||||
t.Fatalf("the rollout lists changed %+v, builds %v, steps %+v; want %+v", p.Changed, p.Builds, p.Steps, wantChange)
|
||||
}
|
||||
// And one read of the build names the same commit, with what it published and its reuse key.
|
||||
// And one read of the build names the same commit.
|
||||
built, found, err := inv.BuildByID(ctx, buildID)
|
||||
if err != nil || !found || built.Commit != "c-notes" || built.Module != "notes" {
|
||||
t.Fatalf("the build: %+v %v %v", built, found, err)
|
||||
}
|
||||
lately, err := inv.Builds(ctx, "notes", 1)
|
||||
if err != nil || len(lately) != 1 || lately[0].ReuseKey != "rk-notes" || lately[0].Published == nil ||
|
||||
lately[0].Published.Artifact != digest {
|
||||
t.Fatalf("what the build published: %+v %v", lately, err)
|
||||
}
|
||||
// The verbs say the links.
|
||||
if lines := changedLines(p); len(lines) != 2 || !strings.Contains(lines[1], "anchor/notes") ||
|
||||
!strings.Contains(lines[1], buildID) {
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// **Acceptance 12 of design 47: no table in the controller's store holds builds, rollouts or assignments** (novox/hq ADR
|
||||
@@ -34,7 +32,7 @@ func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for try := 0; try < 3 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == ""); try++ {
|
||||
for try := 0; try < 6 && (asking.Modules["notes"] == nil || asking.Modules["notes"].Build == "" || asking.Modules["notes"].State == "asked"); try++ {
|
||||
advancePlans(ctx, open)
|
||||
if asking, err = inv.PlanByID(ctx, rollout); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -44,12 +42,6 @@ func TestNoTableOfTheControllersStoreHoldsBuildsRolloutsOrAssignments(t *testing
|
||||
if buildID == "" || len(*asked) != 1 {
|
||||
t.Fatalf("the walk asked %v, and notes' build is %q", *asked, buildID)
|
||||
}
|
||||
digest := "sha256:" + strings.Repeat("b", 64)
|
||||
result := outcome(buildID, "modules/notes", "c-notes", "notes", "")
|
||||
result.Made = []link.MadeArtifact{{Name: "notes", Kind: "image", Reference: "artifact-store://notes/notes@" + digest}}
|
||||
if err := (builds{inv: inv, open: open}).Built(ctx, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// What the live path made is in the chain.
|
||||
if built, found, err := inv.BuildByID(ctx, buildID); err != nil || !found || built.Commit != "c-notes" {
|
||||
|
||||
@@ -190,32 +190,3 @@ func summaryOf(p link.ChangePlan) string {
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// planText is a change plan as a person reads it, for the pull request's comment.
|
||||
func planText(p link.ChangePlan) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "change plan of %s at %.8s into %s: %s\n", p.Repository, p.Head, p.Base, p.Summary)
|
||||
for i, tier := range p.Tiers {
|
||||
fmt.Fprintf(&b, " tier %d: %s\n", i, strings.Join(tier, ", "))
|
||||
}
|
||||
for _, m := range p.Machines {
|
||||
line := " " + m.Machine + ": "
|
||||
if len(m.Receives) > 0 {
|
||||
line += "receives " + strings.Join(m.Receives, ", ")
|
||||
}
|
||||
if len(m.Waits) > 0 {
|
||||
if len(m.Receives) > 0 {
|
||||
line += "; "
|
||||
}
|
||||
line += "waits for a person: " + strings.Join(m.Waits, ", ")
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
for _, s := range p.Steps {
|
||||
b.WriteString(" - " + s + "\n")
|
||||
}
|
||||
if len(p.Unread) > 0 {
|
||||
fmt.Fprintf(&b, " read by no module's build: %s\n", strings.Join(p.Unread, ", "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -72,7 +72,4 @@ func TestAChangePlanSaysWhatEachMachineReceives(t *testing.T) {
|
||||
if !strings.HasPrefix(p.Summary, "builds nothing") || len(p.Machines) != 0 || strings.Join(p.Unread, ",") != "merge-check.sh" {
|
||||
t.Errorf("a root file's plan reads %+v", p)
|
||||
}
|
||||
if !strings.Contains(planText(p), "read by no module's build: merge-check.sh") {
|
||||
t.Errorf("the plan's text does not say why nothing is built:\n%s", planText(p))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// A pull request's check builds go when it closes (novox/hq ADR 0350 §5): the build seat keeps them in the artifact
|
||||
// store's scratch namespace for the walk of its merge to take. **Let go of where the moment is heard**: the
|
||||
// controller ends the walk answering a merge — after that walk took what it reuses — and the delivery's owner, which
|
||||
// hears the forge say a pull request closed unmerged, asks the `forget-checks` verb; both let go through the store,
|
||||
// as the controller's own collection does. The build seat's holders let go of anything a week old on their own,
|
||||
// so a word lost here leaves a week's builds, never more.
|
||||
|
||||
// checkBuildsIn is the scratch namespace as the controller reaches the artifact store.
|
||||
func checkBuildsIn(ctx context.Context, inv *inventory.Inventory) (builder.Scratch, error) {
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address, err := artifactStoreAddress(ctx, inv, shelf, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if address == "" {
|
||||
return nil, errors.New("the artifact store is not on the mesh's network")
|
||||
}
|
||||
return builder.Registry{Address: address}, nil
|
||||
}
|
||||
|
||||
// forgetChecks lets go of a pull request's check builds and answers what it did.
|
||||
var forgetChecks = func(ctx context.Context, inv *inventory.Inventory, pull builder.PullRef) (string, error) {
|
||||
scratch, err := checkBuildsIn(ctx, inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
n, err := scratch.Forget(ctx, pull)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: %d check(s)' builds let go of, and not all: %w", pull, n, err)
|
||||
}
|
||||
return fmt.Sprintf("%s: %d check(s)' builds let go of", pull, n), nil
|
||||
}
|
||||
|
||||
// forgetChecksOfWalk lets go, on the save that ends a walk, of the check builds of every pull request whose merge
|
||||
// it answers: the walk took what it reuses. Said, never fatal.
|
||||
func forgetChecksOfWalk(ctx context.Context, inv *inventory.Inventory, p inventory.Plan) {
|
||||
if p.WalkSaid != inventory.WalkEnded || p.Delivery == nil {
|
||||
return
|
||||
}
|
||||
for _, pull := range pullsOfWalk(&p) {
|
||||
said, err := forgetChecks(ctx, inv, builder.PullRef{Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: its check builds stay until the week's sweep: %v\n", p.ID, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%s: %s\n", p.ID, said)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
func TestAWalkNamesThePullRequestsOfItsMergesOnce(t *testing.T) {
|
||||
p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
|
||||
{Repository: "novox/shop", Number: 7}, {Repository: "http://forge.invalid/novox/web.git", Number: 3},
|
||||
{Repository: "novox/shop", Number: 7}, {Repository: "novox/shop"}}}}
|
||||
if want := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}, {Owner: "novox", Repo: "web", Number: 3}}; !reflect.DeepEqual(pullsOfWalk(p), want) {
|
||||
t.Fatalf("the walk names %+v", pullsOfWalk(p))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
ctx := t.Context()
|
||||
inv := g.open.inventory
|
||||
gateEvery = 0
|
||||
|
||||
digest := "sha256:" + strings.Repeat("c", 64)
|
||||
reused := func(id, reference string) link.BuildResult {
|
||||
manifest := mustJSON(t, catalogue.Manifest{Module: "app", Version: "c3", Resources: []map[string]any{
|
||||
{"id": "svc", "type": "container", "name": "app", "image": reference}}})
|
||||
return link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: "modules/app", Ref: "main", On: "anchor",
|
||||
Commit: "c3", Manifest: manifest, Trunk: "main", OnTrunk: true, Branches: []string{"main"},
|
||||
Made: []link.MadeArtifact{{Name: "server", Kind: "image", Reference: reference}},
|
||||
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"},
|
||||
Reused: "reused from the check of novox/mesh-catalog#9 at head abcdefabcdef (merged into trunk 123456123456): server " + digest,
|
||||
ReusedFrom: &link.ReusedFrom{Pull: link.PullRef{Owner: "novox", Repo: "mesh-catalog", Number: 9},
|
||||
Head: "abcdefabcdef", Trunk: "123456123456", Key: "r1:" + strings.Repeat("1", 64)}}
|
||||
}
|
||||
if _, _, err := takeIn(ctx, inv, reused(link.NewBuildID(time.Now()), "store.internal:5000/scratch/app/server@"+digest)); err == nil ||
|
||||
!strings.Contains(err.Error(), "scratch") {
|
||||
t.Fatalf("a build in the scratch namespace was registered: %v", err)
|
||||
}
|
||||
id := link.NewBuildID(time.Now().Add(time.Second))
|
||||
result := reused(id, "store.internal:5000/app/server@"+digest)
|
||||
if _, _, err := takeIn(ctx, inv, result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := inv.CurrentBuilds(ctx)
|
||||
if err != nil || current["app"].Commit != "c3" {
|
||||
t.Fatalf("the reused build is not what app is registered at: %+v %v", current["app"], err)
|
||||
}
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(shelf["app"].Resources)
|
||||
if !strings.Contains(string(raw), "app/server@"+digest) {
|
||||
t.Fatalf("app is registered as %s, not by the check's digest in its own namespace", raw)
|
||||
}
|
||||
|
||||
// The walk asked it: the walk keeps what its build did, and its gate judges the registered build.
|
||||
asked := time.Now().UTC()
|
||||
p := g.plan(t)
|
||||
p.Commit = "c3"
|
||||
p.Modules["app"] = &inventory.PlanModule{State: "asked", AskedAt: &asked, Build: id}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planBuiltSaying(ctx, g.open, "app", "c3", "", asked, id, result.MadeWords())
|
||||
p = g.plan(t)
|
||||
if m := p.Modules["app"]; m.State != "built" || !strings.HasPrefix(m.Made, "reused from the check of novox/mesh-catalog#9") {
|
||||
t.Fatalf("the walk keeps %+v", m)
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
advancePlans(ctx, g.open)
|
||||
}
|
||||
p = g.plan(t)
|
||||
gate := p.Modules["app"].Gate
|
||||
if gate == nil || gate.To != "c3" || gate.Verdict != inventory.GatePassed {
|
||||
t.Fatalf("the gate judged %+v, not the reused build", gate)
|
||||
}
|
||||
if sent, _, _ := inv.SentBuilds(ctx, "anchor"); sent["app"] != "c3" {
|
||||
t.Fatalf("the first machine was sent %v, not the reused build", sent)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// One build per change (novox/hq ADR 0350), as the controller asks it and takes it in.
|
||||
|
||||
// The check of a change is asked to build what a merge of it would move, in the walk's order: a module of the
|
||||
// repository checked from the merge, one standing on it from its own repository as the walk builds it, and one
|
||||
// that reads the repository through a context not at all. A change that moves no module builds nothing.
|
||||
func TestACheckIsAskedToBuildWhatTheMergeWouldMove(t *testing.T) {
|
||||
entries := []inventory.Entry{
|
||||
{Manifest: catalogue.Manifest{Module: "app"}, Source: inventory.Source{Repository: "novox/shop", Seat: "git", Path: "modules/app"}},
|
||||
{Manifest: catalogue.Manifest{Module: "web"}, Source: inventory.Source{Repository: "novox/web", Seat: "git", Ref: "main"}},
|
||||
{Manifest: catalogue.Manifest{Module: "docs"}, Source: inventory.Source{Repository: "novox/docs", Seat: "git"}},
|
||||
}
|
||||
p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Base: "main", Commit: "abc"}
|
||||
scope := checkScope{Reach: mergeReach{Packaging: entries[2:], Plan: inventory.Plan{Tiers: [][]string{{"app", "docs"}, {"web"}}}}}
|
||||
clone := func(s inventory.Source) (string, error) { return "http://forge.invalid/" + s.Repository + ".git", nil }
|
||||
|
||||
got := checkBuildsOf(p, scope, entries, clone)
|
||||
want := []link.CheckBuild{
|
||||
{Module: "app", Path: "modules/app", Merged: true, Tier: 0},
|
||||
{Module: "docs", Tier: 0, Skip: "its build reads novox/shop through a context, which a check cannot hand the merge"},
|
||||
{Module: "web", Repository: "http://forge.invalid/novox/web.git", Ref: "main", Tier: 1},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the check is asked to build\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
if got := checkBuildsOf(p, checkScope{}, entries, clone); got != nil {
|
||||
t.Fatalf("a change that moves no module asks for builds: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A walk's ask carries its leave to take a check's build, naming the pull requests of the merges it answers; a dry
|
||||
// run never does, and neither does an ask that is not a walk's.
|
||||
|
||||
// A walk ended lets go of its merges' check builds — only once it ended, after it took what it reuses.
|
||||
func TestAWalkEndedLetsGoOfItsMergesCheckBuilds(t *testing.T) {
|
||||
var forgot []builder.PullRef
|
||||
was := forgetChecks
|
||||
forgetChecks = func(_ context.Context, _ *inventory.Inventory, pull builder.PullRef) (string, error) {
|
||||
forgot = append(forgot, pull)
|
||||
return "", nil
|
||||
}
|
||||
t.Cleanup(func() { forgetChecks = was })
|
||||
p := inventory.Plan{ID: "plan-1", Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
|
||||
{Repository: "novox/shop", Number: 7}}}}
|
||||
|
||||
p.WalkSaid = inventory.WalkStarted
|
||||
forgetChecksOfWalk(t.Context(), nil, p)
|
||||
p.WalkSaid = ""
|
||||
forgetChecksOfWalk(t.Context(), nil, p)
|
||||
if len(forgot) != 0 {
|
||||
t.Fatalf("a walk not ended let go of %v, which it may still take", forgot)
|
||||
}
|
||||
p.WalkSaid = inventory.WalkEnded
|
||||
forgetChecksOfWalk(t.Context(), nil, p)
|
||||
if want := []builder.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}; !reflect.DeepEqual(forgot, want) {
|
||||
t.Fatalf("the walk ended let go of %v", forgot)
|
||||
}
|
||||
}
|
||||
|
||||
// A build in the scratch namespace is never registered: only the walk's copy in the module's own namespace is.
|
||||
func TestABuildInTheScratchNamespaceIsNeverRegistered(t *testing.T) {
|
||||
if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: "store.internal:5000/app/server@sha256:aa"}}); got != "" {
|
||||
t.Fatalf("a module's own build reads as a scratch build: %s", got)
|
||||
}
|
||||
scratch := "store.internal:5000/scratch/app/server@sha256:aa"
|
||||
if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: scratch}}); got != scratch {
|
||||
t.Fatalf("a scratch build was not found: %q", got)
|
||||
}
|
||||
if got := inScratch([]link.MadeArtifact{{Name: "look", Reference: "http://store.internal:5000/v2/scratch/app/look/blobs/sha256:bb"}}); got == "" {
|
||||
t.Fatal("a scratch archive was not found")
|
||||
}
|
||||
}
|
||||
|
||||
// The walk says per module what it did: "reused from …" or "built: <why>" — and a build seat from before says
|
||||
// neither, which is said.
|
||||
func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) {
|
||||
if got := (link.BuildResult{Reused: "reused from the check of novox/shop#7"}).MadeWords(); got != "reused from the check of novox/shop#7" {
|
||||
t.Fatalf("said %q", got)
|
||||
}
|
||||
if got := (link.BuildResult{BuiltWhy: "no check built it"}).MadeWords(); got != "built: no check built it" {
|
||||
t.Fatalf("said %q", got)
|
||||
}
|
||||
if got := (link.BuildResult{}).MadeWords(); !strings.Contains(got, "predates ADR 0350") {
|
||||
t.Fatalf("said %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A walk's module the check's build stood in for is registered by the digest the check built — the copy in the
|
||||
// module's namespace — and the walk keeps "reused from …" for it; its first-node gate judges that registered
|
||||
// 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.
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// `check-here` is a pull request's merge check run on the machine at hand **exactly as the build seat
|
||||
// runs it** (novox/hq issue 286): the same code (builder.Check), the same ask the controller would make of
|
||||
// the seat — the gate's modules and judge by the planner's own answer over the facts snapshot, the
|
||||
// repositories beside it at the refs the snapshot says the seat clones them at — in the toolchain image
|
||||
// the mesh holds, as the user the build seat runs as, against a throwaway store and bus of the versions
|
||||
// the mesh runs.
|
||||
//
|
||||
// **The build seat is the reference.** A merge-check.sh that passed on an agent's machine failed on the
|
||||
// seat for three reasons that were each the agent's environment, never the change: a newer Go whose gofmt
|
||||
// lays a file out differently, a sibling checkout at the agent's feature branch where the seat had the
|
||||
// commit the mesh runs, and a different user. Run here, a check sees what the seat will.
|
||||
//
|
||||
// check-here [--tree <checkout>] [--base main] [--registry <artifact store>] [--forge <url of the owner>]
|
||||
// [--number <n>] [--user uid:gid] [--facts store|<file>] [--keep]
|
||||
//
|
||||
// The checkout's HEAD is what is checked, and it must be committed: the seat checks a commit, never a
|
||||
// working tree.
|
||||
func checkHereCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("check-here", flag.ContinueOnError)
|
||||
tree := set.String("tree", ".", "the change's checkout; its HEAD is checked")
|
||||
base := set.String("base", "main", "the branch the change would merge into")
|
||||
registry := set.String("registry", os.Getenv("MESH_REGISTRY"), "the artifact store holding the facts and the toolchains")
|
||||
forge := set.String("forge", "", "where the repositories beside it are cloned from, as <forge>/<repository>.git; "+
|
||||
"the checkout's origin without its own name when not given")
|
||||
number := set.Int("number", 0, "the pull request's number, when there is one")
|
||||
// The build seat's service runs as root, and its check containers run as the builder does.
|
||||
user := set.String("user", "0:0", "the user the check's containers run as: the build seat's")
|
||||
keep := set.Bool("keep", false, "keep the workspace afterwards")
|
||||
factsFrom := set.String("facts", "store", "the facts snapshot: `store`, the one the artifact store holds — "+
|
||||
"what the seat reads — or a file")
|
||||
if _, err := parseAround(set, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *registry == "" {
|
||||
return errors.New("check-here reads the facts and the toolchains from the artifact store: --registry <host:port> or MESH_REGISTRY")
|
||||
}
|
||||
dir, err := filepath.Abs(*tree)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
git := func(args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
if dirty, err := git("status", "--porcelain", "--untracked-files=no"); err != nil {
|
||||
return fmt.Errorf("%s is not a checkout: %w", dir, err)
|
||||
} else if dirty != "" {
|
||||
return errors.New("the checkout has changes not committed: the build seat checks a commit, so commit first")
|
||||
}
|
||||
head, err := git("rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
origin, err := git("remote", "get-url", "origin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("the checkout has no origin to say which repository it is: %w", err)
|
||||
}
|
||||
owner, repo, prefix := ownerRepoOf(origin)
|
||||
if *forge == "" {
|
||||
*forge = prefix
|
||||
}
|
||||
if _, err := git("fetch", "--quiet", "origin", *base); err != nil {
|
||||
return fmt.Errorf("cannot fetch %s to say what the change touches: %w", *base, err)
|
||||
}
|
||||
// Without rename detection, so a file moved out of a build source is said under its old name as well:
|
||||
// its going is a change to the build that held it (novox/hq ADR 0267).
|
||||
changedText, err := git("diff", "--name-only", "--no-renames", "origin/"+*base+"...HEAD")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var paths, removed []string
|
||||
for _, p := range strings.Split(changedText, "\n") {
|
||||
if p = strings.TrimSpace(p); p == "" {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, p)
|
||||
if _, err := os.Stat(filepath.Join(dir, p)); os.IsNotExist(err) {
|
||||
removed = append(removed, p)
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.Setenv("MESH_REGISTRY", *registry)
|
||||
f, err := readFacts(ctx, *factsFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the facts snapshot cannot be read: %w", err)
|
||||
}
|
||||
entries, read, edges, err := graphOfFacts(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := link.PullUpdated{Owner: owner, Repo: repo, Number: *number, Base: *base, Commit: head, Paths: paths,
|
||||
Removed: removed, ModuleDirs: moduleDirsIn(dir, paths), ModuleDirsSaid: true}
|
||||
scope := pullScope(p, entries, read, edges)
|
||||
_, scriptErr := os.Stat(filepath.Join(dir, builder.CheckScript))
|
||||
if !scope.gated() && !scope.Mesh {
|
||||
fmt.Printf("%s: %s, and the repository is not the mesh's: the build seat runs nothing for it\n",
|
||||
owner+"/"+repo, noModuleTouched)
|
||||
return nil
|
||||
}
|
||||
if !scope.gated() && scriptErr != nil {
|
||||
fmt.Printf("%s: %s; %s\n", owner+"/"+repo, noModuleTouched, noMergeCheck)
|
||||
return nil
|
||||
}
|
||||
|
||||
toolchains := map[string]string{}
|
||||
for language, reference := range f.Versions.Toolchains {
|
||||
toolchains[language] = catalogue.Rerouted(reference, *registry)
|
||||
}
|
||||
if len(toolchains) == 0 {
|
||||
return errors.New("the facts snapshot names no toolchain: it was taken by a controller from before " +
|
||||
"issue 286, and the seat's toolchain cannot be known here")
|
||||
}
|
||||
beside := map[string]builder.Beside{}
|
||||
for d, ref := range f.Beside {
|
||||
from := d
|
||||
if d == "mesh-controller-main" {
|
||||
from = "mesh-controller"
|
||||
}
|
||||
beside[d] = builder.Beside{Repository: strings.TrimSuffix(*forge, "/") + "/" + from + ".git", Ref: ref}
|
||||
}
|
||||
id := fmt.Sprintf("check-here-%d", time.Now().UnixNano())
|
||||
spec := builder.CheckSpec{ID: id, Repository: dir, Ref: head, Owner: owner, Repo: repo, Number: *number,
|
||||
Paths: paths, Beside: beside, Modules: scope.Modules, New: scope.New, Manifests: scope.Manifests,
|
||||
Base: *base, Judge: scope.Judge, Toolchain: toolchains["go"], Toolchains: toolchains, User: *user}
|
||||
|
||||
workspace, err := os.MkdirTemp("", "mesh-check-here-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !*keep {
|
||||
defer removeWorkspace(spec.Toolchain, workspace, *user)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "checking %s/%s at %.8s as the build seat would, against the facts of %s, in %s\n",
|
||||
owner, repo, head, f.Taken.Format(time.RFC3339), workspace)
|
||||
v, err := builder.Check(ctx, builder.Command, spec, workspace, *registry, builder.GitCredential{},
|
||||
func(step, message string) {
|
||||
if step != "output" {
|
||||
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("the check could not run — on the seat an error, never a pass: %w", err)
|
||||
}
|
||||
fmt.Println(v.Report)
|
||||
fmt.Println()
|
||||
fmt.Printf("mesh/merge-gate: %s — %s\n", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary)
|
||||
if v.Repo != nil {
|
||||
fmt.Printf("mesh/repo-check: %s — %s\n", strings.ToUpper(v.Repo.Verdict), v.Repo.Summary)
|
||||
}
|
||||
for _, l := range []*builder.Layer{v.Gate, v.Repo} {
|
||||
if l != nil && l.Verdict != "pass" && l.Verdict != "warning" {
|
||||
return errors.New("the build seat would not pass this change")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownerRepoOf reads owner, repository and the owner's URL from a remote: ssh://git@host:222/novox/mesh-host.git
|
||||
// → novox, mesh-host, ssh://git@host:222/novox.
|
||||
func ownerRepoOf(remote string) (string, string, string) {
|
||||
trimmed := strings.TrimSuffix(strings.TrimSuffix(remote, "/"), ".git")
|
||||
cut := strings.LastIndexAny(trimmed, "/:")
|
||||
if cut < 0 {
|
||||
return "", trimmed, ""
|
||||
}
|
||||
repo, prefix := trimmed[cut+1:], trimmed[:cut]
|
||||
owner := prefix
|
||||
if at := strings.LastIndexAny(prefix, "/:"); at >= 0 {
|
||||
owner = prefix[at+1:]
|
||||
}
|
||||
return owner, repo, prefix
|
||||
}
|
||||
|
||||
// removeWorkspace removes what the check left, written as the seat's user: by a container of that user
|
||||
// when it is not this one.
|
||||
func removeWorkspace(image, workspace, user string) {
|
||||
if image != "" && user != fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) {
|
||||
// Everything in it — the check's HOME is the workspace, so the toolchain's own files are there too.
|
||||
_ = exec.Command("docker", "run", "--rm", "--user", user, "--volume", workspace+":/workspace", image,
|
||||
"sh", "-c", "rm -rf /workspace/* /workspace/.[!.]*").Run()
|
||||
}
|
||||
_ = os.RemoveAll(workspace)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// 1212/AC1 and AC2: the check request names the pulls bucket of each module claiming the delivery seat, in the order
|
||||
// of the claimants' names.
|
||||
func TestACheckNamesThePullsBucketsOfTheSeatsClaimants(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
entries []inventory.Entry
|
||||
want []string
|
||||
}{
|
||||
"only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), namedOnly("mesh-delivery")}, []string{"mesh-delivery-gate_pulls"}},
|
||||
"only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery"), namedOnly("gitea")}, []string{"mesh-delivery_pulls"}},
|
||||
"both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}},
|
||||
"none": {[]inventory.Entry{namedOnly("gitea")}, nil},
|
||||
} {
|
||||
if got := pullsBucketsOf(c.entries); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: %v, want %v", name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1212/AC1 and AC2: the check a request carries names those buckets (the wiring, not only the helper).
|
||||
func TestTheCheckRequestCarriesThePullsBuckets(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
entries []inventory.Entry
|
||||
want []string
|
||||
}{
|
||||
"only the gate": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate")}, []string{"mesh-delivery-gate_pulls"}},
|
||||
"only today's": {[]inventory.Entry{claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls"}},
|
||||
"both, name order": {[]inventory.Entry{claimingTheSeat("mesh-delivery-gate"), claimingTheSeat("mesh-delivery")}, []string{"mesh-delivery_pulls", "mesh-delivery-gate_pulls"}},
|
||||
} {
|
||||
got := checkOf(link.PullUpdated{Owner: "novox", Repo: "a", Number: 7}, checkScope{}, c.entries, nil, nil, nil, nil)
|
||||
if !reflect.DeepEqual(got.Pulls, c.want) || got.Owner != "novox" || got.Number != 7 {
|
||||
t.Errorf("%s: %+v, want pulls %v", name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-559
@@ -2,9 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
@@ -13,46 +10,18 @@ import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A pull request's merge check (novox/hq to-be 45 §9, ADR 0237 as amended 2026-10-06): the forge
|
||||
// announces every pull request's new head, the controller decides what is checked, asks the build seat to
|
||||
// check it, and says the verdict as `checked`, which the forge's holder sets as the pull request's
|
||||
// statuses. **Before merge, never after**: every check the mesh had ran after a merge, on a machine.
|
||||
//
|
||||
// **The mesh's module graph decides, not the repository.** The controller holds the graph — every module,
|
||||
// the repository and directory it is built from — and maps the pull request's changed paths onto it by
|
||||
// the planner's own answer (reachOfMerge, touchedBy — the one place a changed file is mapped onto modules,
|
||||
// for the merge handler, the release planner, the merge gate and this check; issue 280): **a changed file
|
||||
// touches exactly the modules whose build reads it** — a module's own directory (the whole repository for
|
||||
// one built from its root), or a repository its recipe packages. A file no build reads — a script at the
|
||||
// root, a README — touches no module. A directory the change adds a module.json in, which the graph does
|
||||
// not hold yet (said by the head, issue 278), is a new module and is checked too.
|
||||
//
|
||||
// - touches a module: the build seat runs **the gate** — `mesh/merge-gate`, the touched manifests, every
|
||||
// machine composed with the change, the replays — and the repository's own merge-check.sh beside it;
|
||||
// - touches none, in a repository that is the mesh's (it sources a module on some branch, or shares the
|
||||
// core's owner): the gate is a pass that says so — a fact, not a missing check — and the repository's
|
||||
// own merge-check.sh runs as `mesh/repo-check`, a warning when it has none;
|
||||
// - touches none, anywhere else: the gate is a pass that says so, and nothing more is said.
|
||||
//
|
||||
// What is checked is decided here and run there (internal/builder/check.go).
|
||||
|
||||
// checkTimeout is how long one check may run on the build seat. Said here so the ask's watchdog (S6)
|
||||
// and the builder agree on what late means.
|
||||
const checkTimeout = 45 * time.Minute
|
||||
|
||||
// noModuleTouched is the gate's word for a change that touches nothing of the graph.
|
||||
const noModuleTouched = "the change touches no module of the mesh's graph"
|
||||
|
||||
// noMergeCheck is the repository layer's word for a repository of the mesh with no merge-check.sh.
|
||||
const noMergeCheck = "the repository declares no merge-check.sh: none of its own tests run before it merges"
|
||||
// What a pull request reaches of the mesh's graph (novox/hq to-be 45 §9, issue 280). The controller no longer checks a
|
||||
// pull request or says a verdict on it (R4g, ADR 0503 §3, ADR 0501 §5): the checker judges the heads, and mesh-delivery
|
||||
// asks the operator's Approve on its verdict. What stays here is the mapping of a pull request's changed paths onto the
|
||||
// mesh's module graph by the planner's own answer (reachOfMerge, touchedBy), which the delivery verbs (`delivery order`,
|
||||
// the change plan) and the head's declared config (tellPullDeclares) read: **a changed file touches exactly the modules
|
||||
// whose build reads it** — a module's own directory (the whole repository for one built from its root), or a repository
|
||||
// its recipe packages — and a directory the change adds a module.json in is a new module.
|
||||
|
||||
// coreModules are the modules whose repositories are the mesh's core, by the directory a check finds
|
||||
// each beside it — and whose owner is the mesh's own.
|
||||
@@ -76,8 +45,6 @@ type checkScope struct {
|
||||
// Mesh says the repository is the mesh's: modules are built from it on some branch, its owner is the
|
||||
// core's, or the change adds a module to it.
|
||||
Mesh bool
|
||||
// Judge is who judges the gate (link.JudgeSelf, or the running controller).
|
||||
Judge string
|
||||
// From is a module built from the repository, for how the mesh clones it; nil when none is.
|
||||
From *inventory.Entry
|
||||
// Reach is the planner's whole answer, which the change plan is made from.
|
||||
@@ -99,10 +66,6 @@ func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][]
|
||||
Width: len(r.Plan.Modules), Tiers: len(r.Plan.Tiers), Reach: r}
|
||||
for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) {
|
||||
s.Manifests = append(s.Manifests, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile))
|
||||
switch e.Manifest.Module {
|
||||
case "mesh-controller":
|
||||
s.Judge = link.JudgeSelf
|
||||
}
|
||||
}
|
||||
for _, d := range r.Added {
|
||||
s.Manifests = append(s.Manifests, path.Join(d, moduleManifestFile))
|
||||
@@ -138,35 +101,29 @@ func sourceOwner(repository string) string {
|
||||
return strings.ToLower(parts[len(parts)-2])
|
||||
}
|
||||
|
||||
// PullUpdated decides a pull request's merge check, and asks for it when there is something to run.
|
||||
// PullUpdated hears a pull request's new head. It asks no check and says no verdict (pullUpdated).
|
||||
func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
|
||||
_, err := f.pullUpdated(ctx, p, os.Stdout)
|
||||
return err
|
||||
return f.pullUpdated(ctx, p, os.Stdout)
|
||||
}
|
||||
|
||||
// pullUpdated is PullUpdated saying what it did to w. It returns the id of the check it asked of the build seat, or
|
||||
// "" when it said a verdict itself and asked nothing. A command that answers JSON on standard output (`delivery
|
||||
// check`) gives it standard error, so its output stays the document alone (novox/hq issue 1311).
|
||||
func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) (string, error) {
|
||||
// pullUpdated is PullUpdated saying what it did to w. It asks nothing of anybody and says no verdict (R4g; ADR 0503 §3, §5,
|
||||
// ADR 0527): the checker judges every repository whose trunk requires `mesh/check`, hq included. All it still does is tell
|
||||
// mesh-config what the head declares (ADR 0502), whatever judges it.
|
||||
func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Writer) error {
|
||||
inv := f.open.inventory
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
read, err := readForPlanning(ctx, inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
edges, err := inv.Dependencies(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
scope := pullScope(p, entries, read, edges)
|
||||
// **The merge gate's checks stop** (novox/hq ADR 0503 §3, §5; issue 1299): the checker judges every repository whose
|
||||
// trunk requires `mesh/check`. A repository whose trunk requires `mesh/repo-check` (hq), whatever else it requires
|
||||
// (ADR 0513 §4), has its own merge-check.sh asked of build-agent, and said with that layer alone; any other is asked
|
||||
// nothing and said nothing. What the forge's protection requires is read, never listed (lead decision L11).
|
||||
// Mesh-config is still told what the head declares (ADR 0502), whatever judges it.
|
||||
this := pullAtOf(p)
|
||||
if this.Truncated {
|
||||
this.Paths, this.Truncated = truncatedPaths(entries, p.Owner, p.Repo, scope.Modules), false
|
||||
@@ -174,310 +131,7 @@ func (f following) pullUpdated(ctx context.Context, p link.PullUpdated, w io.Wri
|
||||
if scope.gated() || holdsModules(entries, p.Owner, p.Repo) {
|
||||
tellPullDeclares(ctx, w, []pullAt{this})
|
||||
}
|
||||
required, err := requiredStatusesOf(ctx, p.Owner, p.Repo, p.Base)
|
||||
switch {
|
||||
case err != nil:
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): what %s's protection requires cannot be read, so nothing is asked and nothing is "+
|
||||
"said: %v\n", p.Owner, p.Repo, p.Number, p.Commit, p.Base, err)
|
||||
return "", nil
|
||||
case !asksRepoCheck(required):
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s does not require %s, and %s; nothing is asked\n", p.Owner, p.Repo, p.Number,
|
||||
p.Commit, p.Base, sdkdelivery.StatusContextRepoCheck, checkerWords(required))
|
||||
return "", nil
|
||||
}
|
||||
return f.repoCheckAlone(ctx, w, p, scope, entries)
|
||||
}
|
||||
|
||||
// asksRepoCheck says what a trunk's protection has the controller ask: its repository's own merge-check.sh whenever the
|
||||
// trunk requires `mesh/repo-check`, whatever else it requires (ADR 0513 §4: a trunk requiring both has the checker judge
|
||||
// `mesh/check` and the repository check asked beside it), and nothing otherwise.
|
||||
func asksRepoCheck(required []string) bool {
|
||||
return slices.Contains(required, sdkdelivery.StatusContextRepoCheck)
|
||||
}
|
||||
|
||||
// checkerWords says who judges a trunk that requires no `mesh/repo-check`.
|
||||
func checkerWords(required []string) string {
|
||||
if slices.Contains(required, sdkdelivery.StatusContextCheck) {
|
||||
return "the checker judges its " + sdkdelivery.StatusContextCheck
|
||||
}
|
||||
return "does not require " + sdkdelivery.StatusContextCheck + " either, so nothing is said"
|
||||
}
|
||||
|
||||
// repoCheckAlone asks build-agent for a repository's own merge-check.sh alone, for a pull request of a repository whose
|
||||
// trunk requires `mesh/repo-check`, whether or not it also requires `mesh/check` (ADR 0503 §5, ADR 0513 §4): no gate, no module built, its verdict said with the
|
||||
// repo-check layer alone.
|
||||
func (f following) repoCheckAlone(ctx context.Context, w io.Writer, p link.PullUpdated, scope checkScope,
|
||||
entries []inventory.Entry) (string, error) {
|
||||
inv := f.open.inventory
|
||||
plan := changePlanOf(p.Owner+"/"+p.Repo, p.Base, p.Commit, scope.Reach, entries, func(module string) (inventory.Upgrade, bool) {
|
||||
u, err := inv.UpgradeOf(ctx, module)
|
||||
return u, err == nil
|
||||
})
|
||||
fmt.Fprint(w, planText(plan))
|
||||
direct := directVerdict(p, plan)
|
||||
switch {
|
||||
case !scope.Mesh:
|
||||
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: "the repository is not the mesh's: nothing run"}
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): the repository is not the mesh's: said, nothing run\n", p.Owner, p.Repo, p.Number,
|
||||
p.Commit)
|
||||
sayChecked(ctx, w, direct)
|
||||
return "", nil
|
||||
case p.MergeCheckSaid && !p.MergeCheck:
|
||||
direct.RepoCheck = &link.CheckLayer{Verdict: "warning", Summary: noMergeCheck}
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): %s\n", p.Owner, p.Repo, p.Number, p.Commit, noMergeCheck)
|
||||
sayChecked(ctx, w, direct)
|
||||
return "", nil
|
||||
}
|
||||
request, err := checkRequestFor(ctx, f.open, p, checkScope{Mesh: true, From: scope.From}, entries)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Check.Plan = &plan
|
||||
seat := buildSeatHeld(ctx)
|
||||
ask, err := askOverOn(seat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer ask.Close()
|
||||
if err := ask.Ask(ctx, request); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(w, "%s/%s#%d (%.8s): asked %s to run its own merge-check.sh alone, as %s\n", p.Owner, p.Repo, p.Number,
|
||||
p.Commit, seat, request.ID)
|
||||
return request.ID, nil
|
||||
}
|
||||
|
||||
// requiredStatusesOf is what the forge's protection of a repository's branch requires before a merge, as the forge
|
||||
// picks the rule it applies (the one named for the branch, else the first glob covering it). A variable so a test needs
|
||||
// no forge.
|
||||
var requiredStatusesOf = func(ctx context.Context, owner, repo, branch string) ([]string, error) {
|
||||
var rules struct {
|
||||
Rules []protectionRule `json:"rules"`
|
||||
}
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer js.Close()
|
||||
raw, _ := json.Marshal(map[string]any{"owner": owner, "repo": repo})
|
||||
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_branch_protection_get", raw, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if answer.Error != "" {
|
||||
return nil, fmt.Errorf("gitea.gitea_branch_protection_get: %s", answer.Error)
|
||||
}
|
||||
if err := json.Unmarshal(answer.Result, &rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := appliedRule(rules.Rules, branch)
|
||||
if r == nil {
|
||||
// empty-on-error: not an error; no rule covers the branch, so it requires nothing, which the caller says
|
||||
return nil, nil
|
||||
}
|
||||
return r.RequiredStatuses, nil
|
||||
}
|
||||
|
||||
// composedWords is what a group member's own verdict says of its gate layer (novox/hq ADR 0403 clause 1).
|
||||
const composedWords = "judged by its group's composed check"
|
||||
|
||||
// gateComposed is a gate layer's verdict when no gate was run for the member alone: never a pass. Mesh-delivery reads it
|
||||
// as the composed check's to judge, and the forge's holder sets no mesh/merge-gate from it.
|
||||
const gateComposed = "composed"
|
||||
|
||||
// composedGate says a group member's own verdict has no gate of its own: its gate layer, and the verdict that is
|
||||
// the gate's, read "composed" in place of a pass.
|
||||
func composedGate(c *link.Checked) {
|
||||
c.Verdict, c.Summary = gateComposed, composedWords
|
||||
c.Gate = &link.CheckLayer{Verdict: gateComposed, Summary: composedWords}
|
||||
}
|
||||
|
||||
// directVerdict is the verdict of a change no build judges — it touches no module, and either the repository is not
|
||||
// the mesh's or the head holds no merge-check.sh. **It names the trunk commit it holds for** (novox/hq ADR 0323 §3):
|
||||
// the base branch's head the forge announced with the head, so the merge tool can weigh it against the trunk's head
|
||||
// and does not refuse it for ever (a verdict naming no trunk is never the trunk's head's). Nothing was built to
|
||||
// judge: the trunk commit is the one the forge said the change was announced against.
|
||||
func directVerdict(p link.PullUpdated, plan link.ChangePlan) link.Checked {
|
||||
trunk := p.BaseSHA
|
||||
if len(trunk) > 12 {
|
||||
trunk = trunk[:12]
|
||||
}
|
||||
if trunk != "" {
|
||||
plan.Trunk = trunk
|
||||
}
|
||||
return link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit,
|
||||
ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched,
|
||||
Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}, Plan: &plan, Trunk: trunk}
|
||||
}
|
||||
|
||||
// checkRequestFor is the ask for one pull request's head: the repository as the mesh clones it, the head,
|
||||
// and what is read beside it.
|
||||
func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scope checkScope,
|
||||
entries []inventory.Entry) (link.BuildRequest, error) {
|
||||
shelf := map[string]catalogue.Manifest{}
|
||||
for _, e := range entries {
|
||||
shelf[e.Manifest.Module] = e.Manifest
|
||||
}
|
||||
world, err := restOfTheMesh(ctx, open, shelf, "")
|
||||
if err != nil {
|
||||
return link.BuildRequest{}, err
|
||||
}
|
||||
clone := func(s inventory.Source) (string, error) {
|
||||
if s.Seat == "" {
|
||||
return s.Repository, nil
|
||||
}
|
||||
return clonedFromSeat(world, s.Seat, s.Repository)
|
||||
}
|
||||
// As the mesh clones a module built from it; a repository no module is built from, from the forge.
|
||||
source := inventory.Source{Seat: gitSeat, Repository: p.Owner + "/" + p.Repo}
|
||||
if scope.From != nil {
|
||||
source = scope.From.Source
|
||||
}
|
||||
repository, err := clone(source)
|
||||
if err != nil {
|
||||
return link.BuildRequest{}, err
|
||||
}
|
||||
current, err := open.inventory.CurrentBuilds(ctx)
|
||||
if err != nil {
|
||||
return link.BuildRequest{}, err
|
||||
}
|
||||
beside, err := besideOf(entries, func(module string) string { return current[module].Commit }, clone)
|
||||
if err != nil {
|
||||
return link.BuildRequest{}, err
|
||||
}
|
||||
builds := checkBuildsOf(p, scope, entries, clone)
|
||||
return link.BuildRequest{
|
||||
ID: link.NewBuildID(time.Now()),
|
||||
Repository: repository,
|
||||
Ref: p.Commit,
|
||||
Held: heldBy(ctx),
|
||||
Seats: seatBases(ctx),
|
||||
Source: sourceOnSeat(source),
|
||||
Check: checkOf(p, scope, entries, beside, builds, namedForTheCheck(ctx, p, builds), runningBy(ctx)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkOf is the check a request carries: the pull request, what the change touches, and the buckets of the pull
|
||||
// requests' keys of the modules claiming the delivery seat (novox/hq ADR 0437 §3, issue 1212).
|
||||
func checkOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry, beside map[string]link.CheckedOut,
|
||||
builds []link.CheckBuild, named []link.CheckedBuild, running map[string]string) *link.CheckRequest {
|
||||
return &link.CheckRequest{Pulls: pullsBucketsOf(entries), Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
|
||||
Trunk: p.BaseSHA, Ready: p.Ready, Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents,
|
||||
New: scope.New, Manifests: scope.Manifests, Judge: scope.Judge, Builds: builds, Named: named, Running: running}
|
||||
}
|
||||
|
||||
// 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. 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)) (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))
|
||||
beside[dir] = link.CheckedOut{Repository: url, Ref: refs[dir]}
|
||||
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 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]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return beside, 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"
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// branch it follows, as the walk builds it. A module whose build reads the repository checked through a context,
|
||||
// or that the merge deletes, cannot be built as the walk would: said, and nothing after it is built. A change that
|
||||
// moves no module builds nothing.
|
||||
func checkBuildsOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry,
|
||||
clone func(inventory.Source) (string, error)) []link.CheckBuild {
|
||||
if len(scope.Reach.Plan.Tiers) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL}
|
||||
byName := map[string]inventory.Entry{}
|
||||
for _, e := range entries {
|
||||
byName[e.Manifest.Module] = e
|
||||
}
|
||||
skip := map[string]string{}
|
||||
for _, e := range scope.Reach.Packaging {
|
||||
skip[e.Manifest.Module] = "its build reads " + p.Owner + "/" + p.Repo + " through a context, which a check " +
|
||||
"cannot hand the merge"
|
||||
}
|
||||
for _, e := range scope.Reach.Deleted {
|
||||
skip[e.Manifest.Module] = "the merge deletes it"
|
||||
}
|
||||
var out []link.CheckBuild
|
||||
for tier, names := range scope.Reach.Plan.Tiers {
|
||||
for _, name := range names {
|
||||
e, known := byName[name]
|
||||
b := link.CheckBuild{Module: name, Tier: tier, Skip: skip[name]}
|
||||
switch {
|
||||
case !known:
|
||||
b.Skip = "the catalogue does not hold it"
|
||||
case b.Skip != "":
|
||||
case sourceIs(e.Source, m):
|
||||
b.Merged, b.Path = true, e.Source.Path
|
||||
default:
|
||||
url, err := clone(e.Source)
|
||||
if err != nil {
|
||||
b.Skip = "its repository cannot be cloned: " + err.Error()
|
||||
break
|
||||
}
|
||||
b.Repository, b.Path, b.Ref = url, e.Source.Path, followedBranch(e.Source.Ref)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return nil
|
||||
}
|
||||
|
||||
// besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under:
|
||||
@@ -533,201 +187,5 @@ func sdkPinned() string {
|
||||
return "main"
|
||||
}
|
||||
|
||||
// siblingOf is another repository of the same owner: novox/mesh-controller → novox/mesh-lab.
|
||||
func siblingOf(repository, name string) string {
|
||||
if cut := strings.LastIndex(repository, "/"); cut >= 0 {
|
||||
return repository[:cut+1] + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// sourceOnSeat is a source's seat form, nil for one on no seat.
|
||||
func sourceOnSeat(s inventory.Source) *link.SourceOnSeat {
|
||||
if s.Seat == "" {
|
||||
return nil
|
||||
}
|
||||
return &link.SourceOnSeat{Seat: s.Seat, Repository: s.Repository}
|
||||
}
|
||||
|
||||
// checkEvents is where the serving controller says a check's verdict; nil in a command.
|
||||
var checkEvents link.Bus
|
||||
|
||||
// maxCheckReport is how much of a check's report travels in its verdict: enough for the failures and
|
||||
// the machines, never a log.
|
||||
const maxCheckReport = 60 << 10
|
||||
|
||||
// checked says a merge check's verdict as the controller's `checked`. Nothing is recorded or
|
||||
// registered: a check builds nothing (issue 240's rule for a dry run, kept for a check).
|
||||
func checked(ctx context.Context, result link.BuildResult) {
|
||||
// **A check stopped by its key says nothing** (novox/hq ADR 0406 §4, issue 974, review 2): it stopped because the head or
|
||||
// the trunk commit moved on, and the newer request will judge. Said as an error it would name the live head — red
|
||||
// statuses on the forge, a delivery refused — and written to the key it would stand in the newer request's way.
|
||||
if result.Failed == link.SupersededByKey {
|
||||
fmt.Printf("%s: stopped, its pull request's key moved on; the newer check will judge\n", result.ID)
|
||||
return
|
||||
}
|
||||
sayChecked(ctx, os.Stdout, checkedOf(result))
|
||||
}
|
||||
|
||||
// checkedOf is what a check's outcome says: each layer, and an error — never a pass — for a check that
|
||||
// could not run.
|
||||
func checkedOf(result link.BuildResult) link.Checked {
|
||||
c := link.Checked{ID: result.ID, On: result.On, Commit: result.Ref}
|
||||
if result.Checked != nil {
|
||||
c.Owner, c.Repo, c.Number = result.Checked.Owner, result.Checked.Repo, result.Checked.Number
|
||||
}
|
||||
switch {
|
||||
case result.Check != nil:
|
||||
c.Verdict, c.Summary, c.Report = result.Check.Verdict, result.Check.Summary, result.Check.Report
|
||||
c.Gate, c.RepoCheck = result.Check.Gate, result.Check.RepoCheck
|
||||
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the
|
||||
// repositories read beside it.
|
||||
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
|
||||
c.Built = result.Check.Built
|
||||
c.Validator = result.Check.Validator
|
||||
c.Siblings, c.SiblingsUnknown, c.JudgeFrom = result.Check.Siblings, result.Check.SiblingsUnknown, result.Check.JudgeFrom
|
||||
if c.Gate == nil {
|
||||
// A build seat from before the layers: its verdict is the gate's.
|
||||
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
||||
}
|
||||
case result.Failed != "":
|
||||
// The check could not run: an error, never read as a pass — on both layers it was asked for.
|
||||
c.Verdict, c.Summary = "error", "the check could not run: "+firstLine(result.Failed)
|
||||
default:
|
||||
c.Verdict, c.Summary = "error", "the build seat answered the check with no verdict"
|
||||
}
|
||||
if c.Verdict == "" {
|
||||
c.Verdict = "error"
|
||||
}
|
||||
if result.Check == nil {
|
||||
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
||||
c.RepoCheck = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
|
||||
}
|
||||
// **A group member's own check has no gate of its own** (novox/hq ADR 0403 clause 1): what ran was the repository's
|
||||
// check alone, and its gate layer is "composed", never a pass. A failure or an error stays what it was.
|
||||
if result.Checked != nil && result.Checked.Composed && result.Check != nil && c.Gate != nil &&
|
||||
(c.Gate.Verdict == "pass" || c.Gate.Verdict == "warning") {
|
||||
composedGate(&c)
|
||||
}
|
||||
if result.Checked != nil && c.Gate != nil && len(c.Gate.Modules) == 0 {
|
||||
c.Gate.Modules = append(append([]string{}, result.Checked.Modules...), prefixedAll("new:", result.Checked.New)...)
|
||||
}
|
||||
if result.Checked != nil && c.Gate != nil && len(c.Gate.Dependents) == 0 {
|
||||
c.Gate.Dependents = result.Checked.Dependents
|
||||
}
|
||||
if result.Checked != nil {
|
||||
c.Plan = result.Checked.Plan
|
||||
if c.Plan != nil && c.Trunk != "" {
|
||||
// The kept delivery plan records the trunk commit with its other inputs (ADR 0323 §3).
|
||||
plan := *c.Plan
|
||||
plan.Trunk = c.Trunk
|
||||
c.Plan = &plan
|
||||
}
|
||||
// A delivery group's composed check (novox/hq ADR 0239): every head it judged, this one first.
|
||||
if g := result.Checked; g.Group != "" {
|
||||
c.Group = g.Group
|
||||
// Every member is named with its trunk commit (novox/hq issue 1106): the seat's word, else the one the forge
|
||||
// announced with the head, so the forge's merge tool never meets a member whose verdict names none.
|
||||
c.Members = append(c.Members, link.CheckedMember{Owner: g.Owner, Repo: g.Repo, Number: g.Number,
|
||||
Commit: result.Ref, Trunk: firstTrunk(c.Trunk, g.Trunk)})
|
||||
for _, m := range g.Members {
|
||||
c.Members = append(c.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, Number: m.Number,
|
||||
Commit: m.Ref, Trunk: firstTrunk(memberTrunk(result.Check, m), m.Trunk)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} {
|
||||
if l != nil && l.Verdict == "" {
|
||||
l.Verdict = "error"
|
||||
}
|
||||
}
|
||||
// **A check run again says so** (novox/hq issue 594). The summaries are the lines the pull request
|
||||
// shows, clipped by the forge to a few words, so they get only a short marker after the verdict's own
|
||||
// words; the report gets the whole sentence. Layers copied, so the build's own outcome is left as it came.
|
||||
if mark := link.RunMarker(result.Delivery); mark != "" {
|
||||
c.Summary += " " + mark
|
||||
for _, l := range []**link.CheckLayer{&c.Gate, &c.RepoCheck} {
|
||||
if *l != nil {
|
||||
layer := **l
|
||||
layer.Summary += " " + mark
|
||||
*l = &layer
|
||||
}
|
||||
}
|
||||
c.Report = link.Restarted(result.Delivery) + "\n\n" + c.Report
|
||||
}
|
||||
if len(c.Report) > maxCheckReport {
|
||||
c.Report = "…" + c.Report[len(c.Report)-maxCheckReport:]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// firstTrunk is the first trunk commit named, as twelve characters; empty when none is.
|
||||
func firstTrunk(named ...string) string {
|
||||
for _, n := range named {
|
||||
if n != "" {
|
||||
return twelve(n)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// memberTrunk is the trunk commit the build seat merged one of a group's heads into (novox/hq ADR 0323 §6), empty
|
||||
// when it said none.
|
||||
func memberTrunk(outcome *link.CheckOutcome, m link.GroupMember) string {
|
||||
if outcome == nil {
|
||||
return ""
|
||||
}
|
||||
for _, j := range outcome.Members {
|
||||
if strings.EqualFold(j.Owner, m.Owner) && strings.EqualFold(j.Repo, m.Repo) && strings.HasPrefix(m.Ref, j.Commit) {
|
||||
return j.Trunk
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func prefixedAll(prefix string, items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, i := range items {
|
||||
out = append(out, prefix+i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sayChecked says a merge check's verdict on the bus, where the forge's holder hears it.
|
||||
func sayChecked(ctx context.Context, w io.Writer, c link.Checked) {
|
||||
// **No merge-gate layer from R4e, for any repository** (ADR 0503 §3, §5; issue 1299): the forge's holder writes
|
||||
// `mesh/repo-check` from the repo-check layer alone, and the checker owes `mesh/check`.
|
||||
c.Gate = nil
|
||||
repo := "none"
|
||||
if c.RepoCheck != nil {
|
||||
repo = strings.ToUpper(c.RepoCheck.Verdict) + " — " + c.RepoCheck.Summary
|
||||
}
|
||||
judged := ""
|
||||
if c.Trunk != "" {
|
||||
judged = " merged into trunk " + c.Trunk
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %s/%s#%d at %.8s%s checked on %s: repository %s\n", c.ID, c.Owner, c.Repo, c.Number,
|
||||
c.Commit, judged, orSomewhere(c.On), repo)
|
||||
if checkEvents == nil {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
stating, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer stop()
|
||||
if err := checkEvents.PublishSeatEvent(stating, link.MeshControllerSeat, link.KeyChecked, body); err != nil {
|
||||
fmt.Fprintf(w, "%s: the verdict could not be said, so the pull request is not told it: %v\n", c.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pullsBucketsOf are the buckets of the pull requests' keys a check names for the build agent: one per module
|
||||
// claiming the delivery seat, in the order of their names (novox/hq ADR 0437 §3, issue 1212).
|
||||
func pullsBucketsOf(entries []inventory.Entry) []string {
|
||||
var out []string
|
||||
for _, m := range claimantsOf(entries, catalogue.DeliverySeat) {
|
||||
out = append(out, broker.BucketName(m, "pulls"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
func TestACheckStoppedByItsKeyIsAnnouncedAsNoVerdict(t *testing.T) {
|
||||
js, err := broker.Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := js.Conn().JetStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was := checkEvents
|
||||
checkEvents = link.OverNATS{Conn: js.Conn(), JS: legacy}
|
||||
t.Cleanup(func() { checkEvents = was })
|
||||
heard := make(chan *nats.Msg, 4)
|
||||
sub, err := js.Conn().ChanSubscribe(link.SeatEventSubject(link.MeshControllerSeat, link.KeyChecked), heard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 3}
|
||||
checked(t.Context(), link.BuildResult{ID: "stopped", Ref: "abc", Checked: asked, Failed: link.SupersededByKey})
|
||||
checked(t.Context(), link.BuildResult{ID: "broken", Ref: "abc", Checked: asked, Failed: "the facts snapshot cannot be read"})
|
||||
select {
|
||||
case m := <-heard:
|
||||
var c link.Checked
|
||||
_ = json.Unmarshal(m.Data, &c)
|
||||
if c.ID != "broken" || c.Verdict != "error" {
|
||||
t.Fatalf("the first verdict said is %+v; want only the broken check's error", c)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("nothing was said")
|
||||
}
|
||||
select {
|
||||
case m := <-heard:
|
||||
t.Fatalf("a second verdict was said: %s", m.Data)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// C1 (ADR 0503 §3, §5; issue 1299, lead decision L11), with the store and a real build queue: a pull request of a
|
||||
// repository whose trunk requires mesh/check asks build-agent nothing and says nothing; one whose trunk requires
|
||||
// mesh/repo-check alone (hq) has its own merge-check.sh asked, and every verdict said names no gate layer; a protection
|
||||
// that cannot be read asks and says nothing.
|
||||
func TestThePullRequestChecksAreAskedOnlyForARepositoryTheCheckerDoesNotJudge(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
js, _ := aBuildQueue(t)
|
||||
servingBus.Store(js)
|
||||
t.Cleanup(func() { servingBus.Store(nil) })
|
||||
said := &saidOnTheBus{}
|
||||
checkEvents = said
|
||||
t.Cleanup(func() { checkEvents = nil })
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "mesh-controller", Version: "1"},
|
||||
inventory.Source{Repository: "novox/mesh-controller", Ref: "main", BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// hq is cloned as a module built from it is, from its URL: no forge is held in this test.
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "hq-docs", Version: "1"},
|
||||
inventory.Source{Repository: "http://forge.invalid/novox/hq.git", Ref: "main", Path: "docs", BuiltFrom: "c0",
|
||||
Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
required := map[string][]string{"novox/mesh-controller": {"mesh/check"}, "novox/hq": {"mesh/repo-check"}}
|
||||
was := requiredStatusesOf
|
||||
requiredStatusesOf = func(_ context.Context, owner, repo, _ string) ([]string, error) {
|
||||
if r, ok := required[owner+"/"+repo]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return nil, errors.New("the forge did not answer")
|
||||
}
|
||||
t.Cleanup(func() { requiredStatusesOf = was })
|
||||
queued := func() int {
|
||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(q.Asks)
|
||||
}
|
||||
pull := func(repo string, number int, mergeCheck bool) link.PullUpdated {
|
||||
p := link.PullUpdated{Owner: "novox", Repo: repo, Number: number, Base: "main", Commit: "abcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
BaseSHA: "1111111111111111111111111111111111111111", Paths: []string{"main.go"}, ModuleDirsSaid: true}
|
||||
p.MergeCheck, p.MergeCheckSaid = mergeCheck, true
|
||||
return p
|
||||
}
|
||||
f := following{open: open, oneShot: true}
|
||||
|
||||
if err := f.PullUpdated(ctx, pull("mesh-controller", 7, true)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := queued(); n != 0 || len(said.events) != 0 {
|
||||
t.Fatalf("0503 §3: a repository the checker judges asked %d check(s) and said %v", n, said.events)
|
||||
}
|
||||
if err := f.PullUpdated(ctx, pull("unread", 3, true)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := queued(); n != 0 || len(said.events) != 0 {
|
||||
t.Fatalf("L11: a protection that cannot be read asked %d check(s) and said %v", n, said.events)
|
||||
}
|
||||
if err := f.PullUpdated(ctx, pull("hq", 9, true)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.Asks) != 1 || q.Asks[0].Request.Check == nil || len(q.Asks[0].Request.Check.Modules) != 0 ||
|
||||
q.Asks[0].Request.Check.Repo != "hq" {
|
||||
t.Fatalf("0503 §5: hq's repository check is not the one ask: %+v", q.Asks)
|
||||
}
|
||||
// A head with no merge-check.sh is said at once, with the repo-check layer alone.
|
||||
if err := f.PullUpdated(ctx, pull("hq", 10, false)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(said.events) != 1 {
|
||||
t.Fatalf("hq's head with no merge-check.sh was said %d time(s)", len(said.events))
|
||||
}
|
||||
var c link.Checked
|
||||
if err := json.Unmarshal(said.bodies[0], &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Gate != nil || c.RepoCheck == nil || c.RepoCheck.Verdict != "warning" {
|
||||
t.Fatalf("0503 §5: the verdict said names a gate layer, or no repo-check layer: %+v", c)
|
||||
}
|
||||
// A trunk requiring both (hq, ADR 0513 §4): the checker judges mesh/check, and the repository check is still asked.
|
||||
required["novox/hq"] = []string{"mesh/check", "mesh/repo-check"}
|
||||
if err := f.PullUpdated(ctx, pull("hq", 11, true)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q, err := link.ReadQueue(ctx, js, link.TheBuildMachine); err != nil || len(q.Asks) != 2 ||
|
||||
q.Asks[1].Request.Check == nil || q.Asks[1].Request.Check.Repo != "hq" {
|
||||
t.Fatalf("0513 §4: a trunk requiring both had no repository check asked: %+v %v", q.Asks, err)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -76,8 +75,8 @@ func TestThePullRequestIsMappedOntoTheModuleGraph(t *testing.T) {
|
||||
t.Errorf("%s: %s %q, wanted %q", c.what, what, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if s.Mesh != c.mesh || s.Judge != c.judge {
|
||||
t.Errorf("%s: the mesh's %v judged by %q, wanted %v by %q", c.what, s.Mesh, s.Judge, c.mesh, c.judge)
|
||||
if s.Mesh != c.mesh {
|
||||
t.Errorf("%s: the mesh's %v, wanted %v", c.what, s.Mesh, c.mesh)
|
||||
}
|
||||
if s.gated() != (c.modules != "" || c.new != "") {
|
||||
t.Errorf("%s: gated %v", c.what, s.gated())
|
||||
@@ -95,28 +94,6 @@ func TestAPullRequestTouchesWhatPackagesItsRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Each layer is said; a check that could not run is an error on both, never a pass; a build seat from
|
||||
// before the layers is read as the gate.
|
||||
func TestAChecksLayersAreEachSaidAndAnErrorIsNeverAPass(t *testing.T) {
|
||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 3, Modules: []string{"gitea"}}
|
||||
c := checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Failed: "the facts snapshot cannot be read"})
|
||||
if c.Verdict != "error" || c.Gate == nil || c.Gate.Verdict != "error" || c.RepoCheck == nil || c.RepoCheck.Verdict != "error" {
|
||||
t.Fatalf("a check that could not run said %+v", c)
|
||||
}
|
||||
if strings.Join(c.Gate.Modules, ",") != "gitea" {
|
||||
t.Errorf("the gate names %v", c.Gate.Modules)
|
||||
}
|
||||
c = checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Check: &link.CheckOutcome{Verdict: "warning", Summary: "wide"}})
|
||||
if c.Gate == nil || c.Gate.Verdict != "warning" || c.RepoCheck != nil {
|
||||
t.Fatalf("an outcome without layers said %+v", c)
|
||||
}
|
||||
c = checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Check: &link.CheckOutcome{Verdict: "pass",
|
||||
Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "its merge-check.sh failed"}}})
|
||||
if c.RepoCheck.Verdict != "fail" || c.Gate.Verdict != "pass" {
|
||||
t.Fatalf("the layers said %+v / %+v", c.Gate, c.RepoCheck)
|
||||
}
|
||||
}
|
||||
|
||||
// **The check asks the planner, never a mapping of its own** (novox/hq ADR 0238): what a pull request
|
||||
// reaches is reachOfMerge's answer — the same that plans a merge — so a file at the root touches no
|
||||
// module in both, and what stands on a touched module is named as its dependents in both.
|
||||
@@ -171,166 +148,5 @@ func TestAPullRequestReachesWhatAMergeOfItWouldPlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **Only a commit on the trunk is published** (novox/hq ADR 0238): a build of a commit off its repository's
|
||||
// default branch — a pull request's head, a branch built by hand, a rebuild or replay of one — is recorded
|
||||
// and never registered, so nothing can send it; a check or a dry run never is either.
|
||||
func TestABuildOffTheTrunkIsNeverPublished(t *testing.T) {
|
||||
on := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main", OnTrunk: true}
|
||||
if err := publishable(on, ""); err != nil {
|
||||
t.Errorf("a build on the trunk was refused: %v", err)
|
||||
}
|
||||
off := link.BuildResult{ID: "b", Commit: "abc", Trunk: "main"}
|
||||
if err := publishable(off, ""); !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "main") {
|
||||
t.Errorf("a build off the trunk was let through: %v", err)
|
||||
}
|
||||
for _, r := range []link.BuildResult{
|
||||
{ID: "c", Trunk: "main", OnTrunk: true, Check: &link.CheckOutcome{Verdict: "pass"}},
|
||||
{ID: "d", Trunk: "main", OnTrunk: true, Checked: &link.CheckRequest{}},
|
||||
{ID: "e", Trunk: "main", OnTrunk: true, DryRun: true},
|
||||
} {
|
||||
if publishable(r, "") == nil {
|
||||
t.Errorf("%s, a check or a dry run, was publishable", r.ID)
|
||||
}
|
||||
}
|
||||
// A module that follows a branch other than the default: that branch is its trunk, and the default
|
||||
// is not.
|
||||
follows := link.BuildResult{ID: "g", Commit: "abc", Trunk: "master", Branches: []string{"nox-mesh"}}
|
||||
if err := publishable(follows, "nox-mesh"); err != nil {
|
||||
t.Errorf("a commit on the branch a module follows was refused: %v", err)
|
||||
}
|
||||
if err := publishable(link.BuildResult{ID: "h", Commit: "abc", Trunk: "master", OnTrunk: true,
|
||||
Branches: []string{"master"}}, "nox-mesh"); err == nil {
|
||||
t.Error("a commit on the default but not on the branch the module follows was published")
|
||||
}
|
||||
if err := publishable(link.BuildResult{ID: "i", Commit: "abc", Trunk: "main", Branches: []string{"feat/x"}}, ""); err == nil {
|
||||
t.Error("a feature branch's commit was published")
|
||||
}
|
||||
// A build seat older than the rule says nothing: let through and said, so the rule can reach the mesh.
|
||||
if err := publishable(link.BuildResult{ID: "f", Commit: "abc"}, ""); err != nil {
|
||||
t.Errorf("a build seat that said nothing was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A verdict names the trunk commit it judged** (novox/hq ADR 0323 §3, issue 341): the controller's `checked`
|
||||
// carries the head and the trunk commit the build seat merged it into, the commits of the repositories read beside
|
||||
// it, how long it took, and the kept delivery plan records the trunk commit with its other inputs. A group's
|
||||
// composed check names, per member, the trunk commit that head was merged into (§6).
|
||||
func TestAVerdictNamesTheTrunkCommitItJudged(t *testing.T) {
|
||||
plan := &link.ChangePlan{Repository: "novox/mesh-host", Base: "main", Head: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
Summary: "builds mesh-host"}
|
||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 66, Base: "main", Plan: plan}
|
||||
c := checkedOf(link.BuildResult{ID: "b", Ref: "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Checked: asked,
|
||||
Check: &link.CheckOutcome{Verdict: "pass", Took: "4m12s", Head: "dfb1415aaaaa", Trunk: "d28ca95bbbbb",
|
||||
Gate: &link.CheckLayer{Verdict: "pass"}, RepoCheck: &link.CheckLayer{Verdict: "pass"},
|
||||
Beside: map[string]string{"mesh-catalog": "0123456789ab", "mesh-lab": "ba9876543210"}}})
|
||||
if c.Trunk != "d28ca95bbbbb" || c.Commit != "dfb1415aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
|
||||
t.Fatalf("the verdict names head %s and trunk %q", c.Commit, c.Trunk)
|
||||
}
|
||||
if c.Beside["mesh-catalog"] != "0123456789ab" || c.Beside["mesh-lab"] != "ba9876543210" {
|
||||
t.Fatalf("the verdict names the repositories read beside it as %v", c.Beside)
|
||||
}
|
||||
if c.Took != "4m12s" {
|
||||
t.Fatalf("the verdict says it took %q", c.Took)
|
||||
}
|
||||
if c.Plan == nil || c.Plan.Trunk != "d28ca95bbbbb" || c.Plan.Head != plan.Head {
|
||||
t.Fatalf("the kept plan records %+v", c.Plan)
|
||||
}
|
||||
if plan.Trunk != "" {
|
||||
t.Fatal("the plan the controller asked with was changed: the verdict's is a copy")
|
||||
}
|
||||
|
||||
group := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 7, Base: "main", Group: "feat/x",
|
||||
Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-host", Number: 8, Ref: "cccccccccccc0000", Base: "main"}}}
|
||||
c = checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{
|
||||
Verdict: "pass", Head: "aaaaaaaaaaaa", Trunk: "111111111111", Gate: &link.CheckLayer{Verdict: "pass"},
|
||||
Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "cccccccccccc", Trunk: "222222222222"}}}})
|
||||
if len(c.Members) != 2 || c.Members[0].Trunk != "111111111111" || c.Members[1].Trunk != "222222222222" ||
|
||||
c.Members[1].Commit != "cccccccccccc0000" {
|
||||
t.Fatalf("the composed check names its members %+v", c.Members)
|
||||
}
|
||||
}
|
||||
|
||||
// **A group's composed verdict names every member's trunk commit** (novox/hq issue 1106): a member the build seat named no
|
||||
// trunk for (an SDK member whose base the seat did not merge, or a seat from before the rule) is named by the trunk the forge
|
||||
// announced with its head, and the primary the same, so the merge tool never reads "names no trunk commit" on a member
|
||||
// whose trunk did not move.
|
||||
func TestAGroupVerdictNamesEveryMembersTrunkTheSDKMemberToo(t *testing.T) {
|
||||
group := &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog", Number: 7, Base: "main", Group: "feat/x",
|
||||
Trunk: "dddddddddddd0000",
|
||||
Members: []link.GroupMember{
|
||||
{Owner: "novox", Repo: "mesh-sdk", Number: 8, Ref: "cccccccccccc0000", Base: "main", Trunk: "eeeeeeeeeeee0000"},
|
||||
{Owner: "novox", Repo: "mesh-host", Number: 9, Ref: "bbbbbbbbbbbb0000", Base: "main", Trunk: "ffffffffffff0000"}}}
|
||||
c := checkedOf(link.BuildResult{ID: "g", Ref: "aaaaaaaaaaaa0000", Checked: group, Check: &link.CheckOutcome{
|
||||
Verdict: "pass", Head: "aaaaaaaaaaaa", Gate: &link.CheckLayer{Verdict: "pass"},
|
||||
Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-host", Commit: "bbbbbbbbbbbb", Trunk: "222222222222"}}}})
|
||||
want := []string{"dddddddddddd", "eeeeeeeeeeee", "222222222222"}
|
||||
if len(c.Members) != 3 {
|
||||
t.Fatalf("the composed check names its members %+v", c.Members)
|
||||
}
|
||||
for i, m := range c.Members {
|
||||
if m.Trunk != want[i] {
|
||||
t.Fatalf("member %d (%s/%s) is named with trunk %q, want %q (the seat's word first, else the announced one): %+v",
|
||||
i, m.Owner, m.Repo, m.Trunk, want[i], c.Members)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The merge made for a check is never registered** (novox/hq ADR 0323 §2): a build of it — a commit on no branch
|
||||
// of the forge — is refused, naming the trunk. The rule of ADR 0238 §1 stands unchanged; this holds it for the
|
||||
// merge commit a check makes.
|
||||
func TestABuildOfACheckMergeIsRefusedNamingTheTrunk(t *testing.T) {
|
||||
merge := link.BuildResult{ID: "b", Commit: "4b5c6d7e8f90aabbccddeeff0011223344556677", Trunk: "main"}
|
||||
err := publishable(merge, "")
|
||||
if !errors.Is(err, errOffTheTrunk) || !strings.Contains(err.Error(), "not on main") {
|
||||
t.Fatalf("a build of a check's merge was answered %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A verdict no build judges names its trunk commit too** (novox/hq ADR 0323 §3): a change touching no module of a
|
||||
// repository with no merge-check.sh (novox/mesh-tools-go) is answered by the controller itself. Naming no trunk, the
|
||||
// merge tool refused it, asked again, got the same verdict, and refused for ever.
|
||||
func TestADirectVerdictNamesTheTrunkTheForgeAnnounced(t *testing.T) {
|
||||
p := link.PullUpdated{Owner: "novox", Repo: "mesh-tools-go", Number: 3, Base: "main", Commit: "1a2b3c4d5e6f7a8b9c0d",
|
||||
BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"}
|
||||
c := directVerdict(p, link.ChangePlan{Repository: "novox/mesh-tools-go", Base: "main", Head: p.Commit})
|
||||
if c.Trunk != "7a8b9c0d1e2f" || c.Plan == nil || c.Plan.Trunk != "7a8b9c0d1e2f" {
|
||||
t.Fatalf("the direct verdict names trunk %q, its plan %+v", c.Trunk, c.Plan)
|
||||
}
|
||||
if c.Verdict != "pass" || c.Commit != p.Commit {
|
||||
t.Fatalf("the direct verdict is %+v", c)
|
||||
}
|
||||
if c := directVerdict(link.PullUpdated{Owner: "novox", Repo: "x", Commit: "abc"}, link.ChangePlan{}); c.Trunk != "" {
|
||||
t.Fatalf("a forge that said no base head gave the trunk %q", c.Trunk)
|
||||
}
|
||||
}
|
||||
|
||||
// A head checked again through mesh-delivery (`delivery check --members`, a recheck) reaches the same direct verdict:
|
||||
// it names the base head the forge last announced, kept on the member, or the merge tool would refuse it for ever.
|
||||
func TestARecheckedHeadsDirectVerdictNamesItsTrunk(t *testing.T) {
|
||||
m := orderMember{ID: "novox/mesh-tools-go@1a2b3c4d5e6f", Repository: "novox/mesh-tools-go", Base: "main",
|
||||
Head: "1a2b3c4d5e6f7a8b9c0d", Number: 3, BaseSHA: "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"}
|
||||
if c := directVerdict(m.pull(), link.ChangePlan{}); c.Trunk != "7a8b9c0d1e2f" {
|
||||
t.Fatalf("a rechecked head's direct verdict names trunk %q", c.Trunk)
|
||||
}
|
||||
}
|
||||
|
||||
// A check stopped by its pull request's key moving on says no verdict (issue 974, review 2): an error said for it would name the
|
||||
// live head, and the newer request is the one that judges. A check that failed for any other reason still says its error.
|
||||
|
||||
// C2 (ADR 0513 §4, ADR 0503 §5; issue 1299): a trunk requiring mesh/repo-check has its repository check asked, whether or
|
||||
// not it also requires mesh/check (hq requires both); a trunk requiring mesh/check alone, or neither, is asked nothing.
|
||||
func TestARepositoryCheckIsAskedWheneverTheTrunkRequiresIt(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
required []string
|
||||
asks bool
|
||||
}{
|
||||
{[]string{"mesh/check", "mesh/repo-check"}, true},
|
||||
{[]string{"mesh/repo-check"}, true},
|
||||
{[]string{"mesh/check"}, false},
|
||||
{nil, false},
|
||||
} {
|
||||
if got := asksRepoCheck(c.required); got != c.asks {
|
||||
t.Fatalf("a trunk requiring %v asks its repository check: %v, want %v", c.required, got, c.asks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A delivery group's gate is the composed check's, and its members' own checks run their repository's check alone
|
||||
// (novox/hq ADR 0403 clauses 1 and 7). Pure: no store, no bus.
|
||||
|
||||
// The heads of a group's members, by repository.
|
||||
const (
|
||||
headHost = "1111111111111111111111111111111111111111"
|
||||
headController = "2222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
// A composed member's verdict never says its gate passed: the gate layer, and the verdict that is the gate's, read
|
||||
// "composed"; a failure or an error stays what it was.
|
||||
func TestAComposedMembersVerdictHasNoPassingGate(t *testing.T) {
|
||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 114, Composed: true}
|
||||
pass := link.BuildResult{ID: "b", Ref: headHost, Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", Summary: "no module touched",
|
||||
Gate: &link.CheckLayer{Verdict: "pass", Summary: "no module touched"}, RepoCheck: &link.CheckLayer{Verdict: "pass", Summary: "ok"}}}
|
||||
c := checkedOf(pass)
|
||||
if c.Gate == nil || c.Gate.Verdict != "composed" || c.Verdict != "composed" || c.RepoCheck == nil || c.RepoCheck.Verdict != "pass" {
|
||||
t.Fatalf("the member's verdict says %+v gate %+v", c, c.Gate)
|
||||
}
|
||||
asked.Composed = false
|
||||
if c := checkedOf(pass); c.Gate.Verdict != "pass" || c.Verdict != "pass" {
|
||||
t.Fatalf("a member of no composed group has its own gate: %+v", c.Gate)
|
||||
}
|
||||
asked.Composed = true
|
||||
failing := pass
|
||||
failing.Check = &link.CheckOutcome{Verdict: "fail", Summary: "conflict", Gate: &link.CheckLayer{Verdict: "fail", Summary: "conflict"},
|
||||
RepoCheck: &link.CheckLayer{Verdict: "fail", Summary: "conflict"}}
|
||||
if c := checkedOf(failing); c.Gate.Verdict != "fail" || c.Verdict != "fail" {
|
||||
t.Fatalf("a failure was read as composed: %+v", c.Gate)
|
||||
}
|
||||
broken := link.BuildResult{ID: "b", Ref: headHost, Checked: asked, Failed: "the facts snapshot cannot be read"}
|
||||
if c := checkedOf(broken); c.Verdict != "error" || c.Gate.Verdict != "error" {
|
||||
t.Fatalf("an error was read as composed: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// The composed verdict on the bus carries every member, each with its trunk commit, for the forge's holder to set each
|
||||
// member's mesh/merge-gate from it.
|
||||
func TestTheComposedVerdictCarriesEveryMember(t *testing.T) {
|
||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-host", Number: 114, Group: "feat/x",
|
||||
Members: []link.GroupMember{{Owner: "novox", Repo: "mesh-controller", Number: 346, Ref: headController}}}
|
||||
c := checkedOf(link.BuildResult{ID: "g", Ref: headHost, Checked: asked, Check: &link.CheckOutcome{Verdict: "pass", Summary: "ok",
|
||||
Trunk: "aaaaaaaaaaaa", Members: []link.CheckedMember{{Owner: "novox", Repo: "mesh-controller", Commit: headController[:12], Trunk: "bbbbbbbbbbbb"}}}})
|
||||
body, _ := json.Marshal(c)
|
||||
for _, want := range []string{`"group":"feat/x"`, `"members":[{"owner":"novox","repo":"mesh-host","number":114,"commit":"` + headHost + `","trunk":"aaaaaaaaaaaa"}`,
|
||||
`{"owner":"novox","repo":"mesh-controller","number":346,"commit":"` + headController + `","trunk":"bbbbbbbbbbbb"}`} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Fatalf("the composed verdict lacks %s:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -815,18 +815,21 @@ func deliveryPlanDocument(m orderMember, entries []inventory.Entry, read map[str
|
||||
// delivery walks [-n 50] [--plan <id>]
|
||||
func deliveryCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("delivery order|check|go|stop|walks")
|
||||
return errors.New("delivery order|go|stop|walks")
|
||||
}
|
||||
sub, rest := args[0], args[1:]
|
||||
if sub == "plan" {
|
||||
// Removed (ADR 0450 §8; issue 1318): refused by name before any flag is read, naming its replacement.
|
||||
return removedByName("delivery-plan")
|
||||
}
|
||||
if sub == "check" {
|
||||
// Removed (ADR 0503 §3, ADR 0527; issue 1318): the checker judges every head, so the controller asks nothing.
|
||||
return removedByName("delivery-check")
|
||||
}
|
||||
set := flag.NewFlagSet("delivery "+sub, flag.ContinueOnError)
|
||||
membersJSON := set.String("members", "", "a group's members, as JSON")
|
||||
membersFile := set.String("members-file", "", "a file of a group's members, as JSON: the same as --members, for a list "+
|
||||
"too long for an argument (novox/hq issue 1272)")
|
||||
group := set.String("group", "", "a delivery group's id")
|
||||
ready := set.Bool("ready", false, "the head's pull request is marked ready: its check is queued before every other check "+
|
||||
"(novox/hq ADR 0406 §3)")
|
||||
by := set.String("by", catalogue.DeliverySeat, "who says it")
|
||||
@@ -883,25 +886,6 @@ func deliveryCommand(ctx context.Context, args []string) error {
|
||||
return fmt.Errorf("what the members say of their manifests cannot be read: %w", err)
|
||||
}
|
||||
return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges, changes))
|
||||
case "check":
|
||||
if *group == "" && len(members) == 1 {
|
||||
// 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.
|
||||
return recheckOneHead(members[0].ID, answer, func() (string, error) {
|
||||
var asked string
|
||||
err := sayingOnTheBus(ctx, inv, func() (err error) {
|
||||
asked, err = (following{open: open, oneShot: true}).pullUpdated(ctx, members[0].pull(), os.Stderr)
|
||||
return err
|
||||
})
|
||||
return asked, err
|
||||
})
|
||||
}
|
||||
if *group == "" || len(members) < 2 {
|
||||
return errors.New("delivery check --group <id> --members <json> (two heads or more), or --members <one head>")
|
||||
}
|
||||
// **The group's composed check is the checker's** (novox/hq ADR 0503 §3; issue 1299, C2): nothing is asked of
|
||||
// build-agent, and the answer says who judges it.
|
||||
return answer(groupCheckAnswer(*group))
|
||||
case "go":
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("delivery go <plan> [--by <who>] [--why <text>]")
|
||||
@@ -1076,14 +1060,6 @@ func theGraph(ctx context.Context, inv *inventory.Inventory) ([]inventory.Entry,
|
||||
return entries, read, edges, nil
|
||||
}
|
||||
|
||||
// twelve is a commit as twelve characters.
|
||||
func twelve(c string) string {
|
||||
if len(c) > 12 {
|
||||
return c[:12]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// appendNew appends what is not there yet.
|
||||
func appendNew(to []string, items ...string) []string {
|
||||
for _, i := range items {
|
||||
@@ -1235,32 +1211,3 @@ func overNATS(conn *nats.Conn) (link.Bus, error) {
|
||||
}
|
||||
return link.OverNATS{Conn: conn, JS: js}, nil
|
||||
}
|
||||
|
||||
// recheckAnswer is what `delivery check` answers for one head: the id of the check asked of the build seat (empty when
|
||||
// the controller said the verdict itself and asked nothing), which mesh-delivery reads, and the head rechecked.
|
||||
func recheckAnswer(asked, member string) map[string]any {
|
||||
return map[string]any{"asked": asked, "rechecked": member}
|
||||
}
|
||||
|
||||
// recheckOneHead runs a one-head `delivery check` and answers it as data. Standard output is the answer's JSON alone,
|
||||
// or the seat verb reads no answer from it (novox/hq issue 1311): while the check runs, whatever it prints to standard
|
||||
// output (the controller's own path prints its prose there, as do the stores it reads) goes to standard error.
|
||||
func recheckOneHead(member string, answer func(any) error, ask func() (string, error)) error {
|
||||
real := os.Stdout
|
||||
os.Stdout = os.Stderr
|
||||
asked, err := func() (string, error) {
|
||||
defer func() { os.Stdout = real }()
|
||||
return ask()
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return answer(recheckAnswer(asked, member))
|
||||
}
|
||||
|
||||
// groupCheckAnswer is what `delivery check` of a group answers from R4e: who judges it, and that nothing was asked
|
||||
// (novox/hq ADR 0503 §3; issue 1299, C2).
|
||||
func groupCheckAnswer(group string) map[string]any {
|
||||
return map[string]any{"asked": "", "group": group, "judged-by": checkerModule,
|
||||
"said": "the checker judges a delivery group's heads merged together (ADR 0503 §3): nothing was asked"}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// `delivery check` with one head answers the id of the check it asked, as data (novox/hq issue 1311): the prose
|
||||
// the check says goes to standard error, so standard output is the document alone and answerOf reads it.
|
||||
func TestDeliveryCheckOfOneHeadAnswersTheRequestID(t *testing.T) {
|
||||
argv := []string{"delivery", "check", "--members", "[]"}
|
||||
doc, err := json.Marshal(recheckAnswer("check-42", "novox/x@abc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seen failing first: the prose the command used to print on standard output before the document.
|
||||
polluted := append([]byte("novox/x#7 (abc): asked the build seat as check-42\n"), doc...)
|
||||
if got := answerOf(argv, polluted, "", true); got.Answer != nil {
|
||||
t.Fatalf("prose before the document should leave no answer; got %v", got.Answer)
|
||||
}
|
||||
got := answerOf(argv, doc, "novox/x#7 (abc): asked the build seat as check-42\n", true)
|
||||
m, ok := got.Answer.(map[string]any)
|
||||
if !ok || m["asked"] != "check-42" || m["rechecked"] != "novox/x@abc" {
|
||||
t.Fatalf("the answer is %v; want asked check-42 and rechecked novox/x@abc", got.Answer)
|
||||
}
|
||||
if !strings.Contains(got.Output, "asked the build seat") {
|
||||
t.Fatalf("the prose is still said in the text: %q", got.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// The text of the other callers does not change: sayChecked writes the same line to the writer it is given, and
|
||||
// the controller's own path still gives PullUpdated standard output.
|
||||
func TestSayCheckedWritesTheSameLineToItsWriter(t *testing.T) {
|
||||
var w bytes.Buffer
|
||||
sayChecked(context.Background(), &w, link.Checked{ID: "c1", Owner: "novox", Repo: "x", Number: 7, Commit: "0123456789abcdef",
|
||||
Verdict: "pass", Summary: "fine"})
|
||||
want := "c1: novox/x#7 at 01234567 checked on a machine that did not say which: repository none\n"
|
||||
if w.String() != want {
|
||||
t.Fatalf("sayChecked said %q; want %q", w.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the check prints to standard output while it runs (checks.go namedForTheCheck, the inventory's pointer
|
||||
// repair) must not reach the answer's standard output: it is exactly one JSON document (novox/hq issue 1311).
|
||||
func TestOneHeadRecheckLeavesStandardOutputTheAnswerAlone(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, ew, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realOut, realErr := os.Stdout, os.Stderr
|
||||
os.Stdout, os.Stderr = w, ew
|
||||
runErr := recheckOneHead("novox/x@abc", func(v any) error { return json.NewEncoder(os.Stdout).Encode(v) },
|
||||
func() (string, error) {
|
||||
fmt.Println(" the earlier checks' builds could not be read, so the check builds")
|
||||
fmt.Fprintln(os.Stdout, "repaired a pointer")
|
||||
return "check-42", nil
|
||||
})
|
||||
os.Stdout, os.Stderr = realOut, realErr
|
||||
w.Close()
|
||||
ew.Close()
|
||||
if runErr != nil {
|
||||
t.Fatal(runErr)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
_, _ = out.ReadFrom(r)
|
||||
_, _ = errOut.ReadFrom(e)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil || doc["asked"] != "check-42" || doc["rechecked"] != "novox/x@abc" {
|
||||
t.Fatalf("standard output %q is not the answer alone (%v)", out.String(), err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "repaired a pointer") {
|
||||
t.Fatalf("the prose should be on standard error: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ type stalledLine struct {
|
||||
// waiting for the owner's word and what holds it (novox/hq issues 362, 529).
|
||||
Walk string `json:"walk,omitempty"`
|
||||
// Waiting are the merge checks a line of the state `unanswered` waits on, as mesh-delivery says each:
|
||||
// "mesh/merge-gate pending since <UTC time>", "mesh/repo-check never set" (novox/hq issue 438).
|
||||
// "mesh/check pending since <UTC time>", "mesh/check never set" (novox/hq issue 438).
|
||||
Waiting []string `json:"waiting,omitempty"`
|
||||
// Checks are the same merge checks as data, which the controller words in the operator's own time (novox/hq
|
||||
// issue 443): a time inside a finished sentence cannot be said again in another zone. A mesh-delivery from before
|
||||
|
||||
@@ -136,8 +136,6 @@ func TestTheDeliveryVerbsComposeTheirCommands(t *testing.T) {
|
||||
{"delivery-walks", map[string]any{}, []string{"delivery", "walks"}},
|
||||
{"delivery-walks", map[string]any{"plan": "plan-1"}, []string{"delivery", "walks", "--plan", "plan-1"}},
|
||||
{"delivery-order", map[string]any{"members": "[]"}, []string{"delivery", "order", "--members", "[]"}},
|
||||
{"delivery-check", map[string]any{"group": "feat/x", "members": "[]"},
|
||||
[]string{"delivery", "check", "--group", "feat/x", "--members", "[]"}},
|
||||
{"plans", map[string]any{"go": "plan-1", "why": "down"}, []string{"plans", "go", "plan-1", "--why", "down"}},
|
||||
} {
|
||||
got, err := argvFor(c.verb, c.args)
|
||||
|
||||
@@ -669,7 +669,7 @@ func localDoctor(ctx context.Context) (*doctor, func(), error) {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
js, err := dialTheBus()
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
open.Close()
|
||||
return nil, nil, err
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
func TestADryRunsOutcomeIsTakenInByNothing(t *testing.T) {
|
||||
err := builds{}.Built(t.Context(), link.BuildResult{
|
||||
ID: "build-1", Repository: "ssh://forge/app.git", Ref: "unreviewed", Module: "app", DryRun: true,
|
||||
Manifest: json.RawMessage(`{"module":"app","version":"1"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a dry run's outcome was not simply set aside: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A dry run's outcome is looked at, never taken in (novox/hq issue 240). The daemon here holds no
|
||||
// store at all, so anything that tried to record or register would fail rather than pass quietly.
|
||||
|
||||
// The mark survives the wire both ways: asked as a dry run, answered as one.
|
||||
func TestTheDryRunMarkTravelsWithTheBuild(t *testing.T) {
|
||||
raw, _ := json.Marshal(link.BuildRequest{ID: "build-1", Repository: "r", DryRun: true})
|
||||
var asked link.BuildRequest
|
||||
if err := json.Unmarshal(raw, &asked); err != nil || !asked.DryRun {
|
||||
t.Fatalf("the request lost its dry-run mark: %s", raw)
|
||||
}
|
||||
raw, _ = json.Marshal(link.BuildResult{ID: "build-1", DryRun: true})
|
||||
var answered link.BuildResult
|
||||
if err := json.Unmarshal(raw, &answered); err != nil || !answered.DryRun {
|
||||
t.Fatalf("the outcome lost its dry-run mark: %s", raw)
|
||||
}
|
||||
raw, _ = json.Marshal(link.BuildResult{ID: "build-2"})
|
||||
if string(raw) != `{"id":"build-2","repository":"","on":""}` {
|
||||
t.Fatalf("an ordinary outcome carries a dry-run mark: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// What the core's bounds are set from (novox/hq to-be 45 Phase 0).
|
||||
@@ -24,25 +23,6 @@ import (
|
||||
// controller as it hears each — a send's first report, a machine's next word, a plan leaving a tier, a
|
||||
// build's outcome — and summarised here per machine, repository or module.
|
||||
|
||||
// recordBuildDuration measures one build from its ask to its outcome heard.
|
||||
func recordBuildDuration(ctx context.Context, inv *inventory.Inventory, result link.BuildResult, asked time.Time) {
|
||||
if asked.IsZero() || result.ID == "" {
|
||||
return
|
||||
}
|
||||
subject := result.Module
|
||||
if subject == "" {
|
||||
subject = result.Repository
|
||||
}
|
||||
detail := "built"
|
||||
if result.Failed != "" {
|
||||
detail = "failed: " + firstLine(result.Failed)
|
||||
}
|
||||
if err := inv.RecordDuration(ctx, inventory.Duration{Kind: inventory.DurationBuild, Subject: subject,
|
||||
Node: result.On, Ref: result.ID, Started: asked, Took: time.Since(asked), Detail: detail}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: how long it took could not be recorded: %v\n", result.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// durationSummary is one subject's measurements of one kind.
|
||||
type durationSummary struct {
|
||||
Kind string `json:"kind"`
|
||||
|
||||
@@ -64,7 +64,7 @@ func registerFrom(t *testing.T, forms *sourceForms) (int64, error) {
|
||||
mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true,
|
||||
request: inventory.BuildRequest{ID: "build-1", Repository: aRepoOnTheForge, For: "build"}}, aRepoOnTheForge}
|
||||
return mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", inventory.Source{Repository: aRepoOnTheForge},
|
||||
byBuild("build-1"), "", "main")
|
||||
registrationAsk{Build: "build-1"}, "", "main")
|
||||
}
|
||||
|
||||
// 1277/AC2: an address unread twice, then read, is waited out and the forge is asked.
|
||||
@@ -112,7 +112,7 @@ func TestAKnownAddressAndAnotherHostIsStillNotOnTheForge(t *testing.T) {
|
||||
const elsewhere = "http://elsewhere.example/x/y.git"
|
||||
mesh := &aMeshRegisteredFromTheURL{anEmptyMesh{asked: true, request: inventory.BuildRequest{ID: "build-1", For: "build"}}, elsewhere}
|
||||
_, err := mayRegisterIn(t.Context(), mesh, sourceFormsRead(context.Background(), time.Now, read), "mesh-delivery-view",
|
||||
inventory.Source{Repository: elsewhere}, byBuild("build-1"), "", "main")
|
||||
inventory.Source{Repository: elsewhere}, registrationAsk{Build: "build-1"}, "", "main")
|
||||
if !errors.Is(err, errNotItsSource) || !strings.Contains(err.Error(), "is not on the mesh's forge") {
|
||||
t.Fatalf("1277/AC4: the refusal changed: %v", err)
|
||||
}
|
||||
|
||||
@@ -444,8 +444,12 @@ func TestAMergeThatDeletesAModulePlansNothingToBuildForIt(t *testing.T) {
|
||||
if err := inv.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planBuilt(ctx, open, "kept", "", "http://forge/novox/mesh-catalog.git has no module.json at modules/kept, so "+
|
||||
"there is nothing saying what it is: open …/module.json: no such file or directory", asked0, "build-k")
|
||||
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-k", Module: "kept", Repository: "novox/mesh-catalog",
|
||||
At: time.Now().UTC(), Asked: asked0, Failed: "http://forge/novox/mesh-catalog.git has no module.json at modules/kept, so " +
|
||||
"there is nothing saying what it is: open …/module.json: no such file or directory"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advancePlans(ctx, open)
|
||||
p, _ := inv.PlanByID(ctx, "plan-deleted")
|
||||
if p.State == inventory.PlanFailed || p.Modules["kept"].State != planDeleted {
|
||||
t.Fatalf("a module deleted at its source failed the plan: %s %+v", p.State, p.Modules["kept"])
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The grants reach the bus before the gate (novox/hq issue 490).
|
||||
@@ -176,35 +174,3 @@ func TestTheGrantsStepKeepsOneMachinesBuilds(t *testing.T) {
|
||||
t.Error("the grants step keeps the builds of a machine it does not send")
|
||||
}
|
||||
}
|
||||
|
||||
// The delivery plan says the step before the merge.
|
||||
func TestAChangePlanSaysTheGrantsStep(t *testing.T) {
|
||||
const catalogue_ = "http://forge.internal:20000/novox/mesh-catalog.git"
|
||||
entry := func(name string, on ...string) inventory.Entry {
|
||||
e := fromRepo(name, catalogue_, "modules/"+name)
|
||||
e.Source.BuiltFrom = "old"
|
||||
e.On = on
|
||||
return e
|
||||
}
|
||||
nats := entry("nats", "novox")
|
||||
nats.Manifest.BusUsers = "/etc/nats/users.conf"
|
||||
nats.Manifest.Claims = []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}}
|
||||
restic := entry("restic", "ace", "novox", "shanks")
|
||||
only := entry("bus-tools", "novox")
|
||||
entries := []inventory.Entry{nats, restic, only}
|
||||
rollsOut := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true }
|
||||
plan := func(paths ...string) link.ChangePlan {
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "head", Paths: paths}
|
||||
return changePlanOf("novox/mesh-catalog", "main", "head", reachOfMerge(m, entries, nil, nil), entries, rollsOut)
|
||||
}
|
||||
|
||||
text := planText(plan("modules/restic/module.json"))
|
||||
if !strings.Contains(text, "grants step: if this changes what the bus's user list says (a new tool, subject or "+
|
||||
"user), novox is sent that list alone, every build there kept, before restic is judged on its first machine") {
|
||||
t.Errorf("the change plan does not say the grants step:\n%s", text)
|
||||
}
|
||||
// A module only on the bus's machine goes there with the list in its own send: no step of its own.
|
||||
if text := planText(plan("modules/bus-tools/module.json")); strings.Contains(text, "grants step") {
|
||||
t.Errorf("a module on the bus's machine alone reads:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// C2 (ADR 0503 §3; issue 1299): a delivery group's composed check is answered as the checker's, and nothing is asked
|
||||
// of build-agent: the command answers before any ask is composed.
|
||||
func TestTheGroupCheckNamesTheCheckerAndAsksNothing(t *testing.T) {
|
||||
a := groupCheckAnswer("feat/x")
|
||||
if a["judged-by"] != "mesh-delivery-checker" || a["asked"] != "" || a["group"] != "feat/x" {
|
||||
t.Fatalf("the group check answered %v", a)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestAWalkWaitingOnAHeldDeliveryNamesTheHold(t *testing.T) {
|
||||
"check did not pass for the heads that merged — a person decides: `mesh-delivery.release` (id " +
|
||||
"novox/mesh-sdk@d41d255f28ad, why) lets it go, `mesh-delivery.stop` ends it"
|
||||
waits := walkWaits{p.ID: {{ID: "novox/mesh-sdk@d41d255f28ad", State: "held", Walk: p.ID, Says: held}}}
|
||||
line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits)
|
||||
line := planLineSaid(p, now, tierAtLeast, nil, waits)
|
||||
for _, want := range []string{"held for a person", "composed check did not pass", "mesh-delivery.release"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("the line does not say %q: %q", want, line)
|
||||
@@ -41,7 +41,7 @@ func TestAWalkWaitingOnAHeldDeliveryNamesTheHold(t *testing.T) {
|
||||
t.Fatalf("a held walk's line points at a hand start: %q", line)
|
||||
}
|
||||
// Told nothing — an older mesh-delivery, or none answering — it reads as before.
|
||||
if line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, nil); !strings.Contains(line, "starts it by hand") {
|
||||
if line := planLineSaid(p, now, tierAtLeast, nil, nil); !strings.Contains(line, "starts it by hand") {
|
||||
t.Fatalf("told nothing, the line reads %q", line)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestAWalkWaitingOnADeliveryNotAtItsTurnSaysSo(t *testing.T) {
|
||||
p := waitingWalk(now)
|
||||
waits := walkWaits{p.ID: {{ID: "novox/mesh-sdk@9747f3e1b1ac", State: "published", Walk: p.ID,
|
||||
Says: "not let go yet: novox/mesh-tools@3f0c1a6cec18 (published) carried by it not at its turn"}}}
|
||||
line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits)
|
||||
line := planLineSaid(p, now, tierAtLeast, nil, waits)
|
||||
if !strings.Contains(line, "not at its turn") || !strings.Contains(line, "novox/mesh-tools@3f0c1a6cec18") {
|
||||
t.Fatalf("the line reads %q", line)
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestAWalkHeldByNothingSaysWhyAndTheHandStart(t *testing.T) {
|
||||
waits := walkWaits{p.ID: {{ID: "novox/mesh-media-catalog@cdcdcdcd0000", State: "unheld", Walk: p.ID,
|
||||
Says: "held by nothing: its merges' deliveries ended as nothing-to-move (novox/mesh-media-catalog@abababab0000), " +
|
||||
"yet the walk moves nzbgeek, nzbget — nothing gives it its word"}}}
|
||||
line := planLineSaid(p, now, pauseView{}, tierAtLeast, nil, waits)
|
||||
line := planLineSaid(p, now, tierAtLeast, nil, waits)
|
||||
if !strings.Contains(line, "ended as nothing-to-move") || !strings.Contains(line, "plans go "+p.ID) {
|
||||
t.Fatalf("the line reads %q", line)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// What the tests below take a build in with (R4g): the controller itself registers a module only from the builder's done
|
||||
// key now (takeInKey), but a great many of these tests only need a module registered with a build recorded, in the form
|
||||
// the old outcome carried, so that is kept here and nowhere else. It is the controller's take-in of a build-agent outcome
|
||||
// as it was, less the checks only an outcome needed. Nothing in the controller calls any of it.
|
||||
|
||||
// buildFrom turns what a builder said into what the mesh keeps.
|
||||
//
|
||||
// **By digest and path, never by where it was pushed** (novox/hq 04-ISSUES/102). The builder
|
||||
// says `<registry>:<port>/<module>/<artifact>@sha256:…`; the mesh records the artifact-store
|
||||
// reference and composes the store's address back in where a reference is used. `against` — what
|
||||
// the build stood on, the catalogue's edge — is recorded the same way, so an edge names a module's
|
||||
// artifact and not the machine it was pulled from.
|
||||
func buildFrom(result link.BuildResult) inventory.Build {
|
||||
kept := inventory.Build{
|
||||
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
|
||||
Commit: result.Commit, On: result.On, Failed: result.Failed,
|
||||
// **What the announcement carries, kept rather than discarded** (novox/hq 04-ISSUES/050).
|
||||
// The catalogue turns the manifest into requires/provides edges and `against` into build
|
||||
// edges, and it is not always listening when a build happens — on a fresh mesh it cannot
|
||||
// be, for exactly the modules it needs most. Keeping them is what makes a replay able to
|
||||
// rebuild the graph rather than a list of names.
|
||||
Path: result.Path,
|
||||
// What it was made from (novox/hq issue 280): two builds with one are one build.
|
||||
SourceFingerprint: result.SourceFingerprint,
|
||||
SourceInputs: result.SourceInputs,
|
||||
}
|
||||
// When it was asked, which is what orders it against another build of the same module
|
||||
// (novox/hq 04-ISSUES/219) — not when it was heard.
|
||||
if asked, ok := link.BuildAskedAt(result.ID); ok {
|
||||
kept.Asked = asked
|
||||
}
|
||||
for _, ref := range result.Against {
|
||||
kept.Against = append(kept.Against, catalogue.Recorded(ref))
|
||||
}
|
||||
for _, ref := range result.Mirrored {
|
||||
kept.Mirrored = append(kept.Mirrored, catalogue.Recorded(ref))
|
||||
}
|
||||
for _, r := range result.Read {
|
||||
kept.Read = append(kept.Read, inventory.ReadRepository{Repository: r.Repository, Ref: r.Ref})
|
||||
}
|
||||
// What it was made from, as files (novox/hq ADR 0267): the planner maps the next merge onto it.
|
||||
for _, s := range result.Sources {
|
||||
kept.Sources = append(kept.Sources, inventory.BuildSource{Repository: s.Repository, Ref: s.Ref, Paths: s.Paths})
|
||||
}
|
||||
var announced []inventory.Artifact
|
||||
for _, made := range result.Made {
|
||||
announced = append(announced, inventory.Artifact{
|
||||
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
|
||||
})
|
||||
kept.Made = append(kept.Made, inventory.Artifact{
|
||||
Name: made.Name, Kind: made.Kind, Reference: catalogue.Recorded(made.Reference),
|
||||
})
|
||||
}
|
||||
kept.Manifest = recordedManifest(result.Manifest, announced)
|
||||
// The module name comes from the manifest, which only exists when the build got that far.
|
||||
if len(kept.Manifest) > 0 {
|
||||
if list, err := judgedWith(result); err == nil {
|
||||
if m, err := catalogue.ParseManifestWith(kept.Manifest, list); err == nil {
|
||||
kept.Module = m.Module
|
||||
}
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// recordedManifest is a build's manifest with the store's address taken off every reference the
|
||||
// build itself made. Other references — an image a module runs from a public registry — are what
|
||||
// they were, which is why this rewrites only what `made` names rather than everything that looks
|
||||
// like an address.
|
||||
func recordedManifest(raw json.RawMessage, made []inventory.Artifact) json.RawMessage {
|
||||
announced := map[string]bool{}
|
||||
for _, a := range made {
|
||||
announced[a.Reference] = true
|
||||
}
|
||||
return withReferences(raw, func(ref string) (string, bool) {
|
||||
if !announced[ref] {
|
||||
return ref, false
|
||||
}
|
||||
return catalogue.Recorded(ref), true
|
||||
})
|
||||
}
|
||||
|
||||
// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and
|
||||
// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's
|
||||
// result reaches the catalogue whether or not the asker was still listening.
|
||||
//
|
||||
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
|
||||
// nobody asked for, and the difference is the whole of whether somebody should be looking at
|
||||
// something. Then parsed with the same parser a hand-written manifest goes through — a second path
|
||||
// would be a second thing to disagree about what a manifest is — and registered with where it came
|
||||
// from: **for a source on a seat, as the path and the seat, never the URL just cloned** (ADR 0111),
|
||||
// which the request carried and the outcome echoes. A definition naming an installation is refused
|
||||
// here, where it would enter the catalogue; the build stays recorded and the refusal says which.
|
||||
//
|
||||
// Idempotent: the same outcome taken in twice registers the same module twice, which is one row
|
||||
// written with the same values.
|
||||
func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResult) (
|
||||
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)
|
||||
if err := inv.RecordBuild(ctx, kept); err != nil {
|
||||
return catalogue.Manifest{}, kept, err
|
||||
}
|
||||
if result.Failed != "" {
|
||||
// The builder's own words. Wrapping them in something about the control plane would put
|
||||
// two explanations between a person and a build log.
|
||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s could not build %s:\n%s",
|
||||
result.On, result.Repository, result.Failed)
|
||||
}
|
||||
// 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 {
|
||||
return catalogue.Manifest{}, kept, fmt.Errorf("%s built %s and what came back is not a manifest: %w",
|
||||
result.On, result.Repository, err)
|
||||
}
|
||||
recorded := inventory.Source{
|
||||
Repository: result.Repository, Path: result.Path, Ref: result.Ref,
|
||||
BuiltFrom: result.Commit, Head: result.Commit,
|
||||
// What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4).
|
||||
Against: kept.Against,
|
||||
// When it was asked, so an older request heard later does not replace a newer one
|
||||
// (novox/hq 04-ISSUES/219).
|
||||
Asked: kept.Asked,
|
||||
}
|
||||
if result.Source != nil && result.Source.Seat != "" {
|
||||
recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat
|
||||
}
|
||||
// **A build at a commit does not change the branch a module follows** (novox/hq 04-ISSUES/215):
|
||||
// the commit is built and recorded as what it was built from, and the module keeps following
|
||||
// what it followed before — the repository's default branch for one new to the catalogue.
|
||||
if followedBranch(result.Ref) == "" && result.Ref != "" {
|
||||
recorded.Ref = ""
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||
recorded.Ref = followedBranch(was.Ref)
|
||||
}
|
||||
}
|
||||
trunk := result.Trunk
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil && followedBranch(was.Ref) != "" {
|
||||
trunk = followedBranch(was.Ref)
|
||||
}
|
||||
if trunk == "" {
|
||||
trunk = "main"
|
||||
}
|
||||
j := takenBuild{kept: kept, manifest: manifest, recorded: recorded, ask: registrationAsk{Build: result.ID}, path: result.Path,
|
||||
trunk: trunk, by: result.On, what: result.Repository, commit: result.Commit}
|
||||
// The checks only an outcome of build-agent needs: a key is a trunk build by construction (ADR 0501 §2), in no
|
||||
// scratch namespace, and carries no list of its own.
|
||||
j.published = func() error {
|
||||
// **Only a commit on the trunk is published** (novox/hq ADR 0238): a commit off its repository's
|
||||
// default branch — a pull request's head, a feature branch built by hand, a `rebuild` or `replay
|
||||
// --register` of one — is for checking, and is never a module's version; nothing could then send it.
|
||||
// The branch the module already follows is its trunk — never the branch a build was asked at, or a
|
||||
// build of a feature branch by name would make that branch its trunk.
|
||||
follows := ""
|
||||
if was, err := inv.SourceOf(ctx, manifest.Module); err == nil {
|
||||
follows = followedBranch(was.Ref)
|
||||
}
|
||||
if err := publishable(result, follows); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
j.carried = func() error {
|
||||
// **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.
|
||||
carried, err := carriedList(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
manifest, err = registerJudged(ctx, inv, j)
|
||||
return manifest, kept, err
|
||||
}
|
||||
|
||||
// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published.
|
||||
var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " +
|
||||
"never published (ADR 0238) — merge it, and the merge builds it")
|
||||
|
||||
// publishable says whether a build's outcome may become a module's version: its commit on the module's
|
||||
// trunk, as the build seat read the forge at the build — the branch the module follows when its source
|
||||
// names one, else its repository's default branch. A build seat that could not say — one older than the
|
||||
// rule, building its own successor — is let through and said, so the rule can reach the mesh.
|
||||
func publishable(result link.BuildResult, follows string) error {
|
||||
if result.DryRun {
|
||||
return errors.New("a dry run is never published")
|
||||
}
|
||||
if result.Trunk == "" {
|
||||
fmt.Printf("%s: the build seat did not say whether %s is on its repository's trunk (it predates ADR 0238); "+
|
||||
"registered as before\n", result.ID, short(result.Commit))
|
||||
return nil
|
||||
}
|
||||
trunk := result.Trunk
|
||||
if follows != "" {
|
||||
trunk = follows
|
||||
}
|
||||
on := slices.Contains(result.Branches, trunk) || (trunk == result.Trunk && result.OnTrunk)
|
||||
if !on {
|
||||
return fmt.Errorf("%w (%s is not on %s)", errOffTheTrunk, short(result.Commit), trunk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// builds is what the tests of a pending assignment feed an outcome to: the take-in above, then the tick that settles what
|
||||
// waited for it (settlePending), as the daemon's hearing of an outcome did before R4g (it also moved the plans, which the
|
||||
// walk now does from its keys). Nothing in the controller calls it.
|
||||
type builds struct {
|
||||
inv *inventory.Inventory
|
||||
open *stores
|
||||
}
|
||||
|
||||
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
||||
_, _, err := takeIn(ctx, b.inv, result)
|
||||
if b.open != nil {
|
||||
settlePending(ctx, b.open, time.Now())
|
||||
}
|
||||
if result.Failed != "" {
|
||||
return nil // the build is recorded; whether it registered is what the tests read
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -34,16 +34,6 @@ func deafObservations(now time.Time, held func(broker.Consumer) (uint64, error))
|
||||
return obs[0].Said, len(obs)
|
||||
}
|
||||
|
||||
// 1063/AC1: a check in flight on a builder is not the controller's event loop being deaf.
|
||||
func TestDeafLookIgnoresCheckWorkers(t *testing.T) {
|
||||
now := time.Now()
|
||||
for _, w := range broker.CheckWorkers() {
|
||||
if said, n := deafObservations(now, holdingOne(w.Stream+"/"+w.Name)); n != 0 {
|
||||
t.Fatalf("%s holding one message raised controller-deaf: %s", w.Name, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1063/AC2: a message held by one of the controller's own consumers is still deaf, naming its stream.
|
||||
func TestDeafLookStillCountsOwnConsumers(t *testing.T) {
|
||||
now := time.Now()
|
||||
@@ -55,25 +45,16 @@ func TestDeafLookStillCountsOwnConsumers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1063/AC3: the list is the controller's consumers and none of the check workers.
|
||||
// 1063/AC3: the list is the controller's consumers: the mesh defines no other durable consumer the loop could read (the
|
||||
// check workers went with the checks' queues, ADR 0525 clause 6).
|
||||
func TestDeafLookReadsExactlyTheControllersOwnConsumers(t *testing.T) {
|
||||
workers := map[string]bool{}
|
||||
for _, w := range broker.CheckWorkers() {
|
||||
workers[w.Stream+"/"+w.Name] = true
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, c := range broker.MeshConsumers() {
|
||||
if !workers[c.Stream+"/"+c.Name] {
|
||||
want[c.Stream+"/"+c.Name] = true
|
||||
}
|
||||
want[c.Stream+"/"+c.Name] = true
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, c := range loopConsumers() {
|
||||
k := c.Stream + "/" + c.Name
|
||||
if workers[k] {
|
||||
t.Errorf("the deaf look reads the check worker %s", k)
|
||||
}
|
||||
got[k] = true
|
||||
got[c.Stream+"/"+c.Name] = true
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("deaf look reads %v, want %v", got, want)
|
||||
|
||||
+19
-117
@@ -8,7 +8,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/identity"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/licences"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
@@ -82,8 +80,6 @@ func run() error {
|
||||
case "build", "rebuild":
|
||||
// **The hand build goes with the asks** (novox/hq ADR 0501 §5): refused by name, naming the replacement.
|
||||
return removedByName(args[0])
|
||||
case "builder":
|
||||
return builderCommand(ctx, args[1:])
|
||||
case "board":
|
||||
return boardCommand(ctx, args[1:])
|
||||
case "api":
|
||||
@@ -98,23 +94,10 @@ func run() error {
|
||||
return rehearseCommand(ctx, args[1:])
|
||||
case "builds":
|
||||
return buildsCommand(ctx, args[1:])
|
||||
// The build queue, controlled by hand (novox/hq ADR 0219).
|
||||
case "queue":
|
||||
return queueCommand(ctx, args[1:])
|
||||
case "cancel":
|
||||
return cancelCommand(ctx, args[1:])
|
||||
case "clear":
|
||||
return clearCommand(ctx, args[1:])
|
||||
case "replay":
|
||||
return replayCommand(ctx, args[1:])
|
||||
case "kill":
|
||||
return killCommand(ctx, args[1:])
|
||||
case "forget-checks":
|
||||
return removedByName("forget-checks")
|
||||
case "forget-checks", "queue", "cancel", "clear", "kill", "replay", "pause", "resume":
|
||||
return removedByName(args[0])
|
||||
case "try":
|
||||
return tryCommand(ctx, args[1:])
|
||||
case "pause", "resume":
|
||||
return pauseCommand(ctx, args[0], args[1:])
|
||||
case "collection":
|
||||
return collectionCommand(ctx, args[1:])
|
||||
// What the records say the registries may keep, asked on demand (novox/hq ADR 0251).
|
||||
@@ -157,12 +140,6 @@ func run() error {
|
||||
// The facts snapshot a merge check is fed (novox/hq to-be 45 §9).
|
||||
case "facts":
|
||||
return factsCommand(ctx, args[1:])
|
||||
// The merge gate: every machine of the snapshot composed with a change (novox/hq to-be 45 §9).
|
||||
case "merge-gate":
|
||||
return mergeGateCommand(ctx, args[1:])
|
||||
// A pull request's merge check run here exactly as the build seat runs it (novox/hq issue 286).
|
||||
case "check-here":
|
||||
return checkHereCommand(ctx, args[1:])
|
||||
case "upgrade":
|
||||
return upgradeCommand(ctx, args[1:])
|
||||
// The bus as a planned step (novox/hq to-be 45 §8, ADR 0236).
|
||||
@@ -317,13 +294,7 @@ var usageText = `mesh-controller — the control plane
|
||||
--reference <artifact>=<reference> (repeatable) and --why, at artifacts the store
|
||||
holds, recorded as a build by hand (ADR 0526 §3)
|
||||
switch [--json] the switch's list: each module, the build it runs, and what the builder holds
|
||||
builds [<module>] what has been built lately, and what came of it
|
||||
queue [--json] every ask in the build queue: waiting, in flight (where, how long), dead
|
||||
cancel <id> drop a waiting or dead ask; recorded failed, cancelled by hand
|
||||
clear [--dead] cancel every waiting ask (and the dead ones); never one in flight
|
||||
replay <build-id> that build's commit again, a dry run: what a build of it makes
|
||||
kill <id> end a build where it runs; recorded failed, killed by hand
|
||||
pause [<node>] / resume [<node>] the build seat's holder there, or every holder, takes nothing new / again
|
||||
builds [<module>] [-n N] what has been built lately: the builder's done keys, recorded as builds
|
||||
plans retry <id> ask a failed plan's failed builds again, and carry the plan on
|
||||
plans stop|close <id> --why <text> end a plan by hand; recorded in the hand-act log
|
||||
hand-act record <what> --why <text> --cause <word> [--condition <key>]
|
||||
@@ -340,8 +311,6 @@ var usageText = `mesh-controller — the control plane
|
||||
durations [--kind K] [--days N] [--json]
|
||||
apply, heartbeat, plan-tier, build and walk-phase durations
|
||||
collection [--json] kept archives held/unheld by a manifest, and what the sweep may let go
|
||||
builder issue <name> a broker account for a build machine, scoped to build work,
|
||||
delivered as the builder module's broker secret (module add it first)
|
||||
licence add|list|use|key model access, under the name a person calls it
|
||||
licence manager <name> <node> the node that holds a refreshable licence's refresh token
|
||||
licence refresh <name> mint a new access token and seal it to every holder
|
||||
@@ -392,87 +361,6 @@ func parseAround(set *flag.FlagSet, args []string) ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Built is the daemon hearing a build's outcome on the bus — its own asking, an announcement's, or
|
||||
// a tool's that did not wait (novox/hq issue 176) — and taking it in: recorded, and the module
|
||||
// registered, the same as the waiting command does. Said either way, so the daemon's log tells what
|
||||
// became of a build nobody was watching.
|
||||
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
||||
// **A merge check builds nothing** (novox/hq to-be 45 §9): its verdict is said, and nothing of it is
|
||||
// recorded or registered.
|
||||
if result.Check != nil || result.Checked != nil {
|
||||
if isTry(result) {
|
||||
// A branch tried before its pull request (novox/hq issue 686): its asker reads the outcome from the
|
||||
// build's log. Never said as `checked`, so the forge's holder sets no status.
|
||||
fmt.Printf("%s: a try of %s at %.8s, read with `try --id`: said to nobody, nothing taken in\n", result.ID,
|
||||
result.Repository, result.Ref)
|
||||
return nil
|
||||
}
|
||||
checked(ctx, result)
|
||||
return nil
|
||||
}
|
||||
// **A dry run is looked at, never taken in** (novox/hq issue 240). On 2026-10-04 a dry run of an
|
||||
// unmerged branch was heard here like any build, registered, and its definition reached a machine
|
||||
// before anyone had reviewed it.
|
||||
if result.DryRun {
|
||||
fmt.Printf("%s: a dry run of %s on %s, not taken in\n", result.ID, result.Repository, result.Ref)
|
||||
return nil
|
||||
}
|
||||
manifest, _, err := takeIn(ctx, b.inv, result)
|
||||
// The assignments pending on this build, made or ended (novox/hq issue 325), said in the daemon's log
|
||||
// after what became of the build.
|
||||
if b.open != nil {
|
||||
defer func() {
|
||||
for _, line := range settlePendingOnBuild(ctx, b.open, result, manifest.Module, err) {
|
||||
fmt.Printf("%s: %s\n", result.ID, line)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// When it was asked, so a plan takes as its outcome only a build asked for it or after it
|
||||
// (novox/hq 04-ISSUES/219). Zero when the id does not say.
|
||||
asked, _ := link.BuildAskedAt(result.ID)
|
||||
// And how long it took, asked to heard, which a build's bound will be set from (novox/hq to-be 45
|
||||
// Phase 0). Said if lost; never a reason not to take the build in.
|
||||
recordBuildDuration(ctx, b.inv, result, asked)
|
||||
switch {
|
||||
case err != nil && result.Failed != "":
|
||||
fmt.Printf("%s: %v\n", result.ID, err)
|
||||
if result.Module != "" {
|
||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed, asked, result.ID)
|
||||
} else {
|
||||
planFailedBuild(ctx, b.open, result)
|
||||
}
|
||||
return nil
|
||||
case errors.Is(err, inventory.ErrSuperseded):
|
||||
// Not a failure: the module is already at what a later request built. A plan that asked
|
||||
// before that later request is answered by it; one that asked after it ignores this.
|
||||
fmt.Printf("%s: %v\n", result.ID, err)
|
||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID)
|
||||
return nil
|
||||
case err != nil:
|
||||
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
||||
takeRefused(ctx, b.inv, result, manifest.Module, err.Error(), refusedOutcome{
|
||||
built: func(module, why string) {
|
||||
planBuilt(ctx, b.open, module, result.Commit, why, asked, result.ID)
|
||||
},
|
||||
failedBuild: func(failed link.BuildResult) { planFailedBuild(ctx, b.open, failed) },
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||
// What the walk's build did, kept on the walk whose ask it answers (novox/hq ADR 0350 §4); a build seat from
|
||||
// before it says nothing, and that is what is kept.
|
||||
made := result.MadeWords()
|
||||
if result.Reused != "" || result.BuiltWhy != "" {
|
||||
fmt.Printf("%s: %s %s\n", result.ID, manifest.Module, made)
|
||||
}
|
||||
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
||||
planBuiltSaying(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID, made)
|
||||
// A module registered may be one a machine is now behind: `status` is composed again.
|
||||
statusFrom.nudge()
|
||||
return nil
|
||||
}
|
||||
|
||||
// verbVar carries the seat verb a command runs for, from the serving controller to the process it starts.
|
||||
const verbVar = "MESH_VERB"
|
||||
|
||||
@@ -497,10 +385,24 @@ func removedByName(command string) error {
|
||||
"catalogue does not build is registered at the controller's terminal with `register`",
|
||||
"rebuild": "the controller asks no build (ADR 0501 §5): a failed key is built again by the builder as a new " +
|
||||
"revision (ADR 0429 §18), and a walk's Retry waits for it",
|
||||
"replay --register": "a build is registered only from the builder's done key of a trunk fingerprint (ADR 0501 " +
|
||||
"§1, §2); `replay` without --register still shows what a build of a recorded commit makes",
|
||||
"replay": "the controller asks no build, so there is nothing to replay (ADR 0501 §5, ADR 0525 clause 6); a branch is " +
|
||||
"tried with `try`",
|
||||
"queue": "the controller keeps no build queue (ADR 0525 clause 6): the builds and checks are asks on the build modules' " +
|
||||
"streams, and the delivery view shows their backlog and the oldest ask's age",
|
||||
"cancel": "the controller keeps no build queue (ADR 0525 clause 6): an ask is on a build module's stream, and the delivery " +
|
||||
"view shows the backlog",
|
||||
"clear": "the controller keeps no build queue (ADR 0525 clause 6): an ask is on a build module's stream, and the delivery " +
|
||||
"view shows the backlog",
|
||||
"kill": "the controller keeps no build queue and starts no build (ADR 0525 clause 6): a build runs on a build module, " +
|
||||
"which ends it",
|
||||
"pause": "the build seat is retired (ADR 0525 clause 7): a node's builds are paused with its build module's own pause, " +
|
||||
"or a capacity of 0",
|
||||
"resume": "the build seat is retired (ADR 0525 clause 7): a node's builds are resumed with its build module's own " +
|
||||
"resume, or its capacity",
|
||||
"forget-checks": "build-agent makes no check build from R4e (ADR 0503 §3), so there is nothing to let go of",
|
||||
"delivery-plan": "a delivery's plan is read with `delivery-order` and the checker's verdict (ADR 0503 §3)",
|
||||
"delivery-check": "the checker judges every head and every delivery group (ADR 0503 §3, ADR 0527): the controller asks " +
|
||||
"nothing and says no verdict of a pull request",
|
||||
}[command]
|
||||
return &removedCommand{msg: fmt.Sprintf("`%s` is removed: %s", command, replacement)}
|
||||
}
|
||||
|
||||
@@ -47,18 +47,6 @@ func readBackFromTheCommand(t *testing.T, argv []string, members string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCheckHandsALongMembersListInAFile(t *testing.T) { // 1272/AC1
|
||||
members := aLongGroup(t)
|
||||
argv, err := argvFor("delivery-check", map[string]any{"group": "feat/x", "members": members})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if argv[0] != "delivery" || argv[1] != "check" || !slices.Contains(argv, "--group") {
|
||||
t.Errorf("the command is %.100v", strings.Join(argv, " "))
|
||||
}
|
||||
readBackFromTheCommand(t, argv, members)
|
||||
}
|
||||
|
||||
func TestDeliveryOrderHandsALongMembersListInAFile(t *testing.T) { // 1272/AC2
|
||||
members := aLongGroup(t)
|
||||
argv, err := argvFor("delivery-order", map[string]any{"members": members})
|
||||
@@ -73,8 +61,8 @@ func TestDeliveryOrderHandsALongMembersListInAFile(t *testing.T) { // 1272/AC2
|
||||
|
||||
func TestAShortMembersListIsStillTheArgumentItWas(t *testing.T) { // 1272/AC3
|
||||
members := `[{"id":"novox/a#1"},{"id":"novox/b#2"}]`
|
||||
argv, err := argvFor("delivery-check", map[string]any{"group": "feat/x", "members": members, "ready": "true"})
|
||||
want := []string{"delivery", "check", "--group", "feat/x", "--members", members, "--ready"}
|
||||
argv, err := argvFor("delivery-order", map[string]any{"members": members})
|
||||
want := []string{"delivery", "order", "--members", members}
|
||||
if err != nil || !slices.Equal(argv, want) {
|
||||
t.Errorf("a short list gives %v, %v, want %v", argv, err, want)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheGateJudgesAFileOfPathsAsItDoesTheSameListOfThem(t *testing.T) { // 1247/AC3
|
||||
paths := []string{"modules/a/x.go", "modules/a/y.go", "modules/b/z.go", "README.md"}
|
||||
file := filepath.Join(t.TempDir(), "changed.txt")
|
||||
if err := os.WriteFile(file, []byte(strings.Join(paths, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromFile, err := changedPaths("", file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromList, err := changedPaths(strings.Join(paths, ","), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(fromFile, fromList) || len(fromFile) != len(paths) {
|
||||
t.Errorf("the file gives %v, the list %v", fromFile, fromList)
|
||||
}
|
||||
if _, err := changedPaths("", filepath.Join(t.TempDir(), "none")); err == nil {
|
||||
t.Error("a file that is not there is read as no paths")
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
func TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
said := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
|
||||
for node, contract := range map[string]int{"anchor": link.HandedContract, "laptop": link.HandedContract - 1} {
|
||||
if _, err := open.inventory.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: contract,
|
||||
SaidAt: said, HeardAt: said}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.Machines) != 2 {
|
||||
t.Fatalf("the snapshot holds %d machines", len(f.Machines))
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, m := range f.Machines {
|
||||
if m.Contract == 0 {
|
||||
t.Fatalf("%s: the snapshot carries no contract", m.Name)
|
||||
}
|
||||
want[m.Name] = m.Contract >= link.HandedContract
|
||||
}
|
||||
handing := 0
|
||||
for _, hands := range want {
|
||||
if hands {
|
||||
handing++
|
||||
}
|
||||
}
|
||||
if handing != 1 {
|
||||
t.Fatalf("one machine takes handed files in the snapshot, got %v", want)
|
||||
}
|
||||
|
||||
raised, drop, err := throwawayStores(ctx, os.Getenv("MESH_TEST_POSTGRES"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer drop()
|
||||
if _, err := raiseFromFacts(ctx, raised, f, map[string]catalogue.Manifest{}, map[string]inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, hands := range want {
|
||||
in, err := readComposeInput(ctx, raised)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node, _ := nodeOf(in.Input, name)
|
||||
if got := node.Stated && node.Contract >= compose.HandedContract; got != hands {
|
||||
t.Errorf("%s: the gate raises it as taking handed files = %v (%v), the mesh says %v", name, got, err, hands)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Issue 969: the gate composes a machine as the mesh does, so a moved module is handed where the machine takes
|
||||
// handed files and composed the old way where it does not.
|
||||
func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing.T) {
|
||||
taken := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
stated, ok := standInHealth(snapshot.Machine{Name: "f35", Contract: link.HandedContract}, taken)
|
||||
if !ok || stated.Contract < link.HandedContract {
|
||||
t.Fatalf("a machine whose engine takes handed files must be given that in the gate: %+v, %v", stated, ok)
|
||||
}
|
||||
old, ok := standInHealth(snapshot.Machine{Name: "e12", Contract: link.HandedContract - 1}, taken)
|
||||
if !ok || old.Contract >= link.HandedContract {
|
||||
t.Fatalf("a machine whose engine is older must not take handed files: %+v, %v", old, ok)
|
||||
}
|
||||
none, ok := standInHealth(snapshot.Machine{Name: "d01"}, taken)
|
||||
if ok {
|
||||
t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 969, through the store: a machine whose engine stated the handed contract is in the snapshot with it, the
|
||||
// gate raises it as taking handed files, and one whose statement is below it is raised as not taking them. Run in
|
||||
// the merge check with the other tests that need a store; the composition itself, handed here and refused there, is
|
||||
// the catalogue's TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded.
|
||||
@@ -1,135 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
)
|
||||
|
||||
// Config values held for the keys an open pull request introduces (novox/hq ADR 0502 clause 4, issue 1826): the gate
|
||||
// lays each approved one over its module's values on its node in the composition with the change, and in that one
|
||||
// only. In the mesh as it is the key is not declared, so the value would be refused there.
|
||||
|
||||
// heldValue is one approved held value as the build agent writes it (--held): the value of Key for Module on Node,
|
||||
// a machine's real name, which the snapshot holds only as a pseudonym.
|
||||
type heldValue struct {
|
||||
Pull string `json:"pull"`
|
||||
Head string `json:"head,omitempty"`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Approved string `json:"approved,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
// readHeld reads the file --held names; none named, none held.
|
||||
func readHeld(path string) ([]heldValue, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var held []heldValue
|
||||
if err := json.Unmarshal(raw, &held); err != nil {
|
||||
return nil, fmt.Errorf("the held values are not readable: %w", err)
|
||||
}
|
||||
for _, h := range held {
|
||||
if h.Node == "" || h.Module == "" || h.Key == "" || len(h.Value) == 0 {
|
||||
return nil, fmt.Errorf("a held value of %s names no node, module, key or value", h.Pull)
|
||||
}
|
||||
}
|
||||
return held, nil
|
||||
}
|
||||
|
||||
// layHeld is the snapshot with each held value laid as the value of its key in its node's layer of its module, and what
|
||||
// it said of them. The snapshot given is not changed. **A held value for a machine the snapshot does not hold, or a
|
||||
// module the change does not define, is said and not laid**, never dropped silently; one for a module not assigned to
|
||||
// its machine is laid and said. The notes name machines as the snapshot does (roles and pseudonym), not by their names.
|
||||
func layHeld(f snapshot.Facts, held []heldValue, shelf map[string]catalogue.Manifest) (snapshot.Facts, []string) {
|
||||
if len(held) == 0 {
|
||||
return f, nil
|
||||
}
|
||||
out := f
|
||||
out.Machines = make([]snapshot.Machine, len(f.Machines))
|
||||
copy(out.Machines, f.Machines)
|
||||
var notes, laid []string
|
||||
pulls := map[string]bool{}
|
||||
for _, h := range held {
|
||||
at := -1
|
||||
pseudonym := snapshot.Pseudonym("machine", h.Node)
|
||||
for i, m := range out.Machines {
|
||||
if m.Name == pseudonym {
|
||||
at = i
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s=%s) is not composed: the snapshot holds no machine %s",
|
||||
h.Module, h.Pull, h.Key, h.Value, pseudonym))
|
||||
continue
|
||||
}
|
||||
if _, defined := shelf[h.Module]; !defined {
|
||||
notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s=%s) is not composed: the change defines no module %s",
|
||||
h.Module, h.Pull, h.Key, h.Value, h.Module))
|
||||
continue
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(h.Value, &value); err != nil {
|
||||
notes = append(notes, fmt.Sprintf("the value held for %s of %s (%s) is not composed: it is not a value: %v",
|
||||
h.Module, h.Pull, h.Key, err))
|
||||
continue
|
||||
}
|
||||
m := &out.Machines[at]
|
||||
layers := make([]snapshot.Settings, len(m.Settings))
|
||||
copy(layers, m.Settings)
|
||||
found := false
|
||||
for i := range layers {
|
||||
if layers[i].Module == h.Module {
|
||||
values := make(map[string]any, len(layers[i].Values)+1)
|
||||
for k, v := range layers[i].Values {
|
||||
values[k] = v
|
||||
}
|
||||
values[h.Key] = value
|
||||
layers[i].Values = values
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
layers = append(layers, snapshot.Settings{Module: h.Module, Values: map[string]any{h.Key: value}})
|
||||
sort.Slice(layers, func(a, b int) bool { return layers[a].Module < layers[b].Module })
|
||||
}
|
||||
m.Settings = layers
|
||||
assigned := false
|
||||
for _, a := range m.Assigned {
|
||||
assigned = assigned || a == h.Module
|
||||
}
|
||||
by := ""
|
||||
if h.Approved != "" {
|
||||
by = ", approved by " + h.Approved
|
||||
}
|
||||
line := fmt.Sprintf("%s on %s %s=%s%s", h.Module, m.Described(), h.Key, h.Value, by)
|
||||
if !assigned {
|
||||
line += " (not assigned there in the snapshot)"
|
||||
}
|
||||
laid = append(laid, line)
|
||||
pulls[h.Pull] = true
|
||||
}
|
||||
if len(laid) > 0 {
|
||||
var names []string
|
||||
for p := range pulls {
|
||||
names = append(names, p)
|
||||
}
|
||||
sort.Strings(names)
|
||||
notes = append([]string{fmt.Sprintf("composed with %d value(s) held for %s: %s", len(laid), strings.Join(names, ", "),
|
||||
strings.Join(laid, "; "))}, notes...)
|
||||
}
|
||||
return out, notes
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
)
|
||||
|
||||
func heldGateFacts() snapshot.Facts {
|
||||
return snapshot.Facts{Machines: []snapshot.Machine{
|
||||
{Name: snapshot.Pseudonym("machine", "ace"), Roles: []string{"the build node"}, Assigned: []string{"n8n"},
|
||||
Settings: []snapshot.Settings{{Module: "n8n", Values: map[string]any{"port": 5678.0}}}},
|
||||
{Name: snapshot.Pseudonym("machine", "g14")},
|
||||
}}
|
||||
}
|
||||
|
||||
var heldShelf = map[string]catalogue.Manifest{"n8n": {Module: "n8n"}}
|
||||
|
||||
// 1826/AC3: a held value is laid in its node's layer of its module, beside what the layer holds, and said.
|
||||
func TestAHeldValueIsLaidOverItsNodesLayerAndSaid(t *testing.T) {
|
||||
f := heldGateFacts()
|
||||
got, notes := layHeld(f, []heldValue{{Pull: "novox/mesh-catalog#434", Node: "ace", Module: "n8n", Key: "media-gid",
|
||||
Value: []byte("2000"), Approved: "the operator"}}, heldShelf)
|
||||
layer := got.Machines[0].Settings[0].Values
|
||||
if layer["media-gid"] != 2000.0 || layer["port"] != 5678.0 {
|
||||
t.Fatalf("the layer holds %v", layer)
|
||||
}
|
||||
if _, has := f.Machines[0].Settings[0].Values["media-gid"]; has {
|
||||
t.Fatal("the snapshot given was changed")
|
||||
}
|
||||
if len(notes) != 1 || !strings.Contains(notes[0], "composed with 1 value(s) held for novox/mesh-catalog#434") ||
|
||||
!strings.Contains(notes[0], "media-gid=2000") || !strings.Contains(notes[0], "approved by the operator") ||
|
||||
strings.Contains(notes[0], "ace") {
|
||||
t.Fatalf("said %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/AC3: a machine with no layer of the module gets one.
|
||||
func TestAHeldValueMakesTheLayerAMachineLacks(t *testing.T) {
|
||||
got, _ := layHeld(heldGateFacts(), []heldValue{{Pull: "p", Node: "g14", Module: "n8n", Key: "k", Value: []byte(`"x"`)}}, heldShelf)
|
||||
s := got.Machines[1].Settings
|
||||
if len(s) != 1 || s[0].Module != "n8n" || s[0].Values["k"] != "x" {
|
||||
t.Fatalf("layers %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/AC3: a held value for a machine or a module the snapshot or the change does not hold is said, never laid.
|
||||
func TestAHeldValueForWhatIsNotHeldIsSaidNotLaid(t *testing.T) {
|
||||
f := heldGateFacts()
|
||||
got, notes := layHeld(f, []heldValue{
|
||||
{Pull: "p", Node: "nowhere", Module: "n8n", Key: "k", Value: []byte("1")},
|
||||
{Pull: "p", Node: "ace", Module: "ghost", Key: "k", Value: []byte("1")},
|
||||
}, heldShelf)
|
||||
if len(notes) != 2 || !strings.Contains(notes[0], "holds no machine") || !strings.Contains(notes[1], "defines no module ghost") {
|
||||
t.Fatalf("said %q", notes)
|
||||
}
|
||||
if _, has := got.Machines[0].Settings[0].Values["k"]; has {
|
||||
t.Fatal("laid")
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/AC3: none held lays nothing and says nothing; the file is read and refuses a value without a node.
|
||||
func TestNoHeldValuesLayNothingAndTheFileIsRead(t *testing.T) {
|
||||
if got, notes := layHeld(heldGateFacts(), nil, heldShelf); len(notes) != 0 || len(got.Machines) != 2 {
|
||||
t.Fatalf("%v %v", got, notes)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "good.json")
|
||||
os.WriteFile(good, []byte(`[{"pull":"p","node":"ace","module":"n8n","key":"k","value":1}]`), 0o644)
|
||||
if h, err := readHeld(good); err != nil || len(h) != 1 || h[0].Key != "k" {
|
||||
t.Fatalf("%v %v", h, err)
|
||||
}
|
||||
bad := filepath.Join(dir, "bad.json")
|
||||
os.WriteFile(bad, []byte(`[{"pull":"p","module":"n8n","key":"k","value":1}]`), 0o644)
|
||||
if _, err := readHeld(bad); err == nil {
|
||||
t.Fatal("a value without a node was read")
|
||||
}
|
||||
if h, err := readHeld(""); h != nil || err != nil {
|
||||
t.Fatalf("%v %v", h, err)
|
||||
}
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
func catalogueMesh(t *testing.T) (snapshot.Facts, map[string]string) {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
manifests := map[string]string{
|
||||
"objects": `{"module":"objects","version":"1",
|
||||
"provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}],
|
||||
"receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`,
|
||||
"resolver": `{"module":"resolver","version":"1",
|
||||
"provides":[{"name":"wildcard-resolution","scope":"mesh","identity":false}]}`,
|
||||
"networkmanager": `{"module":"networkmanager","version":"1","requires":["wildcard-resolution"]}`,
|
||||
"album": `{"module":"album","version":"1","requires":["s3-bucket"]}`,
|
||||
"lemurs": `{"module":"lemurs","version":"1","capabilities":["systemd"],
|
||||
"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`,
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "resolver"}, {"laptop", "networkmanager"},
|
||||
{"laptop", "album"}, {"laptop", "lemurs"}} {
|
||||
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
||||
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
||||
}
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f, manifests
|
||||
}
|
||||
|
||||
func gateJudged(t *testing.T, f snapshot.Facts, tree string, changed ...string) mergeVerdict {
|
||||
t.Helper()
|
||||
admin := os.Getenv("MESH_TEST_POSTGRES")
|
||||
in := mergeCheckInput{facts: f, admin: admin, changed: changed}
|
||||
if tree != "" {
|
||||
in.repository, in.tree = "novox/mesh-catalog", tree
|
||||
}
|
||||
v, err := judgeChange(t.Context(), in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestTheMeshAsItIsPassesTheGate(t *testing.T) {
|
||||
f, manifests := catalogueMesh(t)
|
||||
for _, m := range f.Machines {
|
||||
if !m.Declaration.Composes {
|
||||
t.Fatalf("the mesh itself does not compose: %+v", m.Declaration)
|
||||
}
|
||||
}
|
||||
v := gateJudged(t, f, aTree(t, manifests))
|
||||
if v.Verdict != "pass" {
|
||||
t.Fatalf("an unchanged catalogue does not pass:\n%s", v.Report())
|
||||
}
|
||||
for _, m := range v.Machines {
|
||||
if !m.Base.Composes || !m.Change.Composes {
|
||||
t.Errorf("%s does not compose in the gate's store as it does on the mesh: %+v / %+v", m.Described, m.Base, m.Change)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue263AnIdentityARealMachineNameOverflowsFailsThePullRequest(t *testing.T) {
|
||||
f, manifests := catalogueMesh(t)
|
||||
tree := aTree(t, withEdit(manifests, "networkmanager",
|
||||
`{"module":"networkmanager","version":"1","requires":["wildcard-resolution","s3-bucket"]}`))
|
||||
v := gateJudged(t, f, tree)
|
||||
if v.Verdict != "fail" {
|
||||
t.Fatalf("the overflow passed the gate:\n%s", v.Report())
|
||||
}
|
||||
report := v.Report()
|
||||
if !strings.Contains(report, "networkmanager") || !strings.Contains(report, "s3-bucket") {
|
||||
t.Errorf("the refusal does not name the module and the provision:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest(t *testing.T) {
|
||||
f, manifests := catalogueMesh(t)
|
||||
broken := `{"module":"lemurs","version":"1","capabilities":["systemd"],
|
||||
"resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`
|
||||
v := gateJudged(t, f, aTree(t, withEdit(manifests, "lemurs", broken)))
|
||||
if v.Verdict != "fail" || !strings.Contains(v.Report(), "lemurs") {
|
||||
t.Fatalf("a service the node-engine refuses passed the gate:\n%s", v.Report())
|
||||
}
|
||||
laptop := snapshot.Pseudonym("machine", "laptop")
|
||||
if !strings.Contains(v.Report(), laptop) {
|
||||
t.Errorf("the refusal does not name the machine it would be refused on:\n%s", v.Report())
|
||||
}
|
||||
|
||||
// And as a new module, which no machine runs: tried on one that could.
|
||||
newcomer := strings.ReplaceAll(broken, `"lemurs"`, `"greeter"`)
|
||||
newcomer = strings.ReplaceAll(newcomer, "lemurs.service", "greeter.service")
|
||||
v = gateJudged(t, f, aTree(t, withEdit(manifests, "greeter", newcomer)))
|
||||
if v.Verdict != "fail" || !strings.Contains(v.Report(), "greeter, assigned to") {
|
||||
t.Fatalf("a new module the node-engine would refuse passed the gate:\n%s", v.Report())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManifestTheRunningControllerCannotReadFailsThePullRequest(t *testing.T) {
|
||||
f, manifests := catalogueMesh(t)
|
||||
v := gateJudged(t, f, aTree(t, withEdit(manifests, "album",
|
||||
`{"module":"album","version":"1","requires":["s3-bucket"],"a-field-of-a-newer-controller":true}`)))
|
||||
if v.Verdict != "fail" || !strings.Contains(v.Report(), "refuses it") {
|
||||
t.Fatalf("a manifest this controller cannot read passed:\n%s", v.Report())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleAMachineRunsRemovedFromItsSourceFails(t *testing.T) {
|
||||
f, manifests := catalogueMesh(t)
|
||||
v := gateJudged(t, f, aTree(t, withEdit(manifests, "album", "")))
|
||||
if v.Verdict != "fail" || !strings.Contains(v.Report(), "album is removed") {
|
||||
t.Fatalf("removing a module a machine runs passed:\n%s", v.Report())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue278AWideRebuildIsSaidBeforeTheMerge(t *testing.T) {
|
||||
f, manifests := catalogueMesh(t)
|
||||
was := wideRebuild
|
||||
wideRebuild = 2
|
||||
t.Cleanup(func() { wideRebuild = was })
|
||||
for _, file := range []string{"modules/showcase/index.ts", "merge-check.sh", "README.md"} {
|
||||
v := gateJudged(t, f, aTree(t, manifests), file)
|
||||
if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 1 || v.Verdict != "pass" {
|
||||
t.Fatalf("%s, read by no build, reads %+v, %s", file, v.Width, v.Verdict)
|
||||
}
|
||||
if !strings.Contains(v.Report(), "read by no module's build") {
|
||||
t.Errorf("why %s rebuilds nothing is not said:\n%s", file, v.Report())
|
||||
}
|
||||
}
|
||||
v := gateJudged(t, f, aTree(t, manifests), "modules/album/x.ts", "modules/objects/x.go", "modules/resolver/x.go")
|
||||
if v.Width == nil || len(v.Width.Modules) < 3 || v.Verdict != "warning" {
|
||||
t.Fatalf("a rebuild wider than the bound is not warned of: %+v, %s", v.Width, v.Verdict)
|
||||
}
|
||||
// With the reference module's definition in the tree, its directory is a module, held or not.
|
||||
withShowcase := withEdit(manifests, "showcase", `{"module":"showcase","version":"1"}`)
|
||||
v = gateJudged(t, f, aTree(t, withShowcase), "modules/showcase/index.ts")
|
||||
if v.Width == nil || len(v.Width.Modules) != 0 || len(v.Width.Unread) != 0 {
|
||||
t.Fatalf("a file of a module nobody holds reads %+v", v.Width)
|
||||
}
|
||||
v = gateJudged(t, f, aTree(t, manifests), "modules/album/module.json")
|
||||
if v.Width == nil || strings.Join(v.Width.Modules, ",") != "album" || v.Verdict != "pass" {
|
||||
t.Fatalf("a change to one module's directory reads %+v, %s", v.Width, v.Verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeliveryGroupsHeadsAreComposedTogether(t *testing.T) {
|
||||
f, manifests := catalogueMeshWithAnApp(t)
|
||||
needsTheApp := withEdit(manifests, "album", `{"module":"album","version":"1","requires":["s3-bucket","app-api"]}`)
|
||||
catTree := aTree(t, needsTheApp)
|
||||
alone := gateJudged(t, f, catTree, "modules/album/module.json")
|
||||
if alone.Verdict != "fail" {
|
||||
t.Fatalf("a requirement nothing provides passed alone:\n%s", alone.Report())
|
||||
}
|
||||
app := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1",
|
||||
"provides":[{"name":"app-api","scope":"mesh","identity":false}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"), repository: "novox/mesh-catalog",
|
||||
tree: catTree, changed: []string{"modules/album/module.json"},
|
||||
group: []groupChange{{Repository: "novox/app", Tree: app, Changed: []string{"module.json"}}}}
|
||||
together, err := judgeChange(t.Context(), in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if together.Verdict == "fail" {
|
||||
t.Fatalf("the group composed together fails:\n%s", together.Report())
|
||||
}
|
||||
if together.Modules["app"] != "changed" || together.Modules["album"] != "changed" {
|
||||
t.Fatalf("the group's heads were not both laid over the mesh: %v", together.Modules)
|
||||
}
|
||||
}
|
||||
|
||||
func catalogueMeshWithAnApp(t *testing.T) (snapshot.Facts, map[string]string) {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
manifests := map[string]string{
|
||||
"objects": `{"module":"objects","version":"1",
|
||||
"provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}],
|
||||
"receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`,
|
||||
"album": `{"module":"album","version":"1","requires":["s3-bucket"]}`,
|
||||
}
|
||||
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 := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"},
|
||||
inventory.Source{Repository: "novox/app", BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "app"}, {"laptop", "album"}} {
|
||||
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
||||
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
||||
}
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f, manifests
|
||||
}
|
||||
|
||||
func busMesh(t *testing.T) snapshot.Facts {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
m, err := catalogue.ParseManifest([]byte(`{"module":"speaker","version":"1",
|
||||
"own-secrets":{"broker":"/var/lib/speaker/broker"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
|
||||
Path: "modules/speaker", BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "speaker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := broker.Principal{Kind: broker.KindModule, Node: "laptop", Module: "speaker"}.Username()
|
||||
password, err := open.inventory.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusModule,
|
||||
Node: "laptop", Module: "speaker"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.AcceptSecretForModule(ctx, "laptop", "speaker", "broker",
|
||||
`{"user":"`+user+`","password":"`+password+`"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mc := range f.Machines {
|
||||
if !mc.Declaration.Composes {
|
||||
t.Fatalf("the mesh itself does not compose: %+v", mc.Declaration)
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestIssue285AModuleOnTheBusComposesInTheGate(t *testing.T) {
|
||||
f := busMesh(t)
|
||||
v := gateJudged(t, f, "")
|
||||
if v.Verdict != "pass" {
|
||||
t.Fatalf("the mesh as it is does not pass:\n%s", v.Report())
|
||||
}
|
||||
for _, m := range v.Machines {
|
||||
if !m.Base.Composes {
|
||||
t.Errorf("%s composes on the mesh and not in the gate: %v", m.Described, m.Base.Problems)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(v.Summary, "2 of 2 compose") {
|
||||
t.Errorf("the summary does not say every machine composes: %s", v.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssue285AMachineTheGateCannotRaiseIsAnErrorNeverAPass(t *testing.T) {
|
||||
f := busMesh(t)
|
||||
for i := range f.Machines {
|
||||
// A fact the snapshot does not carry: the module's credential, gone from the laptop's.
|
||||
var kept []snapshot.Accepted
|
||||
for _, a := range f.Machines[i].Accepted {
|
||||
if a.Name != "broker" {
|
||||
kept = append(kept, a)
|
||||
}
|
||||
}
|
||||
f.Machines[i].Accepted = kept
|
||||
}
|
||||
v := gateJudged(t, f, "")
|
||||
if v.Verdict != "error" {
|
||||
t.Fatalf("a gate whose mesh does not compose said %s:\n%s", v.Verdict, v.Report())
|
||||
}
|
||||
if len(v.Errors) != 1 || !strings.Contains(v.Errors[0], "speaker") {
|
||||
t.Errorf("the error does not name what could not be raised: %v", v.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupsMoveOfAModuleIsNeitherTwoDefinitionsNorARemoval(t *testing.T) {
|
||||
const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller"
|
||||
built := func(repo, path string) inventory.Source {
|
||||
return inventory.Source{Repository: "http://forge.internal:20000/" + repo + ".git", Path: path, BuiltFrom: "c0ffee"}
|
||||
}
|
||||
sources := map[string]inventory.Source{"mover": built(catalogueRepo, "modules/mover")}
|
||||
raw := `{"module":"mover","version":"1"}`
|
||||
base := map[string]catalogue.Manifest{}
|
||||
m, err := catalogue.ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base["mover"] = m
|
||||
facts := snapshot.Facts{Machines: []snapshot.Machine{{Name: "anchor", Assigned: []string{"mover"}}}}
|
||||
|
||||
catalogueWithout := aTree(t, map[string]string{"other": `{"module":"other","version":"1"}`})
|
||||
controllerWith := func() string {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "modules", "mover"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "modules", "mover", "module.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}()
|
||||
removes := groupChange{Repository: catalogueRepo, Tree: catalogueWithout, Changed: []string{"modules/mover/module.json"}}
|
||||
defines := groupChange{Repository: controllerRepo, Tree: controllerWith, Changed: []string{"modules/mover/module.json"}}
|
||||
|
||||
lay := func(heads ...groupChange) (map[string]catalogue.Manifest, []string, map[string]string) {
|
||||
t.Helper()
|
||||
moves, err := movesInGroup(heads, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
at := sources
|
||||
if len(moves) > 0 {
|
||||
at = map[string]inventory.Source{}
|
||||
for k, v := range sources {
|
||||
at[k] = v
|
||||
}
|
||||
for name, mv := range moves {
|
||||
o := at[name]
|
||||
o.Repository, o.Path = mv.To, mv.Dir
|
||||
at[name] = o
|
||||
}
|
||||
}
|
||||
shelf, said := base, map[string]string{}
|
||||
var failures []string
|
||||
for _, h := range heads {
|
||||
var f []string
|
||||
if shelf, f, err = shelfWithChange(shelf, at, facts, h.Repository, h.Tree, said, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failures = append(failures, f...)
|
||||
}
|
||||
for name := range moves {
|
||||
said[name] = "moved"
|
||||
}
|
||||
return shelf, failures, said
|
||||
}
|
||||
for _, order := range [][]groupChange{{removes, defines}, {defines, removes}} {
|
||||
shelf, failures, said := lay(order...)
|
||||
if len(failures) > 0 {
|
||||
t.Fatalf("a move of a module machines run, laid %s first, was refused: %v", order[0].Repository, failures)
|
||||
}
|
||||
if said["mover"] != "moved" {
|
||||
t.Errorf("the move reads %v, not moved", said)
|
||||
}
|
||||
if _, held := shelf["mover"]; !held {
|
||||
t.Errorf("the moved module is not on the shelf after laying %s first", order[0].Repository)
|
||||
}
|
||||
}
|
||||
// Alone, a head is still refused: a second definition (issue 239), and a removal of what runs (ADR 0236).
|
||||
if _, failures, _ := lay(defines); len(failures) != 1 || !strings.Contains(failures[0], "issue 239") {
|
||||
t.Errorf("a definition of a module another repository builds, alone: %v", failures)
|
||||
}
|
||||
if _, failures, _ := lay(removes); len(failures) != 1 || !strings.Contains(failures[0], "ADR 0236") {
|
||||
t.Errorf("the removal of a module machines run, alone: %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeliveryGroupMovingAModuleMachinesRunPassesTheGate(t *testing.T) {
|
||||
f, manifests := catalogueMeshWithAnApp(t)
|
||||
catTree := aTree(t, withEdit(manifests, "album", ""))
|
||||
app := t.TempDir()
|
||||
// The app module is built from this repository's root, and stays there: only album moves.
|
||||
if err := os.WriteFile(filepath.Join(app, "module.json"), []byte(`{"module":"app","version":"1"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(app, "modules", "album"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(app, "modules", "album", "module.json"), []byte(manifests["album"]), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removes := groupChange{Repository: "novox/mesh-catalog", Tree: catTree, Changed: []string{"modules/album/module.json"}}
|
||||
defines := groupChange{Repository: "novox/app", Tree: app, Changed: []string{"modules/album/module.json"}}
|
||||
judge := func(first groupChange, rest ...groupChange) mergeVerdict {
|
||||
v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"),
|
||||
repository: first.Repository, tree: first.Tree, changed: first.Changed, group: rest})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
for _, v := range []mergeVerdict{judge(removes, defines), judge(defines, removes)} {
|
||||
if v.Verdict == "fail" || v.Modules["album"] != "moved" {
|
||||
t.Fatalf("a group moving a module machines run: %v\n%s", v.Modules, v.Report())
|
||||
}
|
||||
}
|
||||
if v := judge(defines); v.Verdict != "fail" {
|
||||
t.Errorf("a definition of a module another repository builds passed alone:\n%s", v.Report())
|
||||
}
|
||||
if v := judge(removes); v.Verdict != "fail" {
|
||||
t.Errorf("the removal of a module machines run passed alone:\n%s", v.Report())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGateRefusesAnArtifactContextNamingAnotherRepository(t *testing.T) {
|
||||
withContext := func(repo string) string {
|
||||
return `{"module":"proxy","version":"1","build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile",` +
|
||||
`"context":{"seat":"git","repository":"` + repo + `","ref":"main"}}]}}`
|
||||
}
|
||||
judge := func(repo string) []string {
|
||||
t.Helper()
|
||||
tree := aTree(t, map[string]string{"proxy": withContext(repo)})
|
||||
_, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, map[string]inventory.Source{},
|
||||
snapshot.Facts{}, "novox/mesh-controller", tree, map[string]string{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
const want = "proxy builds artifact server from novox/mesh-catalog: a module lives whole in one repository (novox/hq ADR 0420 §1)"
|
||||
failures := judge("novox/mesh-catalog")
|
||||
if len(failures) != 1 || !strings.Contains(failures[0], want) {
|
||||
t.Errorf("a context naming another repository: %v, want it to say %q", failures, want)
|
||||
}
|
||||
for _, f := range judge("novox/mesh-controller") {
|
||||
if strings.Contains(f, "ADR 0420 §1") {
|
||||
t.Errorf("a context naming the module's own repository was refused: %s", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMemberJudgedAloneSeesAMoveThroughTheClonesBeside(t *testing.T) {
|
||||
const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller"
|
||||
raw := `{"module":"mover","version":"1"}`
|
||||
sources := map[string]inventory.Source{"mover": {Repository: "http://forge.internal:20000/" + catalogueRepo + ".git",
|
||||
Path: "modules/mover", BuiltFrom: "c0ffee"}}
|
||||
// A tree is written into dir: defining mover, or holding only another module.
|
||||
write := func(dir string, defines bool) string {
|
||||
name, body := "other", `{"module":"other","version":"1"}`
|
||||
if defines {
|
||||
name, body = "mover", raw
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "modules", name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "modules", name, "module.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
defining := func() string { return write(t.TempDir(), true) }
|
||||
without := func() string { return write(t.TempDir(), false) }
|
||||
// beside lays out a MESH_CHECK_BESIDE directory holding the named repositories' trees, each defining mover or not.
|
||||
beside := func(trees map[string]bool) string {
|
||||
root := t.TempDir()
|
||||
for name, defines := range trees {
|
||||
write(filepath.Join(root, name), defines)
|
||||
}
|
||||
return root
|
||||
}
|
||||
moves := func(repository, tree, besideRoot string) map[string]moveOfModule {
|
||||
t.Helper()
|
||||
got, err := movesOfChange(mergeCheckInput{repository: repository, tree: tree, beside: besideRoot}, sources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
// The controller's member: defines mover; the catalogue beside has removed it.
|
||||
if got := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": false})); got["mover"].To != controllerRepo {
|
||||
t.Errorf("the controller's change defining mover, the catalogue beside without it: moves %v", got)
|
||||
}
|
||||
// The sources the gate then judges with: built from the controller, the definition is not a second one (issue 239);
|
||||
// unmoved, it still is.
|
||||
judge := func(at map[string]inventory.Source) []string {
|
||||
t.Helper()
|
||||
_, failures, err := shelfWithChange(map[string]catalogue.Manifest{}, at, snapshot.Facts{}, controllerRepo, defining(),
|
||||
map[string]string{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
mv := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": false}))["mover"]
|
||||
moved := map[string]inventory.Source{"mover": {Repository: mv.To, Path: mv.Dir, BuiltFrom: "c0ffee"}}
|
||||
if f := judge(moved); len(f) != 0 {
|
||||
t.Errorf("a definition of a module the catalogue beside removed was refused: %v", f)
|
||||
}
|
||||
if f := judge(sources); len(f) != 1 || !strings.Contains(f[0], "issue 239") {
|
||||
t.Errorf("with the catalogue beside at main, the definition: %v, want the issue 239 refusal", f)
|
||||
}
|
||||
if got := moves(controllerRepo, defining(), beside(map[string]bool{"mesh-catalog": true})); len(got) != 0 {
|
||||
t.Errorf("the catalogue beside at main still defines mover, yet moves %v", got)
|
||||
}
|
||||
// The catalogue's member: removes mover; the controller beside defines it.
|
||||
if got := moves(catalogueRepo, without(), beside(map[string]bool{"mesh-controller": true})); got["mover"].To != controllerRepo {
|
||||
t.Errorf("the catalogue's change removing mover, the controller beside defining it: moves %v", got)
|
||||
}
|
||||
if got := moves(catalogueRepo, without(), beside(map[string]bool{"mesh-controller": false})); len(got) != 0 {
|
||||
t.Errorf("the controller beside at main does not define mover, yet moves %v", got)
|
||||
}
|
||||
if got := moves(catalogueRepo, without(), ""); len(got) != 0 {
|
||||
t.Errorf("nothing beside, yet moves %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1826/AC3 (integration level, a throwaway store): a change whose container says ${config:media-gid} fails the gate for
|
||||
// want of the value, passes it with the value the operator approved held for its pull request, and the mesh as it is
|
||||
// composes the same either way.
|
||||
func TestAChangeNeedingAHeldValueFailsWithoutItAndPassesWithIt(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
trunk := `{"module":"n8n","version":"1","resources":[{"id":"server","type":"container","name":"n8n",
|
||||
"image":"n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0"}]}`
|
||||
m, err := catalogue.ParseManifest([]byte(trunk))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-catalog",
|
||||
Path: "modules/n8n", BuiltFrom: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "n8n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := gatherFacts(ctx, open, "2.11.17")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range f.Machines {
|
||||
f.Machines[i].Capabilities = append(f.Machines[i].Capabilities, snapshot.Capability{Name: "container-groups", Present: true})
|
||||
}
|
||||
tree := aTree(t, map[string]string{"n8n": n8nWithAGroup})
|
||||
judge := func(held []heldValue) mergeVerdict {
|
||||
v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"),
|
||||
repository: "novox/mesh-catalog", tree: tree, changed: []string{"modules/n8n/module.json"}, held: held})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
without := judge(nil)
|
||||
if without.Verdict != "fail" || !strings.Contains(without.Report(), "media-gid") {
|
||||
t.Fatalf("a required value nothing sets passed, or failed for another reason:\n%s", without.Report())
|
||||
}
|
||||
with := judge([]heldValue{{Pull: "novox/mesh-catalog#434", Node: "laptop", Module: "n8n", Key: "media-gid",
|
||||
Value: []byte("2000"), Approved: "the operator"}})
|
||||
if with.Verdict == "fail" || with.Verdict == "error" {
|
||||
t.Fatalf("the held value did not make it compose:\n%s", with.Report())
|
||||
}
|
||||
if !strings.Contains(with.Report(), "composed with 1 value(s) held for novox/mesh-catalog#434") {
|
||||
t.Fatalf("the value laid is not said:\n%s", with.Report())
|
||||
}
|
||||
for i := range with.Machines {
|
||||
if with.Machines[i].Base.Composes != without.Machines[i].Base.Composes {
|
||||
t.Fatalf("the mesh as it is composes differently with a held value: %+v / %+v", with.Machines[i].Base, without.Machines[i].Base)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The merge gate (novox/hq to-be 45 §9): a change judged against every machine of the snapshot, by the
|
||||
// incidents it is written from. Each case raises a mesh, takes its snapshot, and judges a pull request's
|
||||
// tree against it in throwaway stores of its own.
|
||||
|
||||
// catalogueMesh is two machines and a catalogue repository: a resolver and an object store on the
|
||||
// anchor, and on the laptop a network manager requiring the resolver, an album requiring the object
|
||||
// store, and a login manager. Every module is registered from novox/mesh-catalog, as the mesh's are.
|
||||
|
||||
// aTree is a checkout of the catalogue repository holding these manifests.
|
||||
func aTree(t *testing.T, manifests map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, raw := range manifests {
|
||||
at := filepath.Join(dir, "modules", name)
|
||||
if err := os.MkdirAll(at, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(at, "module.json"), []byte(raw), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func withEdit(manifests map[string]string, name, raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range manifests {
|
||||
out[k] = v
|
||||
}
|
||||
if raw == "" {
|
||||
delete(out, name)
|
||||
} else {
|
||||
out[name] = raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The mesh as it is passes: every machine composes in the gate's store as the controller composed it.
|
||||
|
||||
// **Issue 263**: a change makes the network manager require the object store's provision; on a machine
|
||||
// whose name is six characters its identity is 26 against a bound of 20. Refused in the pull request,
|
||||
// naming the module, and not on the anchor after it merged.
|
||||
|
||||
// **Issue 236**: a login manager's service that omits its state passed the catalogue check and was
|
||||
// refused whole by the node-engine on the first machine. A change to a module a machine runs, and a
|
||||
// module nobody runs yet, are both refused before merge, naming the machine and the module.
|
||||
|
||||
// **Version skew**: a manifest the controller judging it cannot read — the one the mesh runs, for a
|
||||
// catalogue change — fails here, not at registration after the merge.
|
||||
|
||||
// A module a machine runs, removed from its source, fails until it is unassigned (ADR 0236).
|
||||
|
||||
// **Issues 278 and 280**: a file in no held module's directory read as shared code, and a merge rebuilt the
|
||||
// catalogue. A file is a module's only by being inside it — no build reads one outside — so it rebuilds
|
||||
// nothing, and the gate says why; a merge that does rebuild widely is warned of.
|
||||
|
||||
// **A delivery group is judged as one future state** (novox/hq ADR 0239): a catalogue change that needs a
|
||||
// provision only another repository's change adds fails alone and passes composed with it; and a group
|
||||
// whose other head breaks what the first needs fails, naming it.
|
||||
|
||||
// catalogueMeshWithAnApp is catalogueMesh with an application built from a repository of its own, at its
|
||||
// root, on the anchor.
|
||||
|
||||
// busMesh is aMesh with a module on the bus on the laptop, its account issued as `module issue` issues
|
||||
// one: minted, and its credential held as the module's own secret named broker.
|
||||
|
||||
// **Issue 285**: every machine running a module on the bus failed to compose in the gate's store, with
|
||||
// the change and without — the store held the module's credential and no account for it, which
|
||||
// composition refuses (issue 203) — and the gate passed every change, "0 of 4 compose". The account the
|
||||
// mesh issued is raised with its credential, so the machine composes in the gate as on the mesh.
|
||||
|
||||
// **Issue 285**: a machine the mesh composes that the gate cannot raise as it is leaves the change judged
|
||||
// against a machine that is not the mesh's — broken against broken, which passes whatever the change does.
|
||||
// That is an error, never a pass.
|
||||
|
||||
// A path the snapshot withheld is given a path of its own where an access or a place needs one: a bare
|
||||
// "withheld" is no absolute path, and a machine whose setting composes on the mesh would not in the gate.
|
||||
func TestAWithheldPathIsStoodInForByAPath(t *testing.T) {
|
||||
got := standInPaths(map[string]any{
|
||||
"accesses": map[string]any{"races": "withheld", "films": "/media/films", "shows": "/withheld"},
|
||||
"places": map[string]any{"config": map[string]any{"path": "withheld", "owner": "1000:1000"}},
|
||||
"puid": 1000,
|
||||
})
|
||||
accesses := got["accesses"].(map[string]any)
|
||||
if accesses["races"] == accesses["shows"] || !strings.HasPrefix(accesses["races"].(string), "/") ||
|
||||
!strings.HasPrefix(accesses["shows"].(string), "/") || accesses["films"] != "/media/films" {
|
||||
t.Errorf("accesses: %v", accesses)
|
||||
}
|
||||
place := got["places"].(map[string]any)["config"].(map[string]any)
|
||||
if !strings.HasPrefix(place["path"].(string), "/") || place["owner"] != "1000:1000" || got["puid"] != 1000 {
|
||||
t.Errorf("places: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Issue 286**: a check run by hand clones beside a change what the seat clones — one rule, read by the
|
||||
// controller's ask and by the facts — and finds the repository it checks from its origin.
|
||||
func TestACheckByHandClonesWhatTheSeatClones(t *testing.T) {
|
||||
for dir, refs := range map[string]map[string]string{
|
||||
"mesh-catalog": {"mesh-catalog": "main"},
|
||||
"mesh-host": {"mesh-host": "c0ffee"},
|
||||
"mesh-controller": {"mesh-controller": "c0ffee", "mesh-controller-main": "main", "mesh-lab": "main",
|
||||
"mesh-sdk": sdkPinnedByGoMod(t)},
|
||||
} {
|
||||
got := besideRefs(dir, "c0ffee")
|
||||
for d, ref := range refs {
|
||||
if got[d] != ref {
|
||||
t.Errorf("beside %s, %s is cloned at %q, not %q", dir, d, got[d], ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for owner, want := range map[string][3]string{
|
||||
"ssh://git@git.example:222/novox/mesh-host.git": {"novox", "mesh-host", "ssh://git@git.example:222/novox"},
|
||||
"git@git.example:novox/mesh-tools.git": {"novox", "mesh-tools", "git@git.example:novox"},
|
||||
"http://git.example/novox/hq": {"novox", "hq", "http://git.example/novox"},
|
||||
} {
|
||||
o, r, p := ownerRepoOf(owner)
|
||||
if [3]string{o, r, p} != want {
|
||||
t.Errorf("%s reads as %s %s %s", owner, o, r, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sdkPinnedByGoMod is the SDK commit this tree's go.mod pins, read from the file rather than the build, so
|
||||
// sdkPinned is held to what the repository says (novox/hq issue 449).
|
||||
func sdkPinnedByGoMod(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "go.mod"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == sdkModule {
|
||||
if m := pseudoCommit.FindStringSubmatch(fields[1]); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return "go/" + fields[1]
|
||||
}
|
||||
}
|
||||
t.Fatalf("go.mod requires no %s", sdkModule)
|
||||
return ""
|
||||
}
|
||||
|
||||
// **A module one head of a group removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025):
|
||||
// neither a second definition of one module name (issue 239) nor the removal of a module machines run
|
||||
// (ADR 0236). Alone, each head is still refused. This lays the heads over the shelf as judgeChange does,
|
||||
// which needs no database.
|
||||
|
||||
// A group that moves a module machines run passes the gate whole, in either order of its heads, and says the
|
||||
// module moved (novox/hq ADR 0420 §6, issue 1025); a head alone is still refused. Needs the test store.
|
||||
|
||||
// **A module lives whole in one repository** (novox/hq ADR 0420 §1, issue 1019): an artifact whose build
|
||||
// context names another repository is refused by the gate, and one naming its own passes it.
|
||||
|
||||
// **A member of a delivery group judged alone sees a move** through the core clones beside its check
|
||||
// (novox/hq ADR 0420 §6, issue 1043): the controller's own gate, with the catalogue beside having removed the
|
||||
// module, is not refused for a second definition (issue 239); the catalogue's own gate, with the controller
|
||||
// beside defining it, is not refused for a removal (ADR 0236). A clone beside at main finds no move.
|
||||
@@ -116,7 +116,7 @@ func TestANewModuleIsRegisteredOnTheOperatorsApproveOfItsMergeAndRefusedOtherwis
|
||||
if c.built != nil {
|
||||
src = *c.built
|
||||
}
|
||||
id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, byBuild("build-1"), "", "main")
|
||||
id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-view", src, registrationAsk{Build: "build-1"}, "", "main")
|
||||
switch {
|
||||
case c.accepted && err != nil:
|
||||
t.Fatalf("%s: the Approved merge's new module was refused: %v", c.ac, err)
|
||||
@@ -157,7 +157,7 @@ func TestASecondNewModuleFromARepositoryIsJudgedAlongsideAndAsksNoWarrant(t *tes
|
||||
request: inventory.BuildRequest{ID: "build-2", Repository: "novox/mesh-delivery", Seat: "git", For: "build"}}}
|
||||
forms := &sourceForms{bases: map[string]string{}, base: func(string) string { return "http://forge.internal:20000" }}
|
||||
built := inventory.Source{Repository: "novox/mesh-delivery", Seat: "git"}
|
||||
if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, byBuild("build-2"), "", "main"); err != nil || id == 0 {
|
||||
if id, err := mayRegisterIn(t.Context(), mesh, forms, "mesh-delivery-watchman", built, registrationAsk{Build: "build-2"}, "", "main"); err != nil || id == 0 {
|
||||
t.Fatalf("1146/AC5: a second module from a repository the catalogue builds from was refused (id %d): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
@@ -210,10 +209,7 @@ func watchNewModules(ctx context.Context, open *stores) {
|
||||
fmt.Printf("new modules: the builder's keys are not watched: %v\n", err)
|
||||
return
|
||||
}
|
||||
says := watchingSays{js: keys.JS}
|
||||
says.watch(ctx)
|
||||
if err := builderkeys.Watch(ctx, keys.JS, func(module string) {
|
||||
says.watch(ctx)
|
||||
wakeNewModule(ctx, open, module)
|
||||
}); err != nil {
|
||||
closeKeys()
|
||||
@@ -249,8 +245,9 @@ func wakeNewModule(ctx context.Context, open *stores, module string) {
|
||||
// ends only once the work its mesh started has.
|
||||
var background sync.WaitGroup
|
||||
|
||||
// inBackground runs work beside the caller, counted in background.
|
||||
func inBackground(work func()) {
|
||||
// inBackground runs work beside the caller, counted in background. A variable so a test of what a walk does around a
|
||||
// take-in may run it in the caller's turn and need not wait for it.
|
||||
var inBackground = func(work func()) {
|
||||
background.Add(1)
|
||||
go func() {
|
||||
defer background.Done()
|
||||
@@ -278,32 +275,3 @@ func newModulesOf(ctx context.Context, repository string) ([]string, error) {
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// watchingSays watches the builder's bucket of instances once it is there (issue 1886): a change of what the builder says
|
||||
// asks the look, which judges every open walk again. A builder from before R4e makes no such bucket; a write of the
|
||||
// builder's keys asks again whether it is there now, so nothing waits on a timer.
|
||||
type watchingSays struct {
|
||||
js jetstream.JetStream
|
||||
mu *sync.Mutex
|
||||
on *bool
|
||||
}
|
||||
|
||||
func (w *watchingSays) watch(ctx context.Context) {
|
||||
if w.mu == nil {
|
||||
w.mu, w.on = &sync.Mutex{}, new(bool)
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if *w.on {
|
||||
return
|
||||
}
|
||||
on, err := builderkeys.WatchSays(ctx, w.js, askToLook)
|
||||
if err != nil {
|
||||
fmt.Printf("plans: what the builder says it writes is not watched now: %v\n", err)
|
||||
return
|
||||
}
|
||||
if on {
|
||||
*w.on = true
|
||||
askToLook() // what it says now may differ from what the last look read
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// heldElsewhere is the plans held by another act for longer than any test runs: waiting for them ends only with the
|
||||
// context, and a look that does not wait is told at once that they are busy.
|
||||
func heldElsewhere(ctx context.Context, wait bool) (func(), error) {
|
||||
if !wait {
|
||||
return nil, inventory.ErrPlansBusy
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil, inventory.ErrPlansBusy
|
||||
}
|
||||
|
||||
// 1708/AC1: given the plans held by another act, when the event loop takes a build's outcome, then it returns at
|
||||
// once, without waiting the plans' two minutes (on 2026-10-21 at 14:20:45 nodered's outcome held the loop until
|
||||
// 14:22:45, and the operator was told the controller stopped listening).
|
||||
func TestAnOutcomeDoesNotWaitForThePlansAnotherActHolds(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
release, err := holdPlansForOutcome(ctx, heldElsewhere)
|
||||
if took := time.Since(start); took > time.Second {
|
||||
t.Fatalf("taking an outcome waited %s for plans another act holds; the event loop takes nothing meanwhile", took)
|
||||
}
|
||||
if err == nil || release != nil {
|
||||
t.Fatalf("plans another act holds were said to be held: release %v, err %v", release != nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1708/AC4: given the plans free, when the event loop takes a build's outcome, then it holds them and marks the plan
|
||||
// from the outcome directly, as before.
|
||||
func TestAnOutcomeHoldsThePlansWhenTheyAreFree(t *testing.T) {
|
||||
let := false
|
||||
release, err := holdPlansForOutcome(t.Context(), func(context.Context, bool) (func(), error) {
|
||||
return func() { let = true }, nil
|
||||
})
|
||||
if err != nil || release == nil {
|
||||
t.Fatalf("free plans were not held: %v", err)
|
||||
}
|
||||
release()
|
||||
if !let {
|
||||
t.Fatal("the hold given was not the one taken")
|
||||
}
|
||||
}
|
||||
|
||||
// withLooker installs a looker that is never run, and answers it, so a test can read whether a look was asked.
|
||||
func withLooker(t *testing.T) *looker {
|
||||
t.Helper()
|
||||
l := newLooker(func(context.Context) bool { return false })
|
||||
lookerMu.Lock()
|
||||
before := theLooker
|
||||
theLooker = l
|
||||
lookerMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
lookerMu.Lock()
|
||||
theLooker = before
|
||||
lookerMu.Unlock()
|
||||
})
|
||||
return l
|
||||
}
|
||||
|
||||
// 1708/AC2: given an outcome left to the build records because the plans were held, when it is left, then the look
|
||||
// at the plans is asked for, so the records settle it once the holder lets go rather than at the next event.
|
||||
func TestAnOutcomeLeftToTheRecordsAsksForTheLook(t *testing.T) {
|
||||
l := withLooker(t)
|
||||
leaveToRecords("nodered", "build-1708-ac2", "", "", inventory.ErrPlansBusy)
|
||||
select {
|
||||
case <-l.signal:
|
||||
default:
|
||||
t.Fatal("an outcome left to the build records asked for no look: the plan waits for whatever event comes next")
|
||||
}
|
||||
}
|
||||
|
||||
// 1708/AC3: given an outcome left to the records that said what the build did (ADR 0350 §4), when the look settles
|
||||
// the plan from the build records by that build's id, then the module keeps those words, once.
|
||||
func TestTheRecordsSettleAnOutcomeWithWhatTheBuildDid(t *testing.T) {
|
||||
withLooker(t)
|
||||
asked := time.Date(2026, 10, 21, 12, 15, 0, 0, time.UTC)
|
||||
plan := func() inventory.Plan {
|
||||
return inventory.Plan{ID: "plan-1708", Tiers: [][]string{{"nodered"}},
|
||||
Modules: map[string]*inventory.PlanModule{"nodered": {State: "asked", AskedAt: &asked, Build: "build-1708-ac3"}}}
|
||||
}
|
||||
byID := map[string]inventory.Build{"build-1708-ac3": {ID: "build-1708-ac3", Module: "nodered", Commit: "4efac366",
|
||||
At: asked.Add(5 * time.Minute)}}
|
||||
leaveToRecords("nodered", "build-1708-ac3", "reused from build-1708-check", "", inventory.ErrPlansBusy)
|
||||
|
||||
p := plan()
|
||||
if !settleFromRecords(&p, p.Tiers[0], nil, byID) || p.Modules["nodered"].State != "built" {
|
||||
t.Fatalf("the records did not settle the outcome: %+v", p.Modules["nodered"])
|
||||
}
|
||||
if got := p.Modules["nodered"].Made; got != "reused from build-1708-check" {
|
||||
t.Fatalf("settled from the records, the module says %q of what its build did, not the outcome's words", got)
|
||||
}
|
||||
again := plan()
|
||||
settleFromRecords(&again, again.Tiers[0], nil, byID)
|
||||
if got := again.Modules["nodered"].Made; got != "" {
|
||||
t.Fatalf("an outcome's words were kept for a second settle: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1708/AC5: given a serving controller (its look at the plans running), when an outcome was taken into the plans,
|
||||
// then moving them (a release's sends, which may retry for minutes) is left to the look, off the event loop
|
||||
// (2026-10-21 14:28:40 to 14:32:10: the loop sent okioki's release to g14 after mesh-delivery-builder's outcome).
|
||||
func TestAnOutcomeLeavesMovingThePlansToTheLook(t *testing.T) {
|
||||
l := withLooker(t)
|
||||
moved := false
|
||||
advanceAfterOutcome(func() { moved = true })
|
||||
if moved {
|
||||
t.Fatal("the plans were moved on the event loop after an outcome, where a send that retries holds every message")
|
||||
}
|
||||
select {
|
||||
case <-l.signal:
|
||||
default:
|
||||
t.Fatal("the look was not asked to move the plans after an outcome")
|
||||
}
|
||||
}
|
||||
|
||||
// 1708/AC5: given no look running (a command, which has no event loop), when an outcome was taken, then the plans are
|
||||
// moved at once, as before.
|
||||
func TestAnOutcomeMovesThePlansWhereNoLookRuns(t *testing.T) {
|
||||
lookerMu.Lock()
|
||||
before := theLooker
|
||||
theLooker = nil
|
||||
lookerMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
lookerMu.Lock()
|
||||
theLooker = before
|
||||
lookerMu.Unlock()
|
||||
})
|
||||
moved := false
|
||||
advanceAfterOutcome(func() { moved = true })
|
||||
if !moved {
|
||||
t.Fatal("with no look running, an outcome left the plans unmoved")
|
||||
}
|
||||
}
|
||||
|
||||
// 1708/AC6: given a registration the mesh refused (its build recorded as worked, the outcome failed), left to the
|
||||
// records because the plans were held, when the look settles the plan from the records, then the module and the plan
|
||||
// are failed, never built: settled as built, the tier would be sent the version from before (reviewer-1708@shanks).
|
||||
func TestARefusedRegistrationLeftToTheRecordsFailsThePlan(t *testing.T) {
|
||||
withLooker(t)
|
||||
asked := time.Date(2026, 10, 21, 12, 15, 0, 0, time.UTC)
|
||||
p := inventory.Plan{ID: "plan-1708-ac6", State: inventory.PlanBuilding, Tiers: [][]string{{"nodered"}},
|
||||
Modules: map[string]*inventory.PlanModule{"nodered": {State: "asked", AskedAt: &asked, Build: "build-1708-ac6"}}}
|
||||
byID := map[string]inventory.Build{"build-1708-ac6": {ID: "build-1708-ac6", Module: "nodered", Commit: "4efac366",
|
||||
At: asked.Add(5 * time.Minute)}}
|
||||
leaveToRecords("nodered", "build-1708-ac6", "", "the mesh does not register it: a port is taken", inventory.ErrPlansBusy)
|
||||
|
||||
overlayRefusals(nil, byID, refusalsKeptFor([]string{"build-1708-ac6"}))
|
||||
settleFromRecords(&p, p.Tiers[0], nil, byID)
|
||||
if s := p.Modules["nodered"]; s.State != "failed" || p.State != inventory.PlanFailed {
|
||||
t.Fatalf("a refused registration left to the records settled as %q, the plan %q", s.State, p.State)
|
||||
}
|
||||
if !strings.Contains(p.Modules["nodered"].Why, "a port is taken") {
|
||||
t.Fatalf("the refusal's words were lost: %q", p.Modules["nodered"].Why)
|
||||
}
|
||||
}
|
||||
|
||||
// 1708/AC6: a refusal kept in the store (a controller replaced before the settle) fails the record as one kept in
|
||||
// the process does.
|
||||
func TestARefusalFromTheStoreFailsTheRecord(t *testing.T) {
|
||||
recorded := map[string][]inventory.Build{"nodered": {{ID: "b-store", Module: "nodered"}, {ID: "b-other", Module: "nodered"}}}
|
||||
byID := map[string]inventory.Build{"b-store": {ID: "b-store", Module: "nodered"}}
|
||||
overlayRefusals(recorded, byID, map[string]string{"b-store": "not registered"})
|
||||
if recorded["nodered"][0].Worked() || byID["b-store"].Worked() {
|
||||
t.Fatal("a build whose registration was refused still reads as worked")
|
||||
}
|
||||
if !recorded["nodered"][1].Worked() {
|
||||
t.Fatal("a build nobody refused was failed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1708/AC7: given the records settle a module as deleted at its source, or fail the plan, when the look settles, then
|
||||
// it does what taking the outcome directly does: the deleted module is named to be forgotten, and the plan's failure
|
||||
// is said (reviewer-1708@shanks).
|
||||
func TestSettlingFromTheRecordsDoesWhatTakingTheOutcomeDoes(t *testing.T) {
|
||||
p := inventory.Plan{State: inventory.PlanFailed, Modules: map[string]*inventory.PlanModule{
|
||||
"gone": {State: planDeleted}, "x": {State: "failed"}, "y": {State: "built"}}}
|
||||
before := map[string]string{"gone": "asked", "x": "asked", "y": "built"}
|
||||
deleted, failed := settledEffects(before, &p, inventory.PlanBuilding)
|
||||
if len(deleted) != 1 || deleted[0] != "gone" {
|
||||
t.Fatalf("the module the records found deleted is not forgotten: %v", deleted)
|
||||
}
|
||||
if !failed {
|
||||
t.Fatal("the plan the records failed is not said as failed")
|
||||
}
|
||||
if d, f := settledEffects(map[string]string{"gone": planDeleted}, &p, inventory.PlanFailed); len(d) != 0 || f {
|
||||
t.Fatalf("what was deleted or failed before the settle is acted on again: %v %v", d, f)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A module not registered yet, and the assignment that waits for it (novox/hq issue 325, ADR 0261).
|
||||
@@ -74,15 +72,6 @@ type assignOptions struct {
|
||||
Build bool
|
||||
}
|
||||
|
||||
// recordBuildRequest keeps a build request so `assign` can find it, once it is asked. Said, never fatal: the
|
||||
// build was asked.
|
||||
func recordBuildRequest(ctx context.Context, inv *inventory.Inventory, r inventory.BuildRequest) {
|
||||
if err := inv.RecordBuildRequest(ctx, r); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build %s was asked, and could not be kept as a build request, so `assign` "+
|
||||
"will not know it is coming: %v\n", r.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The cases of a module the catalogue does not hold (novox/hq issue 325).
|
||||
const (
|
||||
unknownModule = iota
|
||||
@@ -474,40 +463,6 @@ func expirePending(ctx context.Context, inv *inventory.Inventory, p inventory.Pe
|
||||
return note
|
||||
}
|
||||
|
||||
// settlePendingOnBuild is a build's outcome reaching the assignments waiting for it, wherever the outcome is
|
||||
// taken in (novox/hq issue 325): a module registered makes every assignment pending on it; a build of a
|
||||
// pending assignment that failed, or was not registered, ends it with why. Returns what was said.
|
||||
func settlePendingOnBuild(ctx context.Context, open *stores, result link.BuildResult, module string, takeErr error) []string {
|
||||
inv := open.inventory
|
||||
waiting, err := inv.Pending(ctx, time.Time{})
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("the pending assignments could not be read after build %s: %v", result.ID, err)}
|
||||
}
|
||||
registered := module != "" && (takeErr == nil || errors.Is(takeErr, inventory.ErrSuperseded))
|
||||
var said []string
|
||||
for _, p := range waiting {
|
||||
switch {
|
||||
case registered && p.Module == module:
|
||||
if line := applyPending(ctx, open, p, result.ID); line != "" {
|
||||
said = append(said, line)
|
||||
}
|
||||
case !registered && p.Build == result.ID:
|
||||
why := firstLine(result.Failed)
|
||||
if why == "" && takeErr != nil {
|
||||
why = firstLine(takeErr.Error())
|
||||
}
|
||||
what := fmt.Sprintf("build %s of %s failed: %s", result.ID, orRoot(p.Path), why)
|
||||
if result.Failed == "" {
|
||||
what = fmt.Sprintf("build %s of %s was recorded and not registered: %s", result.ID, orRoot(p.Path), why)
|
||||
}
|
||||
if line := expirePending(ctx, inv, p, what); line != "" {
|
||||
said = append(said, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// settlingPending is the controller's tick over the pending assignments (ADR 0261), every settleEvery until
|
||||
// the context ends. What it does is printed to the controller's log, kept in each row's note (which `status`
|
||||
// lists for a day), and raised as a condition for an assignment that was not made.
|
||||
|
||||
@@ -5,7 +5,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -567,34 +566,6 @@ func TestAStaleClaimIsSettledFromWhatTheMeshHolds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildNoLongerWaitedForIsStillInFlight(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
inv := open.inventory
|
||||
for _, id := range []string{"build-timeout", "build-nothandedover"} {
|
||||
dir := "modules/" + strings.TrimPrefix(id, "build-")
|
||||
if err := inv.RecordBuildRequest(ctx, inventory.BuildRequest{ID: id, Repository: "novox/mesh-catalog",
|
||||
Seat: "git", Path: dir, Ref: "main", For: "build"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
markWaitFailed(ctx, "build-timeout", errors.New("no build machine answered within 10m0s"))
|
||||
markWaitFailed(ctx, "build-nothandedover", fmt.Errorf("%w: cannot submit a build: nats: timeout", link.ErrNotHandedOver))
|
||||
|
||||
said, err := assign(ctx, open, "laptop", "timeout")
|
||||
if err != nil || !strings.Contains(said, "being built") || !strings.Contains(said, "kept as pending") {
|
||||
t.Fatalf("a build no longer waited for: %q %v", said, err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "nothandedover"); err == nil || !strings.Contains(err.Error(), "was not handed over") {
|
||||
t.Fatalf("a build never handed over: %v", err)
|
||||
}
|
||||
settlePending(ctx, open, time.Now().Add(buildRequestBound+time.Minute))
|
||||
rows, _ := inv.PendingFor(ctx, "laptop", "timeout")
|
||||
if len(rows) != 1 || rows[0].State != inventory.PendingExpired || !strings.Contains(rows[0].Note, "its asker stopped waiting") {
|
||||
t.Fatalf("past the bound: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A10 (ADR 0501 §6; issue 1851): an assignment of a module the indexer holds and the catalogue does not yet waits for
|
||||
// the builder's done key of it, asking nothing, and is made when that key registers the module.
|
||||
func TestAnAssignmentWaitsForTheKeyAndAsksNothing(t *testing.T) {
|
||||
|
||||
@@ -1084,7 +1084,7 @@ func unansweredWords(l stalledLine, o conditions.Observation) (headline, explana
|
||||
}
|
||||
|
||||
// uncheckedWaitWords are the merge checks an unanswered line waits on, as the operator reads them: from the checks
|
||||
// given as data, each time in the controller's local zone, as every other time in a message — "mesh/merge-gate
|
||||
// given as data, each time in the controller's local zone, as every other time in a message — "mesh/check
|
||||
// pending since 02:11" — never the UTC time inside mesh-delivery's finished words, which cannot be said again in
|
||||
// another zone (novox/hq issue 443). A line from a mesh-delivery that gives no such data is said by its words.
|
||||
func uncheckedWaitWords(l stalledLine) []string {
|
||||
|
||||
@@ -22,11 +22,11 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) {
|
||||
if !strings.Contains(line, "building for 40m0s") || !strings.Contains(line, "LATE") {
|
||||
t.Fatalf("a plan in its tier for 40m, saved 3s ago, reads %q", line)
|
||||
}
|
||||
st := planStatuses([]inventory.Plan{p}, now, pauseView{}, nil)[0]
|
||||
st := planStatuses([]inventory.Plan{p}, now, nil)[0]
|
||||
if !st.Late || !st.Since.Equal(p.TierEntered) {
|
||||
t.Errorf("status --json says %+v", st)
|
||||
}
|
||||
if _, late := openPlans([]inventory.Plan{p}, now, pauseView{}, nil); late != 1 {
|
||||
if _, late := openPlans([]inventory.Plan{p}, now, nil); late != 1 {
|
||||
t.Errorf("status counted %d late", late)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) {
|
||||
q := p
|
||||
q.TierEntered = now.Add(-in)
|
||||
bound := bounds.of(q.Repository)
|
||||
late := strings.Contains(planLineWith(q, now, pauseView{}, bound), "LATE")
|
||||
late := strings.Contains(planLineWith(q, now, bound), "LATE")
|
||||
stalled := len(watchPlans(&signalFacts{now: now, plans: []planFacts{{id: q.ID, repository: q.Repository,
|
||||
commit: q.Commit, tiers: 1, entered: inTierSince(q), bound: bound}}})) == 1
|
||||
if late != stalled || late != (in > bound) {
|
||||
@@ -65,7 +65,7 @@ func TestAPlansLineCountsFromItsTierNotFromItsLastSave(t *testing.T) {
|
||||
if line := planLine(waiting, now); !strings.Contains(line, "for 40m0s") || strings.Contains(line, "LATE") {
|
||||
t.Errorf("a walk waiting 40m reads %q", line)
|
||||
}
|
||||
if st := planStatuses([]inventory.Plan{waiting}, now, pauseView{}, nil)[0]; st.Late {
|
||||
if st := planStatuses([]inventory.Plan{waiting}, now, nil)[0]; st.Late {
|
||||
t.Errorf("a waiting walk is called late: %+v", st)
|
||||
}
|
||||
let := now.Add(-5 * time.Minute)
|
||||
@@ -99,7 +99,7 @@ func TestAPlanLineSaysTheRefusalOnceItsTierIsBuilt(t *testing.T) {
|
||||
if !strings.Contains(line, "a build waits there for its gate: novox") || strings.Contains(line, "building for") {
|
||||
t.Errorf("920/AC1: a plan with its tier built and a refusal reads %q", line)
|
||||
}
|
||||
st := planStatuses([]inventory.Plan{p}, now, pauseView{}, nil)
|
||||
st := planStatuses([]inventory.Plan{p}, now, nil)
|
||||
if len(st) != 1 || !st[0].Late {
|
||||
t.Errorf("920/AC1: status: %+v", st)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/builderkeys"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
@@ -17,64 +16,19 @@ import (
|
||||
// A walk takes its modules from the builder's done keys (novox/hq ADR 0503 §6, ADR 0501 §1, ADR 0498 §8; issue 1851, A6
|
||||
// and A7).
|
||||
//
|
||||
// From R4e a walk asks build-agent for no module but the builder's own (ADR 0453 §5). A module of its tier is marked
|
||||
// A walk asks no build of anybody (R4g; ADR 0453 §5). A module of its tier is marked
|
||||
// taken from a key (PlanModule.Key) and settled by the look: its fingerprint is read from the indexer's trunk value
|
||||
// written for the commit the walk carries of its repository; the builder's key of that fingerprint, done with every
|
||||
// artifact referenced, is taken in (recorded and registered, off the plans' hold) and the module is built; a key not done
|
||||
// yet, or waiting for a reference, holds the tier and says so; a failed key fails the walk in the builder's own words.
|
||||
// **No timer**: the builder's and the indexer's writes wake the look (builderkeys.Watch, watchNewModules).
|
||||
|
||||
// isBuildersOwn says a module is build-agent's own: asked of build-agent on a merge and taken from its build record, never
|
||||
// from the builder's key, until R4g (ADR 0453 §5, ADR 0498 §7, ADR 0520 §1).
|
||||
func isBuildersOwn(module string) bool { return buildersOwn(module) }
|
||||
|
||||
// buildAgentsOwn are those modules, named (ADR 0520 §1): the builder's; build-agent itself, whose image's context is another
|
||||
// repository's; and mesh-tools, the toolchain image build-agent's builds stand on. The builder writes no key of any of them,
|
||||
// and a walk waiting for one would hold its tier for ever. mesh-tools-go is not among them: it builds from a Dockerfile in
|
||||
// its own directory on a pinned base, and has its key from mesh-build-image.
|
||||
//
|
||||
// **And the parts that write references** (ADR 0522 §6, issue 1886): the build modules push and answer each reference,
|
||||
// and the builder writes it into the key, so none of them is ever taken from a key they write themselves. Until R4g
|
||||
// build-agent is the way they are delivered whatever the builder says.
|
||||
var buildAgentsOwn = map[string]bool{sdkdelivery.BuilderModule: true, builder.AgentModule: true, "mesh-tools": true,
|
||||
"mesh-build-go": true, "mesh-build-node": true, "mesh-build-image": true}
|
||||
|
||||
// buildersOwn is isBuildersOwn's rule: a variable only so a test of the asks left (their bus waits, their plan key) may
|
||||
// name modules of its own as build-agent's.
|
||||
var buildersOwn = func(module string) bool { return buildAgentsOwn[module] }
|
||||
|
||||
// planKeyReads is what a walk reads of the indexer and the builder (builderkeys.Reader, or a test's).
|
||||
type planKeyReads interface {
|
||||
ModuleAt(ctx context.Context, module, commit string) (sdkdelivery.Module, error)
|
||||
Build(ctx context.Context, module, fp string) (builderkeys.Key, error)
|
||||
}
|
||||
|
||||
// builderSayKey carries, on an ask's context, the builder's say the walk read before asking (withBuilderSay).
|
||||
type builderSayKey struct{}
|
||||
|
||||
// withBuilderSay hands the say a walk read to the ask's guard, so the guard never reads it a second time.
|
||||
func withBuilderSay(ctx context.Context, says bool) context.Context {
|
||||
return context.WithValue(ctx, builderSayKey{}, says)
|
||||
}
|
||||
|
||||
// builderSayOf is the say a walk handed the ask, and whether it handed one.
|
||||
func builderSayOf(ctx context.Context) (says, handed bool) {
|
||||
says, handed = ctx.Value(builderSayKey{}).(bool)
|
||||
return says, handed
|
||||
}
|
||||
|
||||
// builderSaysReferences is whether a running builder instance says, in its own bucket of instances, that the keys it
|
||||
// writes name references (issue 1886). Until one does, a walk asks build-agent for every module, as before R4e: the
|
||||
// keys the running builder writes could never be delivered from. A variable so a test needs no bus.
|
||||
var builderSaysReferences = func(ctx context.Context) (bool, error) {
|
||||
keys, closeKeys, err := openBuilderKeys()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer closeKeys()
|
||||
return keys.SaysReferences(ctx)
|
||||
}
|
||||
|
||||
// openPlanKeys opens what a walk reads of the indexer and the builder. A variable so a test needs no bus.
|
||||
var openPlanKeys = func() (planKeyReads, func(), error) {
|
||||
r, closeKeys, err := openBuilderKeys()
|
||||
@@ -112,10 +66,13 @@ var keyTakes = struct {
|
||||
// the buckets and the records; a done key not recorded yet is taken in off the plans' hold, and its end asks the look.
|
||||
func settleFromKeys(ctx context.Context, open *stores, keys planKeyReads, p *inventory.Plan, tier []string) (bool, error) {
|
||||
return settleKeys(ctx, open.inventory, keys, p, tier, func(module string, trunk sdkdelivery.Module, id string) {
|
||||
inBackground(func() { takeKeyOffTheHold(context.WithoutCancel(ctx), open, module, trunk, id) })
|
||||
inBackground(func() { takeKeyIn(context.WithoutCancel(ctx), open, module, trunk, id) })
|
||||
})
|
||||
}
|
||||
|
||||
// takeKeyIn is how a look takes a done key in; a variable so a test of what a walk does around it needs no builder.
|
||||
var takeKeyIn = takeKeyOffTheHold
|
||||
|
||||
// keyRecords is what settling from keys reads of the records (*inventory.Inventory, or a test's).
|
||||
type keyRecords interface {
|
||||
BuildByID(ctx context.Context, id string) (inventory.Build, bool, error)
|
||||
@@ -133,20 +90,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// One say for the ask and the settle (builderSaysReferences): two sources that differ hand a module back and take it
|
||||
// from its key again at every turn of the look, which then never ends.
|
||||
says, err := builderSaysReferences(ctx)
|
||||
if err != nil {
|
||||
return changed, fmt.Errorf("whether the builder writes references cannot be read: %w", err)
|
||||
}
|
||||
// **A walk never waits on a key the running builder cannot reference** (issue 1886): while no builder says it writes
|
||||
// references, a module that waits on its key, or whose key failed, is handed back unasked, and the look asks it of
|
||||
// build-agent. A done key with every reference is still taken.
|
||||
toAgent := func(s *inventory.PlanModule, why string) {
|
||||
s.State, s.Key, s.AskedAt, s.KeyAt, s.Build, s.Fingerprint = "", false, nil, "", "", ""
|
||||
s.Why = "the builder does not say it writes references, so build-agent is asked (" + why + ")"
|
||||
changed = true
|
||||
}
|
||||
for _, m := range tier {
|
||||
s := p.Modules[m]
|
||||
if s == nil || !s.Key || s.State != "asked" {
|
||||
@@ -154,10 +97,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven
|
||||
}
|
||||
trunk, err := keys.ModuleAt(ctx, m, s.KeyAt)
|
||||
if errors.Is(err, builderkeys.ErrNoValue) {
|
||||
if !says {
|
||||
toAgent(s, "the indexer holds no value of it")
|
||||
continue
|
||||
}
|
||||
set(s, fmt.Sprintf("waits for the indexer's value of %s at %s", m, orNow(s.KeyAt)))
|
||||
continue
|
||||
}
|
||||
@@ -169,10 +108,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven
|
||||
if why == "" {
|
||||
why = "the indexer wrote no fingerprint of the new form"
|
||||
}
|
||||
if !says {
|
||||
toAgent(s, why)
|
||||
continue
|
||||
}
|
||||
set(s, fmt.Sprintf("waits: %s at %s has no fingerprint to take a build by: %s", m, orNow(s.KeyAt), why))
|
||||
continue
|
||||
}
|
||||
@@ -184,9 +119,6 @@ func settleKeys(ctx context.Context, inv keyRecords, keys planKeyReads, p *inven
|
||||
return changed, err
|
||||
}
|
||||
switch {
|
||||
case !says && (k.Failed() || !k.Done() || len(k.Unreferenced()) > 0):
|
||||
toAgent(s, k.Says())
|
||||
continue
|
||||
case k.Failed():
|
||||
// **A failed key fails the walk in the builder's own words** (`digests differ:`, `not stored:`, `not pinned:`,
|
||||
// `version taken:`), and a rebuild of it is a new revision the next walk takes.
|
||||
|
||||
@@ -73,12 +73,6 @@ func aWalkOfOne(module string) (*inventory.Plan, map[string]inventory.Entry) {
|
||||
// A6 (ADR 0503 §6, ADR 0498 check §7): a walk whose module has a done key goes asked to built with no build asked: the
|
||||
// ask stub is never called, the key is taken in once, and `plans` says it was taken from the builder's key.
|
||||
func TestAWalkTakesADoneKeyAndAsksNoBuild(t *testing.T) {
|
||||
was := askABuild
|
||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||
t.Fatal("a walk asked build-agent for a module that is not the builder's own")
|
||||
return "", nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
p, byName := aWalkOfOne("sudo")
|
||||
askModule(t.Context(), p, "sudo", byName)
|
||||
s := p.Modules["sudo"]
|
||||
@@ -118,20 +112,11 @@ func TestAWalkTakesADoneKeyAndAsksNoBuild(t *testing.T) {
|
||||
if s.State != "built" || s.Build != id || !strings.Contains(s.Made, "taken from the builder's key") {
|
||||
t.Fatalf("the module is not built from its key: %+v", s)
|
||||
}
|
||||
if takesOutcome(*p, &inventory.PlanModule{Key: true, State: "asked", AskedAt: s.AskedAt}, time.Now(), "build-1") {
|
||||
t.Fatal("a build-agent outcome answers a module taken from a key")
|
||||
}
|
||||
}
|
||||
|
||||
// A7: a key building waits and says so, then moves on when done, with no timer and no ask; a failed key fails the walk
|
||||
// in the builder's words; a value the indexer has not written yet waits, named.
|
||||
func TestAWalkWaitsForAKeyAndFailsWithItsWords(t *testing.T) {
|
||||
asksOfNone := askABuild
|
||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||
t.Fatal("a walk asked a build")
|
||||
return "", nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = asksOfNone })
|
||||
t.Cleanup(func() {
|
||||
keyTakes.Lock()
|
||||
keyTakes.running, keyTakes.waits, keyTakes.taken = map[string]bool{}, map[string]string{}, map[string]bool{}
|
||||
|
||||
@@ -136,18 +136,6 @@ func askToLook() {
|
||||
}
|
||||
}
|
||||
|
||||
// askedToLook asks the serving controller's look at the plans, and says whether there was one to ask.
|
||||
func askedToLook() bool {
|
||||
lookerMu.RLock()
|
||||
l := theLooker
|
||||
lookerMu.RUnlock()
|
||||
if l == nil {
|
||||
return false
|
||||
}
|
||||
l.ask()
|
||||
return true
|
||||
}
|
||||
|
||||
// startPlanLooks starts the look at the open plans, and asks for the first: the controller took the lease, and a
|
||||
// plan left by a replaced controller resumes from there (ADR 0406 §6). It replaces the rollout's 30 second ticker.
|
||||
// It answers a channel that is closed once the look has returned after the context ended, for a test that restores what
|
||||
@@ -206,10 +194,6 @@ func servingPlanSaved(ctx context.Context, bus link.Bus, open *stores) func(*inv
|
||||
return func(_ *inventory.Inventory, p inventory.Plan) {
|
||||
sayPlanMoved(ctx, bus, open.inventory, p)
|
||||
planKept(p)
|
||||
keepBuildingKey(ctx, p)
|
||||
if p.WalkSaid == inventory.WalkEnded {
|
||||
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,12 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// A plan follows what is done to the build queue (novox/hq ADR 0219).
|
||||
@@ -26,106 +24,6 @@ import (
|
||||
// beside it — beside it, the plan would either ask it again or stay failed on an outcome the
|
||||
// rebuild has already replaced.
|
||||
|
||||
// pauseView is whether the build seat takes work: the holders that said they are paused, and
|
||||
// whether that is every holder.
|
||||
type pauseView struct {
|
||||
Nodes []string
|
||||
All bool
|
||||
}
|
||||
|
||||
// pausedWaiting is what a plan waits on when the build seat is paused under it, or false: a plan
|
||||
// building, whose current tier has an ask outstanding, while every holder of the seat is paused.
|
||||
func pausedWaiting(p inventory.Plan, pause pauseView, now time.Time) (string, bool) {
|
||||
if p.State != inventory.PlanBuilding || !pause.All || len(pause.Nodes) == 0 || p.Tier >= len(p.Tiers) {
|
||||
return "", false
|
||||
}
|
||||
var asked *time.Time
|
||||
for _, m := range p.Tiers[p.Tier] {
|
||||
if s := p.Modules[m]; s != nil && s.State == "asked" && s.AskedAt != nil {
|
||||
if asked == nil || s.AskedAt.Before(*asked) {
|
||||
asked = s.AskedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
if asked == nil {
|
||||
return "", false
|
||||
}
|
||||
waited := now.Sub(*asked)
|
||||
said := fmt.Sprintf("waiting: the build seat is paused on %s (asked %s ago)",
|
||||
strings.Join(pause.Nodes, ", "), waited.Round(time.Second))
|
||||
// Not late — a pause is a decision — but a pause forgotten is a plan that never moves, so one held
|
||||
// longer than a day is named.
|
||||
if waited > pausedTooLong {
|
||||
said += " — PAUSED OVER A DAY: `resume` takes builds again"
|
||||
}
|
||||
return said, true
|
||||
}
|
||||
|
||||
// pausedTooLong is how long a plan may wait on a paused build seat before it says the pause is long.
|
||||
const pausedTooLong = 24 * time.Hour
|
||||
|
||||
// awaitsABuild is whether any open plan has an ask outstanding in its current tier — the only case
|
||||
// where the seat being paused changes what a plan says.
|
||||
func awaitsABuild(plans []inventory.Plan) bool {
|
||||
for _, p := range plans {
|
||||
if p.State != inventory.PlanBuilding || p.Tier >= len(p.Tiers) {
|
||||
continue
|
||||
}
|
||||
for _, m := range p.Tiers[p.Tier] {
|
||||
if s := p.Modules[m]; s != nil && s.State == "asked" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildSeatPause reads whether the build seat's holders take work, from what each last said on the
|
||||
// bus (link.HolderState) — read only when a plan waits on a build, so a mesh with nothing building
|
||||
// does not dial the bus to say so. Anything unreadable is said and read as not paused: a plan then
|
||||
// reads as late, which is what it said before this existed.
|
||||
func buildSeatPause(ctx context.Context, inv *inventory.Inventory, plans []inventory.Plan) pauseView {
|
||||
if !awaitsABuild(plans) {
|
||||
return pauseView{}
|
||||
}
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return pauseView{}
|
||||
}
|
||||
seat := buildSeatAmong(entries)
|
||||
holders := holdersAmong(entries, seat)
|
||||
if len(holders) == 0 {
|
||||
return pauseView{}
|
||||
}
|
||||
// On the serving controller's own connection when this is it: a watchdog tick while a walk waits for a
|
||||
// build dialled one every 30 seconds (novox/hq issue 327).
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not reach the bus to read whether the build seat is paused: %v\n", err)
|
||||
return pauseView{}
|
||||
}
|
||||
defer js.Close()
|
||||
said, err := link.PausedSaid(js, seat, holders)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not read whether the build seat is paused: %v\n", err)
|
||||
return pauseView{}
|
||||
}
|
||||
return pauseOf(holders, said)
|
||||
}
|
||||
|
||||
// pauseOf is the view from what each holder said.
|
||||
func pauseOf(holders []string, said map[string]link.HolderState) pauseView {
|
||||
var v pauseView
|
||||
for _, n := range holders {
|
||||
if said[n].Paused {
|
||||
v.Nodes = append(v.Nodes, n)
|
||||
}
|
||||
}
|
||||
sort.Strings(v.Nodes)
|
||||
v.All = len(holders) > 0 && len(v.Nodes) == len(holders)
|
||||
return v
|
||||
}
|
||||
|
||||
// failedIn is the modules of a plan's current tier that failed to build, sorted.
|
||||
func failedIn(p inventory.Plan) []string {
|
||||
if p.Tier >= len(p.Tiers) {
|
||||
@@ -684,20 +582,10 @@ func holdingABuild(p inventory.Plan) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// resumeStopped sets a walk stopped by hand building again at the tier it stopped at, its stop cleared and its asks kept but
|
||||
// build-agent's own (issue 1886): the next look carries it on as any walk — a build done waits for its reference, and one done on the
|
||||
// resumeStopped sets a walk stopped by hand building again at the tier it stopped at, its stop cleared and its asks kept
|
||||
// (issue 1886): the next look carries it on as any walk — a build done waits for its reference, and one done on the
|
||||
// builder's key is handed on (novox/hq ADR 0522 §3). Its note keeps who stopped it and that a person resumed it.
|
||||
func resumeStopped(p *inventory.Plan) string {
|
||||
// **Build-agent's own are asked of build-agent** (novox/hq ADR 0522 §6): one marked taken from a key it writes itself is
|
||||
// set unasked, and the look asks the rest of a partly asked tier (unaskedOf), of build-agent, whatever the builder says.
|
||||
var again []string
|
||||
for _, m := range p.Tiers[p.Tier] {
|
||||
if s := p.Modules[m]; s != nil && s.State == "asked" && s.Key && isBuildersOwn(m) {
|
||||
s.State, s.Key, s.AskedAt, s.Build, s.Why = "", false, nil, "", ""
|
||||
again = append(again, m)
|
||||
}
|
||||
}
|
||||
sort.Strings(again)
|
||||
asked, unsent, unasked := waitingIn(*p)
|
||||
later := 0
|
||||
for i := p.Tier + 1; i < len(p.Tiers); i++ {
|
||||
@@ -706,9 +594,6 @@ func resumeStopped(p *inventory.Plan) string {
|
||||
by, why := p.Delivery.Stopped, p.Delivery.StoppedWhy
|
||||
waits := fmt.Sprintf("%d asked, %d built and not sent, %d not yet asked in it; %d not yet asked in the tiers after",
|
||||
asked, unsent, unasked, later)
|
||||
if len(again) > 0 {
|
||||
waits += "; " + strings.Join(again, ", ") + " set unasked, for the look to ask of build-agent (its own, ADR 0522 §6)"
|
||||
}
|
||||
p.State = inventory.PlanBuilding
|
||||
goesOnAgain(p)
|
||||
p.Delivery.Kept = nil
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -27,7 +26,7 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) {
|
||||
p.Modules[m] = &inventory.PlanModule{State: "built", BuiltAt: &sent, SentAt: &sent}
|
||||
}
|
||||
var tier1 []string
|
||||
for _, m := range []string{"build-agent", "mesh-tools"} {
|
||||
for _, m := range []string{"mesh-tools-go", "mesh-tools"} {
|
||||
tier1 = append(tier1, m)
|
||||
p.Modules[m] = &inventory.PlanModule{State: "built", AskedAt: &sent, BuiltAt: &sent, Build: "build-" + m}
|
||||
}
|
||||
@@ -58,22 +57,12 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) {
|
||||
}
|
||||
for _, m := range onKeys {
|
||||
s := p.Modules[m]
|
||||
if isBuildersOwn(m) {
|
||||
// Build-agent's own are asked of build-agent whatever the builder says (ADR 0522 §6): set unasked, for the look.
|
||||
if s.State != "" || s.Key || s.Build != "" || s.AskedAt != nil {
|
||||
t.Errorf("%s, build-agent's own, was left on the builder's key: %+v", m, s)
|
||||
}
|
||||
if !slices.Contains(unaskedOf(p), m) {
|
||||
t.Errorf("%s is not among what the look asks next: %v", m, unaskedOf(p))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.State != "asked" || !s.Key || s.Build != "build-"+m {
|
||||
t.Errorf("%s's ask was not kept for the look to hand on: %+v", m, s)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"plan-r4e", "tier 2 of 3", "5 asked", "2 built and not sent", "3 not yet asked in it",
|
||||
"3 not yet asked in the tiers after", "mesh-build-go, mesh-build-image, mesh-build-node set unasked"} {
|
||||
for _, w := range []string{"plan-r4e", "tier 2 of 3", "8 asked", "2 built and not sent", "0 not yet asked in it",
|
||||
"3 not yet asked in the tiers after"} {
|
||||
if !strings.Contains(said, w) {
|
||||
t.Errorf("the answer %q does not say %q", said, w)
|
||||
}
|
||||
@@ -109,8 +98,8 @@ func TestRetryResumesAWalkStoppedByHand(t *testing.T) {
|
||||
}
|
||||
// A newer plan holding a build of its tier not yet sent refuses it: resuming would send the older build over the newer.
|
||||
newerBuilt := inventory.Plan{ID: "plan-agent", Repository: "novox/mesh-controller", Branch: "main", Commit: "d00dfeed",
|
||||
Created: at, State: inventory.PlanDone, Tiers: [][]string{{"build-agent"}},
|
||||
Modules: map[string]*inventory.PlanModule{"build-agent": {State: "built"}}}
|
||||
Created: at, State: inventory.PlanDone, Tiers: [][]string{{"mesh-tools"}},
|
||||
Modules: map[string]*inventory.PlanModule{"mesh-tools": {State: "built"}}}
|
||||
if err := retryRefusal(stopped, []inventory.Plan{stopped, newerBuilt}, ownPathOf(nil)); err == nil ||
|
||||
!strings.Contains(err.Error(), "older build") {
|
||||
t.Errorf("a stopped walk was resumed over a newer plan of a build it has not sent: %v", err)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
snapshot "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
@@ -589,46 +587,6 @@ func describe(entries []inventory.Entry) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// **The merge gate reads the build sources the snapshot carries** (novox/hq ADR 0267): the gate's plan of a
|
||||
// change is the merge handler's, so a snapshot taken by a controller that records build sources narrows the
|
||||
// gate's plan as it narrows the merge's; one without them reads every module as before.
|
||||
func TestTheGatePlansFromTheBuildSourcesTheSnapshotCarries(t *testing.T) {
|
||||
manifest := func(name string) json.RawMessage { return json.RawMessage(`{"module":"` + name + `","version":"1"}`) }
|
||||
facts := snapshot.Facts{Modules: []snapshot.Module{
|
||||
{Name: "mesh-controller", Repository: "novox/mesh-controller", Manifest: manifest("mesh-controller"),
|
||||
Sources: []snapshot.BuildSource{{Own: true, Paths: []string{"module.json", "cmd/mesh-controller/", "internal/broker/"}}}},
|
||||
{Name: "route-proxy", Repository: "novox/mesh-catalog", Path: "modules/route-proxy", Manifest: manifest("route-proxy"),
|
||||
Reads: []string{"novox/mesh-controller"},
|
||||
Sources: []snapshot.BuildSource{{Repository: "novox/mesh-controller", Paths: []string{"modules/route-proxy/cmd/route-proxy/", "internal/broker/"}},
|
||||
{Own: true, Paths: []string{"modules/route-proxy/module.json"}}}},
|
||||
}}
|
||||
for paths, want := range map[string]string{
|
||||
"cmd/mesh-controller/main.go": "mesh-controller",
|
||||
"modules/route-proxy/cmd/route-proxy/main.go": "route-proxy",
|
||||
"internal/broker/broker.go": "mesh-controller,route-proxy",
|
||||
"README.md": "",
|
||||
} {
|
||||
r, err := reachOfChange(facts, "novox/mesh-controller", []string{paths}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tiered(r.Plan.Tiers); got != want {
|
||||
t.Errorf("%s: the gate planned %q, wanted %q", paths, got, want)
|
||||
}
|
||||
}
|
||||
// A snapshot without build sources: as before.
|
||||
for i := range facts.Modules {
|
||||
facts.Modules[i].Sources = nil
|
||||
}
|
||||
r, err := reachOfChange(facts, "novox/mesh-controller", []string{"README.md"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := tiered(r.Plan.Tiers); got != "mesh-controller,route-proxy" {
|
||||
t.Errorf("a snapshot without build sources: the gate planned %q for a README", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A plan says why each module is in it** (novox/hq ADR 0267, issue 363): the files of its build source the
|
||||
// merge changed, or why it is read whole — never that it packages a repository as though that moved it.
|
||||
func TestAPlanSaysWhyEachModuleIsInIt(t *testing.T) {
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The plans building (novox/hq ADR 0483 clause 2, issue 1687).
|
||||
//
|
||||
// **A key per delivery plan in its build phase, so that the build seat takes no new check while a delivery builds.** The phase
|
||||
// runs from the ask of the plan's first build until every build of its last tier is built, or the plan fails, stops or waits
|
||||
// on the operator. It is derived from the plan alone (buildPhaseOf), so a controller that restarts finds the same answer. The
|
||||
// key is put when the first build is asked (sayBuilding), kept current at every save of the plan (keepBuildingKey) and by a
|
||||
// look every link.PlansBuildingRefresh while this controller holds the lease (keepingPlansBuilding), and deleted at the phase's
|
||||
// end; the bucket's age removes the key of a controller that stopped, so checks are held at most that long.
|
||||
//
|
||||
// The layout is documented in internal/link/holds.go.
|
||||
|
||||
// buildPhaseOf says whether a plan is in its build phase: open, not waiting for the operator's word, with a build asked and a
|
||||
// build of its tiers still to be built. A tier built and sent leaves the plan rolling while later tiers are still to be asked;
|
||||
// it is in the phase still, which is what keeps a check from taking a place between two tiers.
|
||||
func buildPhaseOf(p inventory.Plan) bool {
|
||||
if !p.Open() || p.Waiting() || p.Release != nil {
|
||||
return false
|
||||
}
|
||||
asked, unbuilt := false, false
|
||||
for _, tier := range p.Tiers {
|
||||
for _, m := range tier {
|
||||
st := p.Modules[m]
|
||||
if st != nil && st.State != "" {
|
||||
asked = true
|
||||
}
|
||||
if st == nil || (st.State != "built" && st.State != planDeleted) {
|
||||
unbuilt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return asked && unbuilt
|
||||
}
|
||||
|
||||
// buildingSince is when the plan's first build was asked, or fallback when no module says.
|
||||
func buildingSince(p inventory.Plan, fallback time.Time) time.Time {
|
||||
since := time.Time{}
|
||||
for _, st := range p.Modules {
|
||||
if st != nil && st.AskedAt != nil && (since.IsZero() || st.AskedAt.Before(since)) {
|
||||
since = *st.AskedAt
|
||||
}
|
||||
}
|
||||
if since.IsZero() {
|
||||
return fallback
|
||||
}
|
||||
return since
|
||||
}
|
||||
|
||||
// buildingStore is the bucket of the plans building.
|
||||
type buildingStore interface {
|
||||
Put(ctx context.Context, plan string, value []byte) error
|
||||
Delete(ctx context.Context, plan string) error
|
||||
Keys(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// syncBuilding makes the bucket say what the plans say: a key for each plan in its build phase, put again, and none for any
|
||||
// other plan, deleted. It returns the faults, each once.
|
||||
func syncBuilding(ctx context.Context, store buildingStore, plans []inventory.Plan, now time.Time) []error {
|
||||
var faults []error
|
||||
live := map[string]bool{}
|
||||
for _, p := range plans {
|
||||
if !buildPhaseOf(p) {
|
||||
continue
|
||||
}
|
||||
live[p.ID] = true
|
||||
if err := putBuilding(ctx, store, p.ID, buildingSince(p, now), now); err != nil {
|
||||
faults = append(faults, err)
|
||||
}
|
||||
}
|
||||
keys, err := store.Keys(ctx)
|
||||
if err != nil {
|
||||
return append(faults, err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if live[key] {
|
||||
continue
|
||||
}
|
||||
if err := store.Delete(ctx, key); err != nil {
|
||||
faults = append(faults, fmt.Errorf("the key of %s could not be taken away: %w", key, err))
|
||||
}
|
||||
}
|
||||
return faults
|
||||
}
|
||||
|
||||
func putBuilding(ctx context.Context, store buildingStore, plan string, since, now time.Time) error {
|
||||
body, _ := json.Marshal(link.PlanBuilding{Plan: plan, Since: since.UTC(), Seen: now.UTC()})
|
||||
if err := store.Put(ctx, plan, body); err != nil {
|
||||
return fmt.Errorf("the key of %s could not be put: %w", plan, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// busBuilding is the store on the controller's bucket.
|
||||
type busBuilding struct {
|
||||
conn *nats.Conn
|
||||
mu sync.Mutex
|
||||
kv jetstream.KeyValue
|
||||
}
|
||||
|
||||
func (b *busBuilding) bucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.kv != nil {
|
||||
return b.kv, nil
|
||||
}
|
||||
api, err := jetstream.New(b.conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kv, err := api.KeyValue(ctx, broker.PlansBuildingBucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the plans building: %w", err)
|
||||
}
|
||||
b.kv = kv
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
func (b *busBuilding) Put(ctx context.Context, plan string, value []byte) error {
|
||||
kv, err := b.bucket(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = kv.Put(ctx, plan, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *busBuilding) Delete(ctx context.Context, plan string) error {
|
||||
kv, err := b.bucket(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := kv.Delete(ctx, plan); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *busBuilding) Keys(ctx context.Context) ([]string, error) {
|
||||
kv, err := b.bucket(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys, err := kv.Keys(ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return keys, err
|
||||
}
|
||||
|
||||
// buildingKeys is the store of the serving controller; nil in a process that is not it, which says nothing.
|
||||
var buildingKeys atomic.Pointer[buildingStore]
|
||||
|
||||
func setBuildingStore(s buildingStore) {
|
||||
if s == nil {
|
||||
buildingKeys.Store(nil)
|
||||
return
|
||||
}
|
||||
buildingKeys.Store(&s)
|
||||
}
|
||||
|
||||
// sayBuilding puts a plan's key: its first build is being asked (or another of its builds). A key that could not be put is
|
||||
// said; the look that follows puts it again.
|
||||
func sayBuilding(ctx context.Context, plan string, since time.Time) {
|
||||
s := buildingKeys.Load()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if err := putBuilding(ctx, *s, plan, since, time.Now()); err != nil {
|
||||
fmt.Printf("plans: %v; checks may take a place of %s until the next look\n", err, plan)
|
||||
}
|
||||
}
|
||||
|
||||
// keepBuildingKey is told each plan this controller keeps: its key put again while it builds and taken away once it does not.
|
||||
func keepBuildingKey(ctx context.Context, p inventory.Plan) {
|
||||
s := buildingKeys.Load()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if buildPhaseOf(p) {
|
||||
sayBuilding(ctx, p.ID, buildingSince(p, time.Now()))
|
||||
return
|
||||
}
|
||||
if err := (*s).Delete(ctx, p.ID); err != nil {
|
||||
fmt.Printf("plans: the key of %s could not be taken away; it ages out: %v\n", p.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// keepingPlansBuilding looks at the plans every link.PlansBuildingRefresh for as long as this controller serves and holds the
|
||||
// lease, so a key is put again before its age and the key of a plan that left the phase unseen is taken away.
|
||||
func keepingPlansBuilding(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn) {
|
||||
store := &busBuilding{conn: conn}
|
||||
setBuildingStore(store)
|
||||
defer setBuildingStore(nil)
|
||||
tick := time.NewTicker(broker.PlansBuildingRefresh)
|
||||
defer tick.Stop()
|
||||
failing := ""
|
||||
for {
|
||||
if epoch, err := theLease.epoch(ctx); err == nil && epoch != 0 {
|
||||
why := ""
|
||||
if plans, err := inv.OpenPlans(ctx); err != nil {
|
||||
why = fmt.Sprintf("the plans cannot be read: %v", err)
|
||||
} else if faults := syncBuilding(ctx, store, plans, time.Now()); len(faults) > 0 {
|
||||
why = fmt.Sprint(faults)
|
||||
}
|
||||
if why != failing {
|
||||
if why != "" {
|
||||
fmt.Printf("plans: the keys of the plans building cannot be kept: %s\n", why)
|
||||
} else {
|
||||
fmt.Printf("plans: the keys of the plans building are kept again\n")
|
||||
}
|
||||
failing = why
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Issue 1687 (ADR 0483 clause 2): a delivery plan's key lives from the ask of its first build until every build of its last
|
||||
// tier is built, or the plan fails, stops or waits on the operator.
|
||||
|
||||
type fakeBuildingStore struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]link.PlanBuilding
|
||||
puts map[string]int
|
||||
}
|
||||
|
||||
func newFakeBuildingStore() *fakeBuildingStore {
|
||||
return &fakeBuildingStore{keys: map[string]link.PlanBuilding{}, puts: map[string]int{}}
|
||||
}
|
||||
|
||||
func (f *fakeBuildingStore) Put(_ context.Context, plan string, value []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var v link.PlanBuilding
|
||||
if err := json.Unmarshal(value, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
f.keys[plan] = v
|
||||
f.puts[plan]++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBuildingStore) Delete(_ context.Context, plan string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.keys, plan)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBuildingStore) Keys(context.Context) ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []string
|
||||
for k := range f.keys {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func aPlan(id, state string, tiers [][]string, states map[string]string) inventory.Plan {
|
||||
p := inventory.Plan{ID: id, State: state, Tiers: tiers, Modules: map[string]*inventory.PlanModule{}}
|
||||
now := time.Now().Add(-time.Minute)
|
||||
for m, st := range states {
|
||||
pm := &inventory.PlanModule{State: st}
|
||||
if st != "" {
|
||||
pm.AskedAt = &now
|
||||
}
|
||||
p.Modules[m] = pm
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAPlanIsInItsBuildPhaseFromTheFirstAskToTheLastBuild(t *testing.T) {
|
||||
tiers := [][]string{{"a", "b"}, {"c"}}
|
||||
cases := []struct {
|
||||
name string
|
||||
plan inventory.Plan
|
||||
want bool
|
||||
}{
|
||||
{"no build asked yet", aPlan("p", inventory.PlanBuilding, tiers, nil), false},
|
||||
{"the first tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked", "b": "asked"}), true},
|
||||
// Between two tiers: the first built and sent (rolling), the second not asked yet.
|
||||
{"between two tiers", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"}), true},
|
||||
{"the last tier asked", aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "built", "b": "built", "c": "asked"}), true},
|
||||
{"every build built", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": "built"}), false},
|
||||
// A module the plan deletes is done as far as its tiers go (release_plan.go), so it holds no key through the rollout.
|
||||
{"a deleted module is done", aPlan("p", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built", "c": planDeleted}), false},
|
||||
{"failed", aPlan("p", inventory.PlanFailed, tiers, map[string]string{"a": "failed", "b": "asked"}), false},
|
||||
{"superseded", aPlan("p", inventory.PlanSuperseded, tiers, map[string]string{"a": "asked"}), false},
|
||||
{"a batch whose window is open", aPlan("p", inventory.PlanAssembling, tiers, map[string]string{"a": "asked"}), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := buildPhaseOf(c.plan); got != c.want {
|
||||
t.Errorf("%s: in the build phase = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
waiting := aPlan("p", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"})
|
||||
waiting.Delivery = &inventory.PlanDelivery{Awaits: "the operator's word"}
|
||||
if buildPhaseOf(waiting) {
|
||||
t.Error("a plan that waits on the operator is in its build phase")
|
||||
}
|
||||
}
|
||||
|
||||
// The key's life: put, refreshed, deleted at the phase's end; a plan that left the phase unseen loses its key at the next look.
|
||||
func TestAPlanKeyIsPutRefreshedAndDeletedAtThePhasesEnd(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeBuildingStore()
|
||||
tiers := [][]string{{"a"}, {"b"}}
|
||||
building := aPlan("plan-1", inventory.PlanBuilding, tiers, map[string]string{"a": "asked"})
|
||||
|
||||
if faults := syncBuilding(ctx, store, []inventory.Plan{building}, time.Now()); len(faults) > 0 {
|
||||
t.Fatal(faults)
|
||||
}
|
||||
if _, ok := store.keys["plan-1"]; !ok {
|
||||
t.Fatal("no key while the first build is asked")
|
||||
}
|
||||
first := store.keys["plan-1"]
|
||||
// Refreshed: put again, the phase's start kept.
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
syncBuilding(ctx, store, []inventory.Plan{building}, time.Now())
|
||||
if store.puts["plan-1"] != 2 || !store.keys["plan-1"].Seen.After(first.Seen) || !store.keys["plan-1"].Since.Equal(first.Since) {
|
||||
t.Errorf("the key was not refreshed with its start kept: %d puts, %+v then %+v", store.puts["plan-1"], first, store.keys["plan-1"])
|
||||
}
|
||||
// Between the tiers: still there.
|
||||
between := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built"})
|
||||
syncBuilding(ctx, store, []inventory.Plan{between}, time.Now())
|
||||
if _, ok := store.keys["plan-1"]; !ok {
|
||||
t.Error("the key was gone between two tiers")
|
||||
}
|
||||
// A key of another plan that is no longer in the phase.
|
||||
store.keys["plan-0"] = link.PlanBuilding{Plan: "plan-0"}
|
||||
done := aPlan("plan-1", inventory.PlanRolling, tiers, map[string]string{"a": "built", "b": "built"})
|
||||
syncBuilding(ctx, store, []inventory.Plan{done}, time.Now())
|
||||
if len(store.keys) != 0 {
|
||||
t.Errorf("keys remain after the last build was built: %v", store.keys)
|
||||
}
|
||||
}
|
||||
|
||||
// keepBuildingKey, told of each save, puts the key while the plan builds and deletes it when the plan fails.
|
||||
func TestASavedPlanPutsOrDeletesItsKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newFakeBuildingStore()
|
||||
var s buildingStore = store
|
||||
buildingKeys.Store(&s)
|
||||
defer buildingKeys.Store(nil)
|
||||
|
||||
p := aPlan("plan-7", inventory.PlanBuilding, [][]string{{"a"}}, map[string]string{"a": "asked"})
|
||||
keepBuildingKey(ctx, p)
|
||||
if _, ok := store.keys["plan-7"]; !ok {
|
||||
t.Fatal("the key was not put when the plan was saved building")
|
||||
}
|
||||
p.State = inventory.PlanFailed
|
||||
keepBuildingKey(ctx, p)
|
||||
if _, ok := store.keys["plan-7"]; ok {
|
||||
t.Fatal("the key stayed after the plan failed")
|
||||
}
|
||||
}
|
||||
|
||||
// The plan's key is there before its first build is asked, so a check cannot take a place between the ask and the plan's save.
|
||||
func TestThePlansKeyIsPutBeforeItsFirstBuildIsAsked(t *testing.T) {
|
||||
asksOfEveryModule(t)
|
||||
store := newFakeBuildingStore()
|
||||
var s buildingStore = store
|
||||
buildingKeys.Store(&s)
|
||||
defer buildingKeys.Store(nil)
|
||||
p, byName := aTierOfThree()
|
||||
keyThere := false
|
||||
was := askABuild
|
||||
askABuild = func(context.Context, buildSource, string, string) (string, error) {
|
||||
_, keyThere = store.keys["plan-1"]
|
||||
return "build-x", nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
askModule(t.Context(), p, "a", byName)
|
||||
if !keyThere {
|
||||
t.Fatal("the plan's key was not there when its first build was asked")
|
||||
}
|
||||
}
|
||||
@@ -149,11 +149,11 @@ func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsk
|
||||
}
|
||||
// Said as `plans <id>` says its first line, and as `status` says the plan.
|
||||
now, one := time.Now(), []inventory.Plan{p}
|
||||
bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one)
|
||||
line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string {
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
line := planLineSaid(p, now, bounds.of(p.Repository), func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
}, waitsOf(ctx, inv, one)) + heldSaid(p, heldNow(ctx, inv))
|
||||
doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line)
|
||||
doc := planWaitedDoc(p, planStatuses(one, now, bounds)[0], until, came, now.Sub(began), line)
|
||||
return json.NewEncoder(os.Stdout).Encode(doc)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestAWaitIsRefusedWhatItCannotDo(t *testing.T) {
|
||||
// the plan and what ended the wait, and its output is the line a person reads — never JSON inside a string.
|
||||
func TestAWaitForAPlanAnswersAsData(t *testing.T) {
|
||||
p := followedPlan(7, inventory.PlanRolling, 1)
|
||||
st := planStatuses([]inventory.Plan{p}, time.Now(), pauseView{}, tierBounds{})[0]
|
||||
st := planStatuses([]inventory.Plan{p}, time.Now(), tierBounds{})[0]
|
||||
doc := planWaitedDoc(p, st, "tier", "tier", 3*time.Second, "rolling, tier 2 of 2")
|
||||
stdout, _ := json.Marshal(doc)
|
||||
a := answerOf([]string{"plans", "wait", "plan-1"}, stdout, "", true)
|
||||
|
||||
@@ -2,13 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/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).
|
||||
@@ -20,48 +16,6 @@ import (
|
||||
// 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 {
|
||||
@@ -72,22 +26,3 @@ func loadPredefined(ctx context.Context, inv *inventory.Inventory) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -10,28 +10,6 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationPublishesTheNewestTrunkListAndJudgesEveryModuleAgainstIt(t *testing.T) {
|
||||
withoutAPublishedList(t)
|
||||
open := aMesh(t)
|
||||
|
||||
@@ -183,9 +183,6 @@ func serve(ctx context.Context) (err error) {
|
||||
// somebody deleted, a mesh raised from a restored backup, or a bus whose data directory was
|
||||
// replaced all have records and no objects — and a node whose consumer is missing hears nothing
|
||||
// while everything else about it looks correct.
|
||||
// And build results nobody was waiting for. A build triggered any other way than `build`
|
||||
// would otherwise be reported into the void, which is the same as not reporting it.
|
||||
server.Records(builds{inv, open})
|
||||
// Open plans move on events (novox/hq ADR 0406 §5, issue 974): a tier is asked on the `built` and the send of the
|
||||
// tier before it, a walk ends on its machines' reports, a silent machine is a key that expired, and a plan left
|
||||
// by a replaced controller resumes from the first look, which this asks for. No timer looks every so often.
|
||||
@@ -286,8 +283,6 @@ func serve(ctx context.Context) (err error) {
|
||||
go mirroringRecords(ctx, open, inv, bus.Conn, bus)
|
||||
// And the bucket of the files that travel by digest, judged against what each machine's latest declaration names (ADR 0486 §3).
|
||||
go sweepingDeclarationFiles(ctx, inv, bus.Conn)
|
||||
// And the key of each plan in its build phase, which holds the build seat's checks while a delivery builds (ADR 0483 clause 2).
|
||||
go keepingPlansBuilding(ctx, inv, bus.Conn)
|
||||
// And the modules the catalogue does not hold yet, registered from the builder's first done key of each, woken by
|
||||
// the builder's and the indexer's writes (novox/hq ADR 0501 §6, issue 1851): nothing asks a first build.
|
||||
watchNewModules(ctx, open)
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The build queue, controlled by hand (novox/hq ADR 0219).
|
||||
//
|
||||
// Seen whole — what waits, what runs where and for how long, what was handed out as often as it
|
||||
// may be and never settled — and changed through the controller: an ask cancelled, the queue
|
||||
// cleared, a module rebuilt, a build replayed. What one machine is running is that machine's
|
||||
// holder's to end or pause, and the controller asks it (`kill`, `pause`, `resume`).
|
||||
//
|
||||
// **Everything that drops an ask leaves a failed outcome for it**, taken in exactly as a build that
|
||||
// failed is (takeIn, then the plan): a plan waiting on an ask a person removed fails, saying so,
|
||||
// rather than waiting for ever on an answer nobody will give.
|
||||
|
||||
// holderAsks is how long a holder's verb is waited for; killAnswer how long its kill is — longer
|
||||
// than the holder's worst case (its two passes removing containers and its wait between, 50s).
|
||||
const (
|
||||
holderAsks = 15 * time.Second
|
||||
killAnswer = 75 * time.Second
|
||||
)
|
||||
|
||||
// dialTheBus is the controller's connection, for a command that reads or changes the queue: the serving
|
||||
// controller's own, lent, when this process is it (novox/hq issue 327).
|
||||
func dialTheBus() (*broker.JetStream, error) {
|
||||
return aBus()
|
||||
}
|
||||
|
||||
// queueCommand prints every ask in the build seat's work queue.
|
||||
func queueCommand(ctx context.Context, args []string) error {
|
||||
return listQueue(ctx, args, os.Stdout)
|
||||
}
|
||||
|
||||
// listQueue is `queue`: the build queue, as a person reads it or as JSON.
|
||||
func listQueue(ctx context.Context, args []string, w io.Writer) error {
|
||||
set := flag.NewFlagSet("queue", flag.ContinueOnError)
|
||||
usageTo(set, w)
|
||||
asJSON := set.Bool("json", false, "the queue as JSON")
|
||||
if _, err := parseAround(set, args); err != nil {
|
||||
return err
|
||||
}
|
||||
js, err := dialTheBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
seat := buildSeatHeld(ctx)
|
||||
q, err := link.ReadQueue(ctx, js, seat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if *asJSON {
|
||||
body, err := json.MarshalIndent(queueAnswerOf(q, now), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w, string(body))
|
||||
return nil
|
||||
}
|
||||
fmt.Fprint(w, queueText(q, now))
|
||||
return nil
|
||||
}
|
||||
|
||||
// queueAnswer is the queue as data (novox/hq issue 455): what a caller reads to say how far back an ask
|
||||
// waits — "waiting in the build queue behind 2" — which the table alone could not tell it. Text is the
|
||||
// table, written from the same read: the verb answers it as its output and the rest as its answer, so the
|
||||
// two never disagree.
|
||||
type queueAnswer struct {
|
||||
Seat string `json:"seat"`
|
||||
Waiting int `json:"waiting"`
|
||||
InFlight int `json:"in-flight"`
|
||||
Dead int `json:"dead"`
|
||||
MaxDeliver int `json:"max-deliver"`
|
||||
Asks []queuedAnswer `json:"asks"`
|
||||
// HeldFor are the delivery plans building, while which the waiting checks are held (ADR 0483 clause 5); empty when none.
|
||||
HeldFor []string `json:"held-for,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// queuedAnswer is one ask as data. Never what the request carries beyond its source — not `held`, every
|
||||
// artifact the mesh has built, nor a check's checkouts beside it.
|
||||
type queuedAnswer struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
// Kind is "delivery" for a build a walk waits for, "check" for a pull request's check and any ask
|
||||
// whose kind cannot be told (link.KindOf, novox/hq issue 558).
|
||||
Kind string `json:"kind"`
|
||||
Repository string `json:"repository"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
// Commit is the ref when it names a commit; empty when the ask follows a branch or the default.
|
||||
Commit string `json:"commit,omitempty"`
|
||||
// Machine is the one building an ask in flight; WasOn the one that started it and handed it back.
|
||||
Machine string `json:"machine,omitempty"`
|
||||
WasOn string `json:"was-on,omitempty"`
|
||||
AskedAt time.Time `json:"asked-at,omitzero"`
|
||||
// Since is when the ask came to its state: its start on the machine for one in flight that a
|
||||
// machine said it started, otherwise when it was asked.
|
||||
Since time.Time `json:"since,omitzero"`
|
||||
// Position is its place among the waiting, 1 being handed out next; 0 for an ask not waiting.
|
||||
Position int `json:"position,omitempty"`
|
||||
Seq uint64 `json:"seq"`
|
||||
// Delivery is the how-manyth time an ask in flight was handed out; more than 1 is a restart
|
||||
// (novox/hq issue 594).
|
||||
Delivery int `json:"delivery,omitempty"`
|
||||
// LetThrough is a check that waited past 30 minutes and is let through while a delivery builds: the next one, or the one
|
||||
// that runs (ADR 0483 clause 5).
|
||||
LetThrough bool `json:"let-through,omitempty"`
|
||||
}
|
||||
|
||||
// aCommitRef is a ref that names a commit by its full hash (SHA-1 or SHA-256).
|
||||
var aCommitRef = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`)
|
||||
|
||||
// queueAnswerOf is the queue as data, in the table's order: in flight, the waiting from the next one to
|
||||
// be handed out, then the dead. A pure function of one read (novox/hq issue 455).
|
||||
func queueAnswerOf(q link.Queue, now time.Time) queueAnswer {
|
||||
waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead)
|
||||
out := queueAnswer{Seat: q.Seat, Waiting: len(waiting), InFlight: len(running), Dead: len(dead),
|
||||
MaxDeliver: q.MaxDeliver, Asks: []queuedAnswer{}, HeldFor: q.Hold.Plans, Text: queueText(q, now)}
|
||||
letNext, letRunning := q.LetThroughOf(now)
|
||||
one := func(a link.QueuedAsk, position int) queuedAnswer {
|
||||
kind := link.KindOf(a.Request)
|
||||
since := a.AskedAt
|
||||
if a.State == link.AskInFlight && a.On != "" && !a.Started.IsZero() {
|
||||
since = a.Started
|
||||
}
|
||||
commit := ""
|
||||
if aCommitRef.MatchString(a.Ref) {
|
||||
commit = a.Ref
|
||||
}
|
||||
return queuedAnswer{ID: a.ID, State: a.State, Kind: kind, Repository: link.WithoutPassword(a.Repository), Path: a.Path,
|
||||
Ref: a.Ref, Commit: commit, Machine: a.On, WasOn: a.Was, AskedAt: a.AskedAt, Since: since,
|
||||
Position: position, Seq: a.Seq, Delivery: a.Delivery, LetThrough: a.ID != "" && (a.ID == letNext || a.ID == letRunning)}
|
||||
}
|
||||
for _, a := range running {
|
||||
out.Asks = append(out.Asks, one(a, 0))
|
||||
}
|
||||
for i, a := range waiting {
|
||||
out.Asks = append(out.Asks, one(a, i+1))
|
||||
}
|
||||
for _, a := range dead {
|
||||
out.Asks = append(out.Asks, one(a, 0))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// queueText is the queue as a person reads it: a summary line, then each kind in queue order.
|
||||
// Never what an ask carries as `held` — that is every artifact the mesh has built.
|
||||
func queueText(q link.Queue, now time.Time) string {
|
||||
var b strings.Builder
|
||||
waiting, running, dead := q.Of(link.AskWaiting), q.Of(link.AskInFlight), q.Of(link.AskDead)
|
||||
fmt.Fprintf(&b, "%s: %d waiting, %d in flight, %d dead\n", q.Seat, len(waiting), len(running), len(dead))
|
||||
ago := func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "asked at an unknown time"
|
||||
}
|
||||
return "asked " + now.Sub(t).Round(time.Second).String() + " ago"
|
||||
}
|
||||
what := func(a link.QueuedAsk) string {
|
||||
s := link.WithoutPassword(a.Repository)
|
||||
if a.Path != "" {
|
||||
s += " at " + a.Path
|
||||
}
|
||||
if a.Ref != "" {
|
||||
s += " on " + a.Ref
|
||||
}
|
||||
return s
|
||||
}
|
||||
letNext, letRunning := q.LetThroughOf(now)
|
||||
const passed = "waited past 30 minutes, let through"
|
||||
if q.HoldFault != "" {
|
||||
fmt.Fprintf(&b, "why checks wait cannot be read, so none is said held: %s\n", q.HoldFault)
|
||||
}
|
||||
if len(running) > 0 {
|
||||
fmt.Fprintln(&b, "\nin flight:")
|
||||
for _, a := range running {
|
||||
where := "taken, not yet said where"
|
||||
switch {
|
||||
case a.On != "":
|
||||
where = fmt.Sprintf("on %s for %s", a.On, now.Sub(a.Started).Round(time.Second))
|
||||
// Started again after an earlier taker left it (novox/hq issue 594).
|
||||
if a.Delivery > 1 {
|
||||
where += fmt.Sprintf(", handed out %d times", a.Delivery)
|
||||
}
|
||||
case a.Was != "":
|
||||
where = fmt.Sprintf("handed back by %s, to be handed out again", a.Was)
|
||||
}
|
||||
note := ""
|
||||
if a.ID != "" && a.ID == letRunning {
|
||||
note = " — " + passed
|
||||
}
|
||||
fmt.Fprintf(&b, " %-26s %-8s %s — %s, %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), where,
|
||||
ago(a.AskedAt), a.Seq, note)
|
||||
}
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
fmt.Fprintln(&b, "\nwaiting, in the order they are handed out:")
|
||||
// **Why the checks wait** (ADR 0483 clause 5): above them, the plans they are held for.
|
||||
if len(q.Hold.Plans) > 0 && hasCheck(waiting) {
|
||||
fmt.Fprintf(&b, " checks are held while delivery %s builds; the oldest check that waited 30 minutes goes first, one at a time\n",
|
||||
strings.Join(q.Hold.Plans, ", "))
|
||||
}
|
||||
for _, a := range waiting {
|
||||
note := ""
|
||||
if a.ID != "" && a.ID == letNext {
|
||||
note = " — " + passed + " next"
|
||||
}
|
||||
fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)%s\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq, note)
|
||||
}
|
||||
}
|
||||
if len(dead) > 0 {
|
||||
fmt.Fprintf(&b, "\ndead — handed out as often as the worker allows (%d) and never settled, still held:\n", q.MaxDeliver)
|
||||
for _, a := range dead {
|
||||
fmt.Fprintf(&b, " %-26s %-8s %s — %s (seq %d)\n", a.ID, link.KindOf(a.Request), what(a), ago(a.AskedAt), a.Seq)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(q.Asks) == 0:
|
||||
fmt.Fprintln(&b, "nothing is asked of it")
|
||||
default:
|
||||
fmt.Fprintln(&b, "\n`cancel <id>` drops a waiting or dead ask, `clear` every waiting one, `kill <id>` ends one in flight")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// hasCheck says a check is among the asks.
|
||||
func hasCheck(asks []link.QueuedAsk) bool {
|
||||
for _, a := range asks {
|
||||
if a.Request.Check != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cancelCommand drops one waiting or dead ask.
|
||||
func cancelCommand(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("cancel <build id>")
|
||||
}
|
||||
js, err := dialTheBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
seat := buildSeatHeld(ctx)
|
||||
q, err := link.ReadQueue(ctx, js, seat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ask, found := q.Find(args[0])
|
||||
if !found {
|
||||
return fmt.Errorf("%s is not in the %s queue: it was built, cancelled, or never asked — `builds` says "+
|
||||
"what came of it", args[0], seat)
|
||||
}
|
||||
said, err := cancelAsk(ctx, js, open, seat, ask)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(said)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelAsk drops one ask from the queue and records it failed, cancelled by hand.
|
||||
//
|
||||
// **In this order, and each step for a reason** (novox/hq ADR 0219):
|
||||
// 1. an ask a machine says it is building is refused: deleting its message ends nothing a machine
|
||||
// is running — that is `kill`, on the machine running it;
|
||||
// 2. its id goes into the seat's cancelled set, so a holder that fetches it from now on ends it;
|
||||
// 3. for a waiting ask, the worker is read again: one handed out since the queue was read was
|
||||
// taken in the moment of cancelling, and the cancel is withdrawn and refused — the holder either
|
||||
// read the mark first and ends it as cancelled, or is building it;
|
||||
// 4. for an ask the worker counts handed out that no machine is building — taken and not yet said,
|
||||
// handed back after a restart, or past its deliveries while nobody pulls — the holder's own look
|
||||
// at the set is waited out, and a start heard since withdraws and refuses the cancel: a holder
|
||||
// that took it before the mark is building it, and only `kill` ends that;
|
||||
// 5. the message is deleted by its sequence;
|
||||
// 6. the failed outcome is taken in as any failed build's is, and the plan that asked fails.
|
||||
func cancelAsk(ctx context.Context, js *broker.JetStream, open *stores, seat string, ask link.QueuedAsk) (string, error) {
|
||||
if ask.State == link.AskInFlight && ask.On != "" {
|
||||
return "", fmt.Errorf("%s is in flight on %s: cancelling drops an ask nobody is building. `kill %s` "+
|
||||
"ends the build where it runs", ask.ID, ask.On, ask.ID)
|
||||
}
|
||||
if err := link.MarkCancelled(ctx, js, seat, ask.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
withdraw := func() {
|
||||
if err := link.UnmarkCancelled(ctx, js, seat, ask.ID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "could not withdraw the cancel of %s: %v — a holder taking it ends it as cancelled\n", ask.ID, err)
|
||||
}
|
||||
}
|
||||
// The worker of the queue it waits in: the seat's own, or one of the two check queues' (novox/hq ADR 0406 §3).
|
||||
worker := link.WorkerOf(seat, ask)
|
||||
switch ask.State {
|
||||
case link.AskWaiting:
|
||||
if taken, err := takenSince(js, worker, ask.Seq); err != nil {
|
||||
withdraw()
|
||||
return "", err
|
||||
} else if taken {
|
||||
withdraw()
|
||||
return "", fmt.Errorf("%s was taken by a holder as it was cancelled, so the cancel is withdrawn. If the "+
|
||||
"holder read it first it ends the ask as %s and its outcome says so; otherwise it is building — "+
|
||||
"`queue` says which, and `kill %s` ends it", ask.ID, link.CancelledByHand, ask.ID)
|
||||
}
|
||||
case link.AskInFlight:
|
||||
if started, err := startedSince(ctx, js, seat, ask); err != nil {
|
||||
withdraw()
|
||||
return "", err
|
||||
} else if started != "" {
|
||||
withdraw()
|
||||
return "", fmt.Errorf("%s has started on %s since it was read, so the cancel is withdrawn: `kill %s` "+
|
||||
"ends it there", ask.ID, started, ask.ID)
|
||||
}
|
||||
}
|
||||
removed, err := link.DeleteAsk(ctx, js, seat, ask)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
// A check ended because its pull request has a newer head records nothing (novox/hq issue 726): open is nil.
|
||||
if open != nil {
|
||||
recordCancelled(ctx, open, ask)
|
||||
}
|
||||
return cancelSaid(ask, seat, removed), nil
|
||||
}
|
||||
|
||||
// cancelSaid is what a cancel says: deleted from the queue only when it was.
|
||||
func cancelSaid(ask link.QueuedAsk, seat string, removed uint64) string {
|
||||
where := fmt.Sprintf("deleted from the %s queue", seat)
|
||||
if removed == 0 {
|
||||
where = fmt.Sprintf("no longer in the %s queue to delete, and marked cancelled, so a holder taking it "+
|
||||
"ends it", seat)
|
||||
}
|
||||
return fmt.Sprintf("cancelled %s (%s, %s): %s and recorded failed, %s — a plan "+
|
||||
"that asked for it fails with that", ask.ID, ask.Repository, ask.State, where, link.CancelledByHand)
|
||||
}
|
||||
|
||||
// holderLooks is how long a cancel waits for a holder that took the ask before the mark to say it
|
||||
// started: longer than a holder's look at the cancelled set (3s) and its start that follows.
|
||||
var holderLooks = 5 * time.Second
|
||||
|
||||
// startedSince waits out a holder's look at the cancelled set and says the machine that started the
|
||||
// ask since it was read, or nothing. A start it ended as cancelled comes with its outcome and is not
|
||||
// a start of a build.
|
||||
func startedSince(ctx context.Context, js *broker.JetStream, seat string, ask link.QueuedAsk) (string, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(holderLooks):
|
||||
}
|
||||
since := time.Now().Add(-7 * 24 * time.Hour)
|
||||
if !ask.AskedAt.IsZero() {
|
||||
since = ask.AskedAt.Add(-time.Minute)
|
||||
}
|
||||
heard, err := link.ReadBuildEvents(ctx, js, seat, since)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s, ok := heard.Started[ask.ID]
|
||||
if !ok || heard.Outcomes[ask.ID] {
|
||||
return "", nil
|
||||
}
|
||||
at, _ := time.Parse(time.RFC3339Nano, s.At)
|
||||
if ask.Started.IsZero() || at.After(ask.Started) {
|
||||
return s.On, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// takenSince is whether the worker has handed out the ask at this sequence.
|
||||
func takenSince(js *broker.JetStream, worker broker.Consumer, seq uint64) (bool, error) {
|
||||
info, err := js.Context().ConsumerInfo(worker.Stream, worker.Name)
|
||||
if errors.Is(err, nats.ErrConsumerNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot read the worker of the queue again: %w", err)
|
||||
}
|
||||
return info.Delivered.Stream >= seq, nil
|
||||
}
|
||||
|
||||
// recordCancelled takes the failed outcome in the way the daemon takes in any build's: recorded,
|
||||
// then the plan that asked for it — by the id it asked with, or by repository and path.
|
||||
func recordCancelled(ctx context.Context, open *stores, ask link.QueuedAsk) {
|
||||
r := ask.Request
|
||||
result := link.BuildResult{ID: ask.ID, Repository: ask.Repository, Path: ask.Path, Ref: ask.Ref,
|
||||
Source: r.Source, DryRun: r.DryRun, Failed: link.CancelledByHand}
|
||||
_ = builds{open.inventory, open}.Built(ctx, result)
|
||||
}
|
||||
|
||||
// clearCommand cancels every waiting ask, and with --dead every dead one too.
|
||||
func clearCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("clear", flag.ContinueOnError)
|
||||
dead := set.Bool("dead", false, "the dead asks too")
|
||||
if _, err := parseAround(set, args); err != nil {
|
||||
return err
|
||||
}
|
||||
js, err := dialTheBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
seat := buildSeatHeld(ctx)
|
||||
said, err := clearQueue(ctx, js, open, seat, *dead)
|
||||
fmt.Print(said)
|
||||
return err
|
||||
}
|
||||
|
||||
// clearQueue cancels what clear names, each as `cancel` would, and never anything in flight.
|
||||
func clearQueue(ctx context.Context, js *broker.JetStream, open *stores, seat string, dead bool) (string, error) {
|
||||
q, err := link.ReadQueue(ctx, js, seat)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b strings.Builder
|
||||
cancelled, refused := 0, 0
|
||||
for _, a := range q.Asks {
|
||||
if a.State == link.AskInFlight || (a.State == link.AskDead && !dead) {
|
||||
continue
|
||||
}
|
||||
said, err := cancelAsk(ctx, js, open, seat, a)
|
||||
if err != nil {
|
||||
refused++
|
||||
fmt.Fprintf(&b, " %s: %v\n", a.ID, err)
|
||||
continue
|
||||
}
|
||||
cancelled++
|
||||
fmt.Fprintf(&b, " %s\n", said)
|
||||
}
|
||||
what := "waiting"
|
||||
if dead {
|
||||
what = "waiting and dead"
|
||||
}
|
||||
fmt.Fprintf(&b, "%d %s ask(s) cancelled", cancelled, what)
|
||||
if refused > 0 {
|
||||
fmt.Fprintf(&b, ", %d could not be", refused)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
if n := len(q.Of(link.AskInFlight)); n > 0 {
|
||||
fmt.Fprintf(&b, "%d in flight, left running: `kill <id>` ends one where it runs\n", n)
|
||||
}
|
||||
if n := len(q.Of(link.AskDead)); n > 0 && !dead {
|
||||
fmt.Fprintf(&b, "%d dead, left: `clear --dead` cancels them too\n", n)
|
||||
}
|
||||
if refused > 0 {
|
||||
return b.String(), fmt.Errorf("%d ask(s) could not be cancelled", refused)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// sourceOfBuild is where a recorded build's repository is asked from: the catalogued module's own
|
||||
// source when the build is of one — on its seat, so the outcome registers it as the mesh records it
|
||||
// (ADR 0111) — else the repository as it was cloned.
|
||||
func sourceOfBuild(b inventory.Build, entries []inventory.Entry) (buildSource, string) {
|
||||
for _, e := range entries {
|
||||
if (b.Module != "" && e.Manifest.Module == b.Module) ||
|
||||
(b.Module == "" && repositoryMatches(e.Source.Repository, b.Repository) && e.Source.Path == b.Path) {
|
||||
return buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}, e.Manifest.Module
|
||||
}
|
||||
}
|
||||
return buildSource{Repository: b.Repository}, b.Module
|
||||
}
|
||||
|
||||
// replayCommand asks a recorded build's repository and path again at the commit it built.
|
||||
func replayCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("replay", flag.ContinueOnError)
|
||||
register := set.Bool("register", false, "removed (novox/hq ADR 0501 §5): refused by name")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// **Registering a replay goes with the asks** (novox/hq ADR 0429 §18, ADR 0501 §5): refused by name.
|
||||
if *register {
|
||||
return removedByName("replay --register")
|
||||
}
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("replay <build id>")
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
b, found, err := inv.BuildByID(ctx, positionals[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("no build %s is recorded", positionals[0])
|
||||
}
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, module := sourceOfBuild(b, entries)
|
||||
if b.Commit == "" {
|
||||
return fmt.Errorf("%s recorded no commit — it failed before it knew what it was building, so there is "+
|
||||
"nothing to replay", b.ID)
|
||||
}
|
||||
id, err := buildOneAsked(ctx, source, b.Path, b.Commit, 0, true, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(replaySaid(b, module, id, false))
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaySaid is what a replay prints once asked: what it builds, and what becomes of the outcome.
|
||||
func replaySaid(b inventory.Build, module, id string, register bool) string {
|
||||
what := b.Repository
|
||||
if module != "" {
|
||||
what = module
|
||||
}
|
||||
said := fmt.Sprintf("replaying %s (%s) at %s as %s", b.ID, what, short(b.Commit), id)
|
||||
if !register {
|
||||
return said + "\n a dry run: the outcome is looked at and not taken in — nothing is recorded or " +
|
||||
"registered, and nothing is sent. `builds --log " + id + "` follows it; `--register` registers it"
|
||||
}
|
||||
return said + "\n registered when it is built, as the module's current version — newer than every build " +
|
||||
"asked before now — and rolled out as its policy says. `builds --log " + id + "` follows it"
|
||||
}
|
||||
|
||||
// killCommand finds the machine running a build and asks its holder to end it.
|
||||
func killCommand(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("kill <build id>")
|
||||
}
|
||||
id := args[0]
|
||||
js, err := dialTheBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
seat := buildSeatHeld(ctx)
|
||||
since := time.Now().Add(-7 * 24 * time.Hour)
|
||||
if at, ok := link.BuildAskedAt(id); ok {
|
||||
since = at.Add(-time.Minute)
|
||||
}
|
||||
heard, err := link.ReadBuildEvents(ctx, js, seat, since)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
started, ok := heard.Started[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("no machine has said it started %s: if it waits in the queue, `cancel %s` drops it", id, id)
|
||||
}
|
||||
if heard.Outcomes[id] {
|
||||
return fmt.Errorf("%s has already ended on %s — `builds` says how", id, started.On)
|
||||
}
|
||||
answer, err := link.AskSeatTool(ctx, js.Conn(), seat, "kill", started.On, map[string]string{"id": id}, killAnswer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printHolderAnswer(started.On, answer)
|
||||
}
|
||||
|
||||
// pauseCommand asks one machine's holder, or every holder's, to pause or resume.
|
||||
func pauseCommand(ctx context.Context, verb string, args []string) error {
|
||||
if len(args) > 1 {
|
||||
return fmt.Errorf("%s [node]", verb)
|
||||
}
|
||||
js, err := dialTheBus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer js.Close()
|
||||
seat := buildSeatHeld(ctx)
|
||||
nodes := args
|
||||
if len(nodes) == 0 {
|
||||
if nodes, err = buildSeatHolders(ctx, seat); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return fmt.Errorf("nothing holds %s, so there is nothing to %s", seat, verb)
|
||||
}
|
||||
}
|
||||
failed := 0
|
||||
for _, node := range nodes {
|
||||
answer, err := link.AskSeatTool(ctx, js.Conn(), seat, verb, node, map[string]string{}, holderAsks)
|
||||
if err != nil {
|
||||
fmt.Printf("%s: %v\n", node, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if err := printHolderAnswer(node, answer); err != nil {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d of %d machine(s) did not %s", failed, len(nodes), verb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// printHolderAnswer prints what a holder said, or its refusal as the command's failure.
|
||||
func printHolderAnswer(node string, answer link.Answer) error {
|
||||
if answer.Error != "" {
|
||||
fmt.Printf("%s: %s\n", node, answer.Error)
|
||||
return errors.New(answer.Error)
|
||||
}
|
||||
var said struct {
|
||||
Said string `json:"said"`
|
||||
}
|
||||
if json.Unmarshal(answer.Result, &said) == nil && said.Said != "" {
|
||||
fmt.Printf("%s: %s\n", node, said.Said)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%s: %s\n", node, string(answer.Result))
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSeatHolders is every machine an assigned module holding the build seat runs on.
|
||||
func buildSeatHolders(ctx context.Context, seat string) ([]string, error) {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer open.Close()
|
||||
entries, err := open.inventory.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return holdersAmong(entries, seat), nil
|
||||
}
|
||||
|
||||
// holdersAmong is the machines of every catalogued module claiming the seat, sorted, once each.
|
||||
func holdersAmong(entries []inventory.Entry, seat string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !e.Manifest.ClaimsSeat(seat) {
|
||||
continue
|
||||
}
|
||||
for _, n := range e.On {
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The queue answers as data as well as text (novox/hq issue 455): each ask's repository, commit, kind,
|
||||
// machine, since and position among the waiting, read from the same queue the table is written from.
|
||||
|
||||
const aCommit = "0123456789abcdef0123456789abcdef01234567"
|
||||
|
||||
func aQueueOfEachKind(now time.Time) link.Queue {
|
||||
asked := now.Add(-5 * time.Minute)
|
||||
started := now.Add(-2 * time.Minute)
|
||||
return link.Queue{Seat: link.TheBuildMachine, Delivered: 2, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{
|
||||
{Seq: 1, ID: "build-dead", Repository: "ssh://forge/novox/x", AskedAt: asked, State: link.AskDead},
|
||||
{Seq: 2, ID: "build-running", Repository: "ssh://forge/novox/mesh-host", Ref: aCommit, AskedAt: asked,
|
||||
State: link.AskInFlight, On: "ace", Started: started,
|
||||
Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-host"}}},
|
||||
{Seq: 3, ID: "build-first", Repository: "ssh://forge/novox/mesh-catalog", Ref: "main", AskedAt: asked,
|
||||
State: link.AskWaiting, Request: link.BuildRequest{ID: "build-first", Repository: "ssh://forge/novox/mesh-catalog"}},
|
||||
{Seq: 4, ID: "build-second", Repository: "ssh://forge/novox/mesh-catalog", Path: "modules/gitea", Ref: aCommit,
|
||||
AskedAt: now.Add(-time.Minute), State: link.AskWaiting,
|
||||
Request: link.BuildRequest{ID: "build-second", Check: &link.CheckRequest{Owner: "novox", Repo: "mesh-catalog"}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestTheQueueVerbAnswersEachAskAsData(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
|
||||
q := aQueueOfEachKind(now)
|
||||
got := queueAnswerOf(q, now)
|
||||
if got.Waiting != 2 || got.InFlight != 1 || got.Dead != 1 || got.Seat != link.TheBuildMachine {
|
||||
t.Fatalf("counts: %+v", got)
|
||||
}
|
||||
if got.Text != queueText(q, now) {
|
||||
t.Fatalf("the data's text is not the table read from the same queue:\n%s", got.Text)
|
||||
}
|
||||
byID := map[string]queuedAnswer{}
|
||||
for _, a := range got.Asks {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
running := byID["build-running"]
|
||||
if running.Kind != "check" || running.Machine != "ace" || running.Commit != aCommit || running.Position != 0 ||
|
||||
!running.Since.Equal(now.Add(-2*time.Minute)) || running.State != link.AskInFlight {
|
||||
t.Errorf("in flight: %+v", running)
|
||||
}
|
||||
first, second := byID["build-first"], byID["build-second"]
|
||||
if first.Position != 1 || first.Kind != "delivery" || first.Commit != "" || first.Ref != "main" || first.Machine != "" ||
|
||||
!first.Since.Equal(now.Add(-5*time.Minute)) {
|
||||
t.Errorf("first waiting: %+v", first)
|
||||
}
|
||||
if second.Position != 2 || second.Kind != "check" || second.Commit != aCommit || second.Path != "modules/gitea" ||
|
||||
second.Repository != "ssh://forge/novox/mesh-catalog" {
|
||||
t.Errorf("second waiting: %+v", second)
|
||||
}
|
||||
if dead := byID["build-dead"]; dead.Position != 0 || dead.State != link.AskDead {
|
||||
t.Errorf("dead: %+v", dead)
|
||||
}
|
||||
// And the table says each ask's kind too (novox/hq issue 558).
|
||||
for _, line := range []string{"build-running check ", "build-first delivery ",
|
||||
"build-second check ", "build-dead check "} {
|
||||
if !strings.Contains(got.Text, line) {
|
||||
t.Errorf("the table does not say %q:\n%s", line, got.Text)
|
||||
}
|
||||
}
|
||||
// In the table's order: in flight, then the waiting by position, then the dead.
|
||||
var order []string
|
||||
for _, a := range got.Asks {
|
||||
order = append(order, a.ID)
|
||||
}
|
||||
if want := []string{"build-running", "build-first", "build-second", "build-dead"}; !reflect.DeepEqual(order, want) {
|
||||
t.Errorf("order %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The verb asks its command for JSON, and its answer carries the data while its output stays the table a
|
||||
// person reads, so a text caller reads what it always did.
|
||||
func TestTheQueueVerbKeepsTheTableAndCarriesTheData(t *testing.T) {
|
||||
argv, err := argvFor("queue", nil)
|
||||
if err != nil || !reflect.DeepEqual(argv, []string{"queue", "--json"}) {
|
||||
t.Fatalf("queue's command line: %v %v", argv, err)
|
||||
}
|
||||
now := time.Now()
|
||||
q := aQueueOfEachKind(now)
|
||||
body, err := json.Marshal(queueAnswerOf(q, now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answer := answerOf(argv, body, "", true)
|
||||
if answer.Output != queueText(q, now) {
|
||||
t.Fatalf("the output is not the table:\n%s", answer.Output)
|
||||
}
|
||||
data, ok := answer.Answer.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("no data: %#v", answer.Answer)
|
||||
}
|
||||
if _, doubled := data["text"]; doubled {
|
||||
t.Error("the table is said twice: once as the output and again inside the data")
|
||||
}
|
||||
asks, _ := data["asks"].([]any)
|
||||
if len(asks) != 4 || data["waiting"] != float64(2) {
|
||||
t.Fatalf("data: %v", data)
|
||||
}
|
||||
second := asks[2].(map[string]any)
|
||||
if second["position"] != float64(2) || second["kind"] != "check" || second["commit"] != aCommit {
|
||||
t.Errorf("second waiting: %v", second)
|
||||
}
|
||||
for _, a := range asks {
|
||||
if _, held := a.(map[string]any)["held"]; held {
|
||||
t.Error("an ask carries what the mesh holds")
|
||||
}
|
||||
}
|
||||
// A failure is said as text, as before.
|
||||
failed := answerOf(argv, nil, "mesh-controller: the bus cannot be reached\n", false)
|
||||
if failed.Answer != nil || !strings.Contains(failed.Output, "cannot be reached") {
|
||||
t.Errorf("a failure: %+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
// A repository named with a password never shows it, in the table or the data.
|
||||
func TestTheQueueNeverShowsARepositorysPassword(t *testing.T) {
|
||||
if got := link.WithoutPassword("https://agent:s3cret@forge.example/novox/x.git"); strings.Contains(got, "s3cret") ||
|
||||
!strings.Contains(got, "agent@forge.example/novox/x.git") {
|
||||
t.Errorf("redacted: %s", got)
|
||||
}
|
||||
now := time.Now()
|
||||
q := link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{{Seq: 1, ID: "build-1",
|
||||
Repository: "https://agent:s3cret@forge.example/novox/x.git", State: link.AskWaiting}}}
|
||||
body, _ := json.Marshal(queueAnswerOf(q, now))
|
||||
if strings.Contains(string(body), "s3cret") {
|
||||
t.Errorf("the queue said a password: %s", body)
|
||||
}
|
||||
for _, kept := range []string{"ssh://git@forge:222/novox/x.git", "novox/x", "git@forge:novox/x.git", ""} {
|
||||
if got := link.WithoutPassword(kept); got != kept {
|
||||
t.Errorf("%q became %q", kept, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A cancel says it deleted the ask only when it did: moved into order and gone since, it says so (novox/hq
|
||||
// issue 558).
|
||||
func TestACancelSaysDeletedOnlyWhenItDeleted(t *testing.T) {
|
||||
ask := link.QueuedAsk{ID: "build-1", Repository: "novox/x", State: link.AskWaiting, Seq: 4}
|
||||
if said := cancelSaid(ask, link.TheBuildMachine, 9); !strings.Contains(said, "deleted from the") {
|
||||
t.Errorf("removed, it says: %s", said)
|
||||
}
|
||||
if said := cancelSaid(ask, link.TheBuildMachine, 0); strings.Contains(said, "deleted from") ||
|
||||
!strings.Contains(said, "no longer in the") {
|
||||
t.Errorf("gone already, it says: %s", said)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// Issue 1688 / 1652 AC5 (ADR 0483 clause 5): the queue names, above the waiting checks, the plan they are held for, and says of a
|
||||
// check let through that it waited past 30 minutes.
|
||||
|
||||
func aHeldQueue(now time.Time) link.Queue {
|
||||
check := func(id string, ago time.Duration, n int) link.QueuedAsk {
|
||||
return link.QueuedAsk{Seq: uint64(n), ID: id, Repository: "ssh://forge/novox/r", AskedAt: now.Add(-ago), State: link.AskWaiting,
|
||||
Request: link.BuildRequest{ID: id, Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: n}}}
|
||||
}
|
||||
return link.Queue{Seat: link.TheBuildMachine, Asks: []link.QueuedAsk{
|
||||
check("build-young", 5*time.Minute, 1),
|
||||
check("build-old", 45*time.Minute, 2),
|
||||
check("build-older", 50*time.Minute, 3),
|
||||
}, Hold: link.CheckHold{Plans: []string{"plan-42"}}}
|
||||
}
|
||||
|
||||
func TestTheQueueNamesThePlanTheChecksAreHeldFor(t *testing.T) {
|
||||
now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||
text := queueText(aHeldQueue(now), now)
|
||||
heldAt := strings.Index(text, "held while delivery plan-42 builds")
|
||||
firstAt := strings.Index(text, "build-young")
|
||||
if heldAt < 0 || firstAt < 0 || heldAt > firstAt {
|
||||
t.Fatalf("the plan the checks are held for is not named above the waiting checks:\n%s", text)
|
||||
}
|
||||
// Nothing building: nothing held.
|
||||
q := aHeldQueue(now)
|
||||
q.Hold = link.CheckHold{}
|
||||
if strings.Contains(queueText(q, now), "held while") {
|
||||
t.Fatal("checks are said held with no delivery building")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACheckLetThroughIsSaidToHaveWaitedPastThirtyMinutes(t *testing.T) {
|
||||
now := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
|
||||
q := aHeldQueue(now)
|
||||
text := queueText(q, now)
|
||||
var saidOf []string
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if strings.Contains(l, "waited past 30 minutes, let through") {
|
||||
saidOf = append(saidOf, strings.Fields(l)[0])
|
||||
}
|
||||
}
|
||||
if len(saidOf) != 1 || saidOf[0] != "build-older" {
|
||||
t.Fatalf("the check let through next (the oldest) is not the one said: %v\n%s", saidOf, text)
|
||||
}
|
||||
answer := queueAnswerOf(q, now)
|
||||
if len(answer.HeldFor) != 1 || answer.HeldFor[0] != "plan-42" {
|
||||
t.Errorf("held-for = %v", answer.HeldFor)
|
||||
}
|
||||
for _, a := range answer.Asks {
|
||||
if a.LetThrough != (a.ID == "build-older") {
|
||||
t.Errorf("%s let-through = %v", a.ID, a.LetThrough)
|
||||
}
|
||||
}
|
||||
// A check in flight under the let-through key is said too.
|
||||
q.Asks = append(q.Asks, link.QueuedAsk{Seq: 9, ID: "build-running", State: link.AskInFlight, On: "ace", AskedAt: now.Add(-time.Hour),
|
||||
Started: now.Add(-time.Minute), Request: link.BuildRequest{ID: "build-running", Check: &link.CheckRequest{Owner: "novox", Repo: "r", Number: 9}}})
|
||||
q.Hold.LetThrough = &link.LetThrough{Builder: "ace"}
|
||||
if !strings.Contains(queueText(q, now), "build-running") || !strings.Contains(queueText(q, now), "waited past 30 minutes, let through\n") {
|
||||
t.Errorf("the check in flight let through is not said:\n%s", queueText(q, now))
|
||||
}
|
||||
// Younger than the bound: none is said.
|
||||
young := aHeldQueue(now)
|
||||
young.Asks = young.Asks[:1]
|
||||
if strings.Contains(queueText(young, now), "waited past") {
|
||||
t.Error("a check of 5 minutes is said to have waited past 30")
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
func TestAFailedPlanIsRetriedAndGoesOnThroughItsLaterTiers(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
asked := asksRecorded(t)
|
||||
twoTiers(t, open)
|
||||
before := time.Now().UTC().Add(-time.Hour)
|
||||
failed := inventory.Plan{ID: "plan-retry", Repository: "novox/a", Branch: "main", Commit: "c0ffee",
|
||||
Created: before, State: inventory.PlanFailed, Tier: 0, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Note: "a failed to build in tier 0",
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "failed", AskedAt: &before, Build: "build-1",
|
||||
Why: link.KilledByHand}}}
|
||||
if err := open.inventory.SavePlan(ctx, &failed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
said, err := retryPlan(ctx, open, failed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := open.inventory.PlanByID(ctx, failed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := p.Modules["a"]
|
||||
if p.State != inventory.PlanBuilding || a.State != "asked" || a.Build == "" || a.Build == "build-1" || a.Why != "" {
|
||||
t.Fatalf("after retry the plan is %s and a is %+v", p.State, a)
|
||||
}
|
||||
if !strings.Contains(said, a.Build) {
|
||||
t.Errorf("retry does not say the new id: %q", said)
|
||||
}
|
||||
if len(*asked) != 1 {
|
||||
t.Fatalf("asked %v", *asked)
|
||||
}
|
||||
|
||||
// The killed build's own late outcome is not this ask's; the new one's is, and tier 1 follows.
|
||||
planBuilt(ctx, open, "a", "c0ffee", link.KilledByHand, before, "build-1")
|
||||
if p, _ = open.inventory.PlanByID(ctx, failed.ID); p.State != inventory.PlanBuilding {
|
||||
t.Fatalf("the old ask's outcome failed the retried plan: %s %q", p.State, p.Note)
|
||||
}
|
||||
asking, _ := link.BuildAskedAt(a.Build)
|
||||
planBuilt(ctx, open, "a", "c0ffee", "", asking, a.Build)
|
||||
p, err = open.inventory.PlanByID(ctx, failed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Tier != 1 || p.Modules["b"] == nil || p.Modules["b"].State != "asked" || p.Modules["b"].Build == "" {
|
||||
t.Fatalf("the retried plan did not go on to tier 1: tier %d, %s, b %+v", p.Tier, p.State, p.Modules["b"])
|
||||
}
|
||||
if len(*asked) != 2 {
|
||||
t.Fatalf("asked %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
func aBuildQueue(t *testing.T) (*broker.JetStream, func(id, repository string) link.BuildRequest) {
|
||||
t.Helper()
|
||||
url := testbus.URL(t)
|
||||
t.Setenv(broker.NATSVar, url)
|
||||
js, err := broker.Dial(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(js.Close)
|
||||
seat := broker.DeclaredSeat{Name: link.TheBuildMachine, Accepts: []string{"build"},
|
||||
Emits: []string{"started", "built", "log.*", "paused.*"}}
|
||||
if err := broker.AssertMeshStreams(js); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT")
|
||||
if err := broker.RaiseSeats(js, []broker.DeclaredSeat{seat}, map[string]broker.Holder{
|
||||
link.TheBuildMachine: {Node: "anchor", Module: "build-agent"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := broker.RaiseCancelledSets(js, []broker.DeclaredSeat{seat}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = js.Context().DeleteStream("SEAT_NODE_BUILD_AGENT")
|
||||
_ = js.Context().DeleteKeyValue(broker.CancelledSetName(link.TheBuildMachine))
|
||||
_ = js.Context().PurgeStream(broker.EventsStream)
|
||||
})
|
||||
ask := func(id, repository string) link.BuildRequest {
|
||||
r := link.BuildRequest{ID: id, Repository: repository, Held: map[string]string{"x/y": "secret-ish"}}
|
||||
body, _ := json.Marshal(r)
|
||||
if _, err := js.Context().Publish(link.BuildWork(), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
return js, ask
|
||||
}
|
||||
|
||||
func TestCancelDeletesTheAskAndFailsThePlanThatAskedIt(t *testing.T) {
|
||||
js, ask := aBuildQueue(t)
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
twoTiers(t, open)
|
||||
id := link.NewBuildID(time.Now())
|
||||
ask(id, "https://forge.example/novox/a.git")
|
||||
asked, _ := link.BuildAskedAt(id)
|
||||
plan := inventory.Plan{ID: "plan-cancel", Repository: "novox/a", Commit: "c0ffee", Created: asked,
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: id}}}
|
||||
if err := open.inventory.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.Asks) != 1 || q.Asks[0].State != link.AskWaiting || q.Asks[0].ID != id {
|
||||
t.Fatalf("the queue reads %+v", q)
|
||||
}
|
||||
if text := queueText(q, time.Now()); !strings.Contains(text, "1 waiting, 0 in flight, 0 dead") ||
|
||||
strings.Contains(text, "secret-ish") {
|
||||
t.Errorf("the queue says:\n%s", text)
|
||||
}
|
||||
|
||||
if err := cancelCommand(ctx, []string{id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine); len(q.Asks) != 0 {
|
||||
t.Fatalf("the ask is still queued: %+v", q.Asks)
|
||||
}
|
||||
if cancelled, err := link.IsCancelled(js.Conn(), link.TheBuildMachine, id); err != nil || !cancelled {
|
||||
t.Errorf("the cancelled set does not hold it: %v %v", cancelled, err)
|
||||
}
|
||||
b, found, err := open.inventory.BuildByID(ctx, id)
|
||||
if err != nil || !found || b.Failed != link.CancelledByHand {
|
||||
t.Fatalf("the cancel is recorded as %+v (%v %v)", b, found, err)
|
||||
}
|
||||
p, err := open.inventory.PlanByID(ctx, plan.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.State != inventory.PlanFailed || p.Modules["a"].State != "failed" || p.Modules["a"].Why != link.CancelledByHand {
|
||||
t.Fatalf("the plan that asked is %s, a %+v", p.State, p.Modules["a"])
|
||||
}
|
||||
if err := cancelCommand(ctx, []string{id}); err == nil {
|
||||
t.Error("an ask cancelled already was cancelled again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCancelsTheWaitingAndTheDeadOnlyWhenTold(t *testing.T) {
|
||||
js, ask := aBuildQueue(t)
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
start := time.Now()
|
||||
dead := link.NewBuildID(start)
|
||||
ask(dead, "https://forge.example/novox/dead.git")
|
||||
deadOne(t, js)
|
||||
var waiting []string
|
||||
for i := 1; i <= 2; i++ {
|
||||
id := link.NewBuildID(start.Add(time.Duration(i) * time.Millisecond))
|
||||
waiting = append(waiting, id)
|
||||
ask(id, fmt.Sprintf("https://forge.example/novox/w%d.git", i))
|
||||
}
|
||||
|
||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.Of(link.AskDead)) != 1 || len(q.Of(link.AskWaiting)) != 2 || q.MaxDeliver != 5 {
|
||||
t.Fatalf("the queue reads %+v", q)
|
||||
}
|
||||
said, err := clearQueue(ctx, js, open, link.TheBuildMachine, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(said, "2 waiting ask(s) cancelled") || !strings.Contains(said, "1 dead, left") {
|
||||
t.Errorf("clear said:\n%s", said)
|
||||
}
|
||||
q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if len(q.Asks) != 1 || q.Asks[0].ID != dead || q.Asks[0].State != link.AskDead {
|
||||
t.Fatalf("after clear the queue is %+v", q.Asks)
|
||||
}
|
||||
if _, err := clearQueue(ctx, js, open, link.TheBuildMachine, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q, _ = link.ReadQueue(ctx, js, link.TheBuildMachine); len(q.Asks) != 0 {
|
||||
t.Fatalf("clear --dead left %+v", q.Asks)
|
||||
}
|
||||
for _, id := range append(waiting, dead) {
|
||||
if b, found, _ := open.inventory.BuildByID(ctx, id); !found || b.Failed != link.CancelledByHand {
|
||||
t.Errorf("%s is recorded as %+v", id, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelRefusesAnAskInFlight(t *testing.T) {
|
||||
js, ask := aBuildQueue(t)
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
id := link.NewBuildID(time.Now())
|
||||
ask(id, "https://forge.example/novox/a.git")
|
||||
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: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := q.Find(id)
|
||||
if a.State != link.AskInFlight || a.On != "ace" {
|
||||
t.Fatalf("the taken ask reads %+v", a)
|
||||
}
|
||||
_, err = cancelAsk(ctx, js, open, link.TheBuildMachine, a)
|
||||
if err == nil || !strings.Contains(err.Error(), "kill "+id) {
|
||||
t.Fatalf("an ask in flight was cancelled: %v", err)
|
||||
}
|
||||
if _, found, _ := open.inventory.BuildByID(ctx, id); found {
|
||||
t.Error("a refused cancel recorded an outcome")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKillAndPauseAskTheHolderOnTheMachine(t *testing.T) {
|
||||
js, _ := aBuildQueue(t)
|
||||
ctx := t.Context()
|
||||
asked := map[string]string{}
|
||||
handlers := map[string]link.ToolHandler{
|
||||
"kill": func(_ context.Context, raw json.RawMessage) (any, error) {
|
||||
var args struct{ ID string }
|
||||
_ = json.Unmarshal(raw, &args)
|
||||
asked["kill"] = args.ID
|
||||
if args.ID != "build-running" {
|
||||
return nil, fmt.Errorf("ace is not building %s", args.ID)
|
||||
}
|
||||
return map[string]any{"said": "killed " + args.ID}, nil
|
||||
},
|
||||
"pause": func(context.Context, json.RawMessage) (any, error) {
|
||||
asked["pause"] = "ace"
|
||||
return map[string]any{"said": "ace is paused"}, nil
|
||||
},
|
||||
}
|
||||
stop, err := link.OverNATS{Conn: js.Conn()}.ServeNodeSeatTools(link.TheBuildMachine, "ace", handlers, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stop()
|
||||
for _, id := range []string{"build-running", "build-other"} {
|
||||
started, _ := json.Marshal(link.BuildStart{ID: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
if _, err := js.Context().Publish(link.BuildStarted(), started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := killCommand(ctx, []string{"build-running"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asked["kill"] != "build-running" {
|
||||
t.Fatalf("the holder was asked %v", asked)
|
||||
}
|
||||
if err := killCommand(ctx, []string{"build-other"}); err == nil {
|
||||
t.Error("the holder's refusal was not the command's")
|
||||
}
|
||||
if err := killCommand(ctx, []string{"build-never"}); err == nil || !strings.Contains(err.Error(), "cancel build-never") {
|
||||
t.Errorf("a build nobody started: %v", err)
|
||||
}
|
||||
if err := pauseCommand(ctx, "pause", []string{"ace"}); err != nil || asked["pause"] != "ace" {
|
||||
t.Fatalf("pause: %v %v", err, asked)
|
||||
}
|
||||
if err := pauseCommand(ctx, "resume", []string{"g14"}); err == nil {
|
||||
t.Error("a machine nothing answers on was resumed")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
ctx := t.Context()
|
||||
asked := asksRecorded(t)
|
||||
twoTiers(t, open)
|
||||
var sentTo [][]string
|
||||
was := sendRollout
|
||||
sendRollout = func(_ context.Context, _ *stores, names []string) ([]string, error) {
|
||||
sentTo = append(sentTo, names)
|
||||
return names, nil
|
||||
}
|
||||
t.Cleanup(func() { sendRollout = was })
|
||||
|
||||
// a records: a person's choice, since the default rolls out (novox/hq ADR 0236).
|
||||
if err := open.inventory.SetUpgradeOf(ctx, "a", inventory.Upgrade{Why: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := time.Now().UTC().Add(-2 * time.Hour)
|
||||
stopped := inventory.Plan{ID: "plan-rollout", Repository: "novox/a", Branch: "main", Commit: "c0ffee",
|
||||
Created: long, State: inventory.PlanFailed, Tiers: [][]string{{"a"}, {"b"}},
|
||||
Note: "a stopped at its first machine in tier 0: laptop refused what it was sent",
|
||||
// Asked by the plan, as every module a plan holds built is (novox/hq issue 754).
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "built", AskedAt: &long, Build: "build-a", BuiltAt: &long,
|
||||
Commit: "c0ffee", First: []string{"laptop"}, FirstAt: &long, Why: "laptop refused what it was sent"}}}
|
||||
if err := open.inventory.SavePlan(ctx, &stopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A newer plan holding a refuses it: sending the older build would put it back.
|
||||
newer := inventory.Plan{ID: "plan-newer", Repository: "novox/other", Commit: "d00d", Created: long.Add(time.Hour),
|
||||
State: inventory.PlanDone, Tiers: [][]string{{"a"}}, Modules: map[string]*inventory.PlanModule{}}
|
||||
if err := open.inventory.SavePlan(ctx, &newer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := retryPlan(ctx, open, stopped.ID); err == nil || !strings.Contains(err.Error(), "plan-newer") {
|
||||
t.Fatalf("retried under a newer plan: %v", err)
|
||||
}
|
||||
newer.State = inventory.PlanSuperseded
|
||||
if err := open.inventory.SavePlan(ctx, &newer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
said, err := retryPlan(ctx, open, stopped.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sentTo) != 1 || !reflect.DeepEqual(sentTo[0], []string{"laptop"}) || !strings.Contains(said, "laptop") {
|
||||
t.Fatalf("sent %v; said %q", sentTo, said)
|
||||
}
|
||||
p, err := open.inventory.PlanByID(ctx, stopped.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := p.Modules["a"]
|
||||
if p.State != inventory.PlanRolling || a.FirstAt == nil || !a.FirstAt.After(long) || a.Why != "" {
|
||||
t.Fatalf("after retry the plan is %s, a %+v", p.State, a)
|
||||
}
|
||||
// And it goes on: a records (its policy sends nothing more), so the next tier is asked.
|
||||
advancePlans(ctx, open)
|
||||
if p, _ = open.inventory.PlanByID(ctx, stopped.ID); p.Tier != 1 || p.Modules["b"] == nil || p.Modules["b"].State != "asked" {
|
||||
t.Fatalf("the retried plan did not go on: tier %d %s %+v", p.Tier, p.State, p.Modules["b"])
|
||||
}
|
||||
if len(*asked) != 1 {
|
||||
t.Fatalf("asked %v", *asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanIsAnsweredOnlyByTheBuildItAskedFor(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
asked := time.Now().UTC().Add(-time.Minute)
|
||||
plan := inventory.Plan{ID: "plan-own", Repository: "novox/a", Commit: "c0ffee", Created: asked,
|
||||
State: inventory.PlanBuilding, Tiers: [][]string{{"a"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked, Build: "build-own"}}}
|
||||
if err := open.inventory.SavePlan(ctx, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planBuilt(ctx, open, "a", "0ldc0mm1t", "", time.Now().UTC(), "build-replay")
|
||||
p, _ := open.inventory.PlanByID(ctx, plan.ID)
|
||||
if p.Modules["a"].State != "asked" {
|
||||
t.Fatalf("a replay asked after the plan settled it: %+v", p.Modules["a"])
|
||||
}
|
||||
// From the records too: a later build of the module recorded is not the plan's.
|
||||
recorded := map[string][]inventory.Build{"a": {{ID: "build-replay", Commit: "0ldc0mm1t", Asked: time.Now(), At: time.Now()}}}
|
||||
if settleFromRecords(&p, p.Tiers[0], recorded, nil) {
|
||||
t.Fatalf("the records settled it with another build: %+v", p.Modules["a"])
|
||||
}
|
||||
planBuilt(ctx, open, "a", "c0ffee", "", asked, "build-own")
|
||||
if p, _ = open.inventory.PlanByID(ctx, plan.ID); p.Modules["a"].State != "built" || p.Modules["a"].Commit != "c0ffee" {
|
||||
t.Fatalf("its own build did not settle it: %+v", p.Modules["a"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelOfAnAskNobodySaidIsWithdrawnWhenItStarts(t *testing.T) {
|
||||
js, ask := aBuildQueue(t)
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
was := holderLooks
|
||||
holderLooks = 300 * time.Millisecond
|
||||
t.Cleanup(func() { holderLooks = was })
|
||||
id := link.NewBuildID(time.Now())
|
||||
ask(id, "https://forge.example/novox/a.git")
|
||||
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)
|
||||
}
|
||||
q, err := link.ReadQueue(ctx, js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := q.Find(id)
|
||||
if a.State != link.AskInFlight || a.On != "" {
|
||||
t.Fatalf("the taken ask reads %+v", a)
|
||||
}
|
||||
// The holder that took it says it started, while the cancel waits.
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
started, _ := json.Marshal(link.BuildStart{ID: id, On: "ace", At: time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
_, _ = js.Context().Publish(link.BuildStarted(), started)
|
||||
}()
|
||||
if _, err := cancelAsk(ctx, js, open, link.TheBuildMachine, a); err == nil || !strings.Contains(err.Error(), "started on ace") {
|
||||
t.Fatalf("a build that started was cancelled: %v", err)
|
||||
}
|
||||
if cancelled, _ := link.IsCancelled(js.Conn(), link.TheBuildMachine, id); cancelled {
|
||||
t.Error("the withdrawn cancel is still marked")
|
||||
}
|
||||
if _, found, _ := open.inventory.BuildByID(ctx, id); found {
|
||||
t.Error("a withdrawn cancel recorded an outcome")
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// The build queue, controlled by hand (novox/hq ADR 0219), and the plans that follow it.
|
||||
//
|
||||
// make postgres PG_PORT=55566 PG_CONTAINER=bq-pg
|
||||
// MESH_TEST_POSTGRES='postgres://postgres:check@127.0.0.1:55566/postgres?sslmode=disable' \
|
||||
// go test ./cmd/mesh-controller/ -run 'Queue|Cancel|Clear|Retry|Rebuild|Replay|Paused' (each test on a bus of its own)
|
||||
|
||||
// asksRecorded makes every ask a plan makes return the next id in a row, and says which were asked.
|
||||
func asksRecorded(t *testing.T) *[]string {
|
||||
t.Helper()
|
||||
asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): these tests are of what a walk does around it
|
||||
var asked []string
|
||||
was := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, path, ref string) (string, error) {
|
||||
id := link.NewBuildID(time.Now().Add(time.Duration(len(asked)) * time.Millisecond))
|
||||
asked = append(asked, source.Repository+"#"+id)
|
||||
return id, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = was })
|
||||
return &asked
|
||||
}
|
||||
|
||||
// twoTiers registers two modules, b standing on a, each with a source a plan asks.
|
||||
func twoTiers(t *testing.T, open *stores) {
|
||||
t.Helper()
|
||||
for _, name := range []string{"a", "b"} {
|
||||
if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: name, Version: "1"},
|
||||
inventory.Source{Repository: "novox/" + name, Seat: "git", Ref: "main", BuiltFrom: "c0ffee", Head: "c0ffee"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failed plan is retried: its failed module asked again under a new id, the plan building at that
|
||||
// tier, and when that build comes in the plan goes on and asks its next tier.
|
||||
|
||||
// What retry refuses, and says why.
|
||||
func TestRetryRefusesWhatItCannotResume(t *testing.T) {
|
||||
at := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
plan := func(id, state string, created time.Time) inventory.Plan {
|
||||
return inventory.Plan{ID: id, Repository: "novox/mesh-catalog", Branch: "main", Commit: id + "c0ffee",
|
||||
Created: created, State: state, Tiers: [][]string{{"a"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "failed"}}}
|
||||
}
|
||||
failed := plan("plan-1", inventory.PlanFailed, at)
|
||||
for _, c := range []struct {
|
||||
p inventory.Plan
|
||||
others []inventory.Plan
|
||||
says string
|
||||
}{
|
||||
{plan("plan-d", inventory.PlanDone, at), nil, "is done"},
|
||||
{plan("plan-s", inventory.PlanSuperseded, at), nil, "was"},
|
||||
{plan("plan-o", inventory.PlanBuilding, at), nil, "still building"},
|
||||
{failed, []inventory.Plan{plan("plan-2", inventory.PlanRolling, at.Add(time.Hour))}, "plan-2 supersedes it"},
|
||||
} {
|
||||
err := retryRefusal(c.p, c.others, ownPathOf(nil))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, wanted it to say %q", c.p.ID, err, c.says)
|
||||
}
|
||||
}
|
||||
stopped := failed
|
||||
stopped.Modules = map[string]*inventory.PlanModule{"a": {State: "built"}}
|
||||
stopped.Note = "a stopped at its first machine"
|
||||
if err := retryRefusal(stopped, nil, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "nothing in tier 0") {
|
||||
t.Errorf("a plan with nothing failed to build was retried: %v", err)
|
||||
}
|
||||
// A later failed plan does not supersede it; a later done one passes it (novox/hq ADR 0451).
|
||||
if err := retryRefusal(failed, []inventory.Plan{plan("plan-4", inventory.PlanFailed, at.Add(time.Hour))}, ownPathOf(nil)); err != nil {
|
||||
t.Errorf("refused for a plan that does not supersede it: %v", err)
|
||||
}
|
||||
if err := retryRefusal(failed, []inventory.Plan{plan("plan-5", inventory.PlanDone, at.Add(time.Hour))}, ownPathOf(nil)); err == nil ||
|
||||
!strings.Contains(err.Error(), "plan-5") {
|
||||
t.Errorf("a plan a later done plan passed was retried: %v", err)
|
||||
}
|
||||
// Another branch's open walk, or an older one, does not supersede it, and is one walk open: one at a time
|
||||
// (novox/hq ADR 0276).
|
||||
other := plan("plan-3", inventory.PlanBuilding, at.Add(time.Hour))
|
||||
other.Branch = "release"
|
||||
for _, open := range []inventory.Plan{other, plan("plan-0", inventory.PlanBuilding, at.Add(-time.Hour))} {
|
||||
if err := retryRefusal(failed, []inventory.Plan{open}, ownPathOf(nil)); err == nil || !strings.Contains(err.Error(), "one walk at a time") {
|
||||
t.Errorf("retried beside the open walk %s: %v", open.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `rebuild` of a module a failed plan holds joins that plan; one held by nothing runs alone.
|
||||
|
||||
// A plan waiting on builds of a seat whose every holder is paused says so and is not late; a seat
|
||||
// paused on some holders only is not a reason the plan is waiting.
|
||||
func TestAPlanWaitingOnAPausedSeatSaysSoAndIsNotLate(t *testing.T) {
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
asked := now.Add(-12 * time.Minute)
|
||||
p := inventory.Plan{ID: "plan-p", Repository: "novox/a", Commit: "c0ffee", State: inventory.PlanBuilding,
|
||||
Updated: now.Add(-2 * time.Hour), Tiers: [][]string{{"a"}},
|
||||
Modules: map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &asked}}}
|
||||
|
||||
all := pauseOf([]string{"g14", "ace"}, map[string]link.HolderState{"ace": {Paused: true}, "g14": {Paused: true}})
|
||||
line := planLineWith(p, now, all, tierAtLeast)
|
||||
if !strings.Contains(line, "waiting: the build seat is paused on ace, g14 (asked 12m0s ago)") || strings.Contains(line, "LATE") {
|
||||
t.Errorf("a plan on a paused seat reads %q", line)
|
||||
}
|
||||
if st := planStatuses([]inventory.Plan{p}, now, all, nil)[0]; st.Late || !strings.Contains(st.Waiting, "paused on ace, g14") {
|
||||
t.Errorf("status --json says %+v", st)
|
||||
}
|
||||
if _, late := openPlans([]inventory.Plan{p}, now, all, nil); late != 0 {
|
||||
t.Errorf("a plan waiting on a paused seat counted late")
|
||||
}
|
||||
|
||||
longAgo := now.Add(-25 * time.Hour)
|
||||
forgotten := p
|
||||
forgotten.Modules = map[string]*inventory.PlanModule{"a": {State: "asked", AskedAt: &longAgo}}
|
||||
if line := planLineWith(forgotten, now, all, tierAtLeast); !strings.Contains(line, "PAUSED OVER A DAY") || strings.Contains(line, "LATE") {
|
||||
t.Errorf("a plan paused over a day reads %q", line)
|
||||
}
|
||||
|
||||
some := pauseOf([]string{"g14", "ace"}, map[string]link.HolderState{"ace": {Paused: true}})
|
||||
if some.All || !reflect.DeepEqual(some.Nodes, []string{"ace"}) {
|
||||
t.Fatalf("%+v", some)
|
||||
}
|
||||
if line := planLineWith(p, now, some, tierAtLeast); !strings.Contains(line, "LATE") {
|
||||
t.Errorf("a seat paused on one holder of two made the plan not late: %q", line)
|
||||
}
|
||||
if st := planStatuses([]inventory.Plan{p}, now, some, nil)[0]; !st.Late {
|
||||
t.Errorf("status --json: %+v", st)
|
||||
}
|
||||
// A plan rolling out, or with nothing asked, is not waiting on the seat.
|
||||
rolling := p
|
||||
rolling.State = inventory.PlanRolling
|
||||
if _, paused := pausedWaiting(rolling, all, now); paused {
|
||||
t.Error("a rolling plan reads as waiting on the build seat")
|
||||
}
|
||||
}
|
||||
|
||||
// The queue's verbs are the controller seat's, each to the command it names.
|
||||
func TestTheQueueVerbsRunTheirCommands(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
verb string
|
||||
args map[string]any
|
||||
want []string
|
||||
}{
|
||||
{"queue", nil, []string{"queue", "--json"}},
|
||||
{"cancel", map[string]any{"id": "build-1"}, []string{"cancel", "build-1"}},
|
||||
{"clear", nil, []string{"clear"}},
|
||||
{"clear", map[string]any{"dead": "true"}, []string{"clear", "--dead"}},
|
||||
{"replay", map[string]any{"id": "build-1"}, []string{"replay", "build-1"}},
|
||||
{"kill", map[string]any{"id": "build-1"}, []string{"kill", "build-1"}},
|
||||
{"pause", nil, []string{"pause"}},
|
||||
{"resume", map[string]any{"node": "ace"}, []string{"resume", "ace"}},
|
||||
{"plans", map[string]any{"retry": "plan-1"}, []string{"plans", "retry", "plan-1"}},
|
||||
} {
|
||||
got, err := argvFor(c.verb, c.args)
|
||||
if err != nil || !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s %v: %v %v, want %v", c.verb, c.args, got, err, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := argvFor("cancel", nil); err == nil {
|
||||
t.Error("cancel without an id was taken")
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for _, v := range catalogue.ControllerVerbs {
|
||||
declared[v.Name] = true
|
||||
}
|
||||
for _, v := range []string{"queue", "cancel", "clear", "rebuild", "replay", "kill", "pause", "resume"} {
|
||||
if !declared[v] {
|
||||
t.Errorf("%s is not a verb of the controller seat", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- against a real bus -----------------------------------------------------------------------
|
||||
|
||||
// aBuildQueue is the build seat's queue, worker and cancelled set on a real server, the controller
|
||||
// pointed at it, and a function that asks the seat one build.
|
||||
|
||||
// deadOne takes the oldest ask from the worker and hands it back as often as the worker allows.
|
||||
func deadOne(t *testing.T, js *broker.JetStream) {
|
||||
t.Helper()
|
||||
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() }()
|
||||
for i := 0; i < worker.MaxDeliver; i++ {
|
||||
msgs, err := sub.Fetch(1, nats.MaxWait(3*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("delivery %d: %v", i+1, err)
|
||||
}
|
||||
_ = msgs[0].Nak()
|
||||
}
|
||||
// One more pull, as a holder always has one waiting: the server finds the ask past its deliveries
|
||||
// then, and stops counting it pending.
|
||||
if msgs, _ := sub.Fetch(1, nats.MaxWait(time.Second)); len(msgs) > 0 {
|
||||
t.Fatalf("an ask past its deliveries was delivered again")
|
||||
}
|
||||
}
|
||||
|
||||
// cancel drops a waiting ask from the bus and records it failed, cancelled by hand — and the plan
|
||||
// that asked for it, matched by the id, fails with it; an ask the plan's records name no module for
|
||||
// is still found.
|
||||
|
||||
// clear cancels every waiting ask and leaves the dead ones unless told, and never one in flight.
|
||||
|
||||
// An ask in flight is not cancelled: kill ends it where it runs.
|
||||
|
||||
// kill finds the machine from the build's start and asks that machine's holder; pause asks the
|
||||
// machine named. Each prints what the holder answered, and a refusal is the command's failure.
|
||||
|
||||
// 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.
|
||||
|
||||
// A plan module asked under an id is settled by that id's outcome alone: a replay or a rebuild beside
|
||||
// the plan, asked later, never answers it (novox/hq ADR 0219).
|
||||
|
||||
// rebuild of a build made at a commit asks what the module follows now, never the commit.
|
||||
|
||||
// An ask the worker counts out that no machine said it started is cancelled only if no start comes
|
||||
// while a holder looks: one that does withdraws the cancel, and kill is what ends it.
|
||||
@@ -252,7 +252,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
||||
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||
Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now(), asked.paused, asked.tierBounds),
|
||||
Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now(), asked.tierBounds),
|
||||
NotAnnounced: asked.notAnnounced, NotAnnouncedWhy: asked.notAnnouncedWhy}
|
||||
// In a stated order, so two readings of an unchanged mesh are the same document.
|
||||
untakenNodes := make([]string, 0, len(asked.untaken))
|
||||
|
||||
@@ -18,23 +18,6 @@ import (
|
||||
// given. Nothing recorded carries a port, so moving the store is a settings change and not a
|
||||
// rebuild of everything the mesh has ever built.
|
||||
|
||||
// recordedManifest is a build's manifest with the store's address taken off every reference the
|
||||
// build itself made. Other references — an image a module runs from a public registry — are what
|
||||
// they were, which is why this rewrites only what `made` names rather than everything that looks
|
||||
// like an address.
|
||||
func recordedManifest(raw json.RawMessage, made []inventory.Artifact) json.RawMessage {
|
||||
announced := map[string]bool{}
|
||||
for _, a := range made {
|
||||
announced[a.Reference] = true
|
||||
}
|
||||
return withReferences(raw, func(ref string) (string, bool) {
|
||||
if !announced[ref] {
|
||||
return ref, false
|
||||
}
|
||||
return catalogue.Recorded(ref), true
|
||||
})
|
||||
}
|
||||
|
||||
// routedManifest is a recorded manifest with the store's address, as this network reaches it now,
|
||||
// composed into every reference the build made — recorded either way, before or after references
|
||||
// were kept without their address.
|
||||
@@ -118,33 +101,3 @@ func whereTheStoreIs(ctx context.Context, inv *inventory.Inventory, forNode stri
|
||||
}
|
||||
return artifactStoreAddress(ctx, inv, shelf, forNode)
|
||||
}
|
||||
|
||||
// whereABuilderReachesTheStore is the store's address for the machine that builds: the network's
|
||||
// when there is one, else loopback on the store's own node — when that node also holds a module
|
||||
// requiring the store, which is what a builder is (genesis: one node holds both).
|
||||
func whereABuilderReachesTheStore(ctx context.Context, inv *inventory.Inventory) (string, error) {
|
||||
shelf, err := inv.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
holder, _, found, err := artifactStoreHolder(ctx, inv)
|
||||
if err != nil || !found {
|
||||
return "", err
|
||||
}
|
||||
assigned, err := inv.Assigned(ctx, holder)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
besideIt := false
|
||||
for _, a := range assigned {
|
||||
for _, r := range shelf[a].Requires {
|
||||
if r == catalogue.ArtifactStoreProvision {
|
||||
besideIt = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !besideIt {
|
||||
holder = ""
|
||||
}
|
||||
return artifactStoreAddress(ctx, inv, shelf, holder)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/chain"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
@@ -381,147 +379,6 @@ func askedInTier(p inventory.Plan) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// takesOutcome says whether a build's outcome is the plan's for a module of its current tier (novox/hq ADR 0219,
|
||||
// issues 219 and 754). **A walk that waits for its delivery's word asks nothing, and so is answered by nothing**
|
||||
// (ADR 0239): a rebuild that lands in the wait is somebody else's build. **Nor is a module the plan never asked
|
||||
// answered**: taken, the plan held a module built that it never asked, and waited for the rest of the tier for
|
||||
// ever. Asked under an id, the plan takes that id's outcome and no other; asked before ids were kept, a build
|
||||
// asked at or after the plan's ask.
|
||||
func takesOutcome(p inventory.Plan, state *inventory.PlanModule, asked time.Time, id string) bool {
|
||||
if p.Waiting() || state == nil || (state.Build == "" && state.AskedAt == nil) {
|
||||
return false
|
||||
}
|
||||
// **A module taken from the builder's key is answered by its key alone** (ADR 0501 §1): a build-agent outcome of it
|
||||
// is somebody else's build.
|
||||
if state.Key {
|
||||
return false
|
||||
}
|
||||
if state.Build != "" {
|
||||
return state.Build == id
|
||||
}
|
||||
return !askedBefore(asked, state.AskedAt)
|
||||
}
|
||||
|
||||
// askABuild is how a plan asks for one build, not waited for, and learns the id it asked under. A
|
||||
// variable so a test of what a plan does around an ask needs no build machine.
|
||||
//
|
||||
// Set in init, not where it is declared: an assignment pending on a build asks for one too (novox/hq issue
|
||||
// 325), and a build's outcome makes pending assignments, so the two refer to each other.
|
||||
var askABuild func(ctx context.Context, source buildSource, path, ref string) (string, error)
|
||||
|
||||
func init() {
|
||||
askABuild = func(ctx context.Context, source buildSource, path, ref string) (string, error) {
|
||||
// **Only the builder's own module is asked of build-agent** (novox/hq ADR 0453 §5, ADR 0498 §7; issue 1851,
|
||||
// A12): refused here, in the one function every ask goes through, whoever calls it.
|
||||
if err := onlyTheBuildersOwn(ctx, source, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Kept by each asker with its own name once the ask is made (novox/hq issue 325).
|
||||
return buildOneAsked(ctx, source, path, ref, 0, false, "")
|
||||
}
|
||||
}
|
||||
|
||||
// errNotTheBuildersOwn says an ask of build-agent was refused: from R4e build-agent builds the builder's own module
|
||||
// alone, and every other is taken from the builder's done key.
|
||||
var errNotTheBuildersOwn = errors.New("build-agent is asked for the builder's own module alone (ADR 0453 §5)")
|
||||
|
||||
// askedModuleOf is the module the catalogue builds from a source and path, empty when it builds none from it. A variable
|
||||
// so a test needs no store.
|
||||
var askedModuleOf = func(ctx context.Context, source buildSource, path string) (string, error) {
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer open.Close()
|
||||
entries, err := open.inventory.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
forms := newSourceForms(ctx, open.inventory)
|
||||
for _, e := range entries {
|
||||
if forms.same(e.Source.Repository, e.Source.Seat, source.Repository, source.Seat) &&
|
||||
strings.Trim(e.Source.Path, "/") == strings.Trim(path, "/") {
|
||||
return e.Manifest.Module, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// onlyTheBuildersOwn refuses an ask of any module but the builder's own, naming the key it is taken from instead.
|
||||
func onlyTheBuildersOwn(ctx context.Context, source buildSource, path string) error {
|
||||
module, err := askedModuleOf(ctx, source, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: which module %s at %q is cannot be read, so nothing is asked: %v", errNotTheBuildersOwn,
|
||||
source, orRoot(path), err)
|
||||
}
|
||||
if isBuildersOwn(module) {
|
||||
return nil
|
||||
}
|
||||
// **While no running builder says it writes references, build-agent builds every module** (issue 1886): judged by the
|
||||
// say the walk read when it has one, read here only for an ask that carries none.
|
||||
says, read := builderSayOf(ctx)
|
||||
if !read {
|
||||
var err error
|
||||
if says, err = builderSaysReferences(ctx); err != nil {
|
||||
says = true // not known: refused below, as before the say existed
|
||||
}
|
||||
}
|
||||
if !says {
|
||||
return nil
|
||||
}
|
||||
if module == "" {
|
||||
module = fmt.Sprintf("%s at %q", source, orRoot(path))
|
||||
}
|
||||
return fmt.Errorf("%w: %s is taken from the builder's done key of its fingerprint in %s (ADR 0501), and no build "+
|
||||
"of it is asked", errNotTheBuildersOwn, module, sdkdelivery.BuilderBuildsBucket)
|
||||
}
|
||||
|
||||
// pullsOfWalk are the pull requests of the merges a walk answers, each once: what its end lets go of (forgetChecksOfWalk)
|
||||
// and what a check of one was (same_program.go).
|
||||
func pullsOfWalk(p *inventory.Plan) []link.PullRef {
|
||||
if p.Delivery == nil {
|
||||
return nil
|
||||
}
|
||||
var out []link.PullRef
|
||||
seen := map[link.PullRef]bool{}
|
||||
for _, m := range p.Delivery.Merges {
|
||||
pull, ok := pullOf(m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !seen[pull] {
|
||||
seen[pull] = true
|
||||
out = append(out, pull)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkedBuildsOf are the builds the build seat's checks of those pull requests said they made for their merges,
|
||||
// within the week check builds are kept (novox/hq ADR 0350 §2): read from the seat's own outcomes on the bus. A
|
||||
// variable so a test needs no bus.
|
||||
var checkedBuildsOf = func(ctx context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
|
||||
if len(pulls) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
js, err := aBus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer js.Close()
|
||||
return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept))
|
||||
}
|
||||
|
||||
// pullOf is a merge's pull request: owner and repository, the last two parts of however the repository is spelled
|
||||
// (owner/repo, a path on the git seat, a URL), and its number. False when the merge names none.
|
||||
func pullOf(m inventory.PlanMerge) (link.PullRef, bool) {
|
||||
parts := strings.Split(strings.Trim(strings.TrimSuffix(m.Repository, ".git"), "/"), "/")
|
||||
if len(parts) < 2 || m.Number <= 0 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" {
|
||||
return link.PullRef{}, false
|
||||
}
|
||||
return link.PullRef{Owner: parts[len(parts)-2], Repo: parts[len(parts)-1], Number: m.Number}, true
|
||||
}
|
||||
|
||||
// askModule asks the build machine for one module of a plan and marks it asked, with the id it was
|
||||
// asked under (novox/hq ADR 0219) — or failed, with the plan, when it could not be asked.
|
||||
func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[string]inventory.Entry) {
|
||||
@@ -539,108 +396,9 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
|
||||
p.Note = name + " is no longer in the catalogue"
|
||||
return
|
||||
}
|
||||
// **Every module but the builder's own is taken from the builder's done key, asking no build** (novox/hq ADR 0503
|
||||
// §6, ADR 0501, ADR 0453 §5): the look settles it (settleFromKeys).
|
||||
// **Only while a running builder says it writes references** (issue 1886): until then the keys it writes could never
|
||||
// be delivered from, and the module is asked of build-agent as before R4e. A say not read leaves it unasked, said,
|
||||
// for the next look.
|
||||
if !isBuildersOwn(name) {
|
||||
says, err := builderSaysReferences(ctx)
|
||||
if err != nil {
|
||||
state.Why = "waits: whether the builder writes references cannot be read: " + firstLine(err.Error())
|
||||
return
|
||||
}
|
||||
if says {
|
||||
sayBuilding(ctx, p.ID, now)
|
||||
takeFromKey(p, state, e, now)
|
||||
return
|
||||
}
|
||||
// The ask's guard judges by this say, never by a second read that could fail or differ (review of issue 1886).
|
||||
ctx = withBuilderSay(ctx, says)
|
||||
}
|
||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||
fmt.Printf(" tier %d: ", p.Tier)
|
||||
// The branch it follows, never a commit a build once named (novox/hq 04-ISSUES/215): the branch contains every
|
||||
// commit a batch's walk carries — but for a merge walked alone after a failed walk (novox/hq ADR 0276
|
||||
// decision 3), built on its own commit to find which merge brought the failure.
|
||||
ref := followedBranch(e.Source.Ref)
|
||||
carried := p.CommitOn(e.Source.Repository, ref)
|
||||
if p.Delivery != nil && p.Delivery.Alone && carried != "" {
|
||||
ref = carried
|
||||
}
|
||||
// **The plan's key is there before its first build is** (ADR 0483 clause 2): a check taken between the ask and the plan's save
|
||||
// would hold a place for its whole run.
|
||||
sayBuilding(ctx, p.ID, now)
|
||||
id, err := askABuild(ctx, source, e.Source.Path, ref)
|
||||
if err != nil {
|
||||
// **A bus that does not answer for a moment is not a failed build** (issue 1334): the module stays unasked and the
|
||||
// tier's next look asks it again. A fault of the module's own fails the plan at once, as does a bus silent too long.
|
||||
reason := err.Error()
|
||||
if busDidNotAnswer(err) {
|
||||
if waitForTheBus(p, state, name, err, now) {
|
||||
return
|
||||
}
|
||||
reason = fmt.Sprintf("the bus did not answer an ask for %s (since %s): %s", busWaitBound,
|
||||
state.BusWaitFrom.UTC().Format(time.RFC3339), firstLine(err.Error()))
|
||||
}
|
||||
// The wait has ended: a Retry or a rebuild waits for the bus afresh.
|
||||
state.BusWaitFrom = nil
|
||||
state.State = "failed"
|
||||
state.Why = reason
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s could not be asked for: %s", name, reason)
|
||||
return
|
||||
}
|
||||
state.BusWaitFrom = nil
|
||||
// The commit of the module's own repository the walk carries (novox/hq ADR 0276): a batch's walk carries
|
||||
// one per repository.
|
||||
commit := carried
|
||||
if commit == "" {
|
||||
commit = p.Commit
|
||||
}
|
||||
recordAsked(ctx, inventory.BuildRequest{ID: id, Repository: e.Source.Repository, Seat: e.Source.Seat,
|
||||
Path: e.Source.Path, Ref: followedBranch(e.Source.Ref), Commit: commit, For: "plan"})
|
||||
state.State = "asked"
|
||||
state.AskedAt = &now
|
||||
state.Build = id
|
||||
state.Why, state.Commit, state.BuiltAt, state.Made = "", "", nil, ""
|
||||
}
|
||||
|
||||
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
||||
// advances what that completes. Called from the daemon's take-in of every outcome.
|
||||
//
|
||||
// **Only a build asked at or after the plan's ask is its outcome** (novox/hq 04-ISSUES/219). Two
|
||||
// plans a few minutes apart both ask for a module; the earlier plan's build, finishing late, is not
|
||||
// the later plan's answer — it stood on the bases from before the later plan's merge, and taking it
|
||||
// would send machines, and the next tier, what the later merge replaced. asked is zero when the
|
||||
// build's request time is not known, and such an outcome is taken as before.
|
||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id string) {
|
||||
planBuiltSaying(ctx, open, module, commit, failed, asked, id, "")
|
||||
}
|
||||
|
||||
// holdPlansForOutcome is how an outcome taken on the event loop holds the plans: **without waiting for them**
|
||||
// (novox/hq issue 1708). The loop takes one message at a time, and the plans' holder may keep them for minutes while
|
||||
// it sends machines their declarations; an outcome that waited its turn (up to inventory.HoldWaitFor, two minutes)
|
||||
// held every report, build and merge behind it for as long, and the watchdog said the controller stopped listening
|
||||
// (2026-10-21 14:20:45 to 14:22:45). Busy, the outcome is left to the build records, where it already is, and the
|
||||
// look settles the plan from them (leaveToRecords).
|
||||
func holdPlansForOutcome(ctx context.Context, hold func(context.Context, bool) (func(), error)) (func(), error) {
|
||||
return hold(ctx, false)
|
||||
}
|
||||
|
||||
// leaveToRecords says an outcome was not taken into the plans, which settle it from the build records (novox/hq issue
|
||||
// 214). When another act holds the plans it asks for the look, which waits for them off the event loop and settles
|
||||
// the plan from the records once they are let go (issue 1708); what the build did is kept for that settle by the
|
||||
// build's id, so the walk's module says it as it would have (ADR 0350 §4).
|
||||
func leaveToRecords(module, id, made, failed string, err error) {
|
||||
fmt.Printf("plans: %s's outcome is left to the build records: %v\n", module, err)
|
||||
if !errors.Is(err, inventory.ErrPlansBusy) {
|
||||
return
|
||||
}
|
||||
keepMadeFor(id, made, time.Now())
|
||||
// A refused outcome (failed, its build recorded as worked) is kept as refused, or the records settle it built.
|
||||
keepRefusalFor(id, failed, time.Now())
|
||||
askToLook()
|
||||
// **Every module is taken from the builder's done key, asking no build** (novox/hq ADR 0503 §6, ADR 0501, ADR 0453 §5;
|
||||
// issue 1851): the look settles it (settleFromKeys). A key not done yet waits, named in the plan, and never fails it.
|
||||
takeFromKey(p, state, e, now)
|
||||
}
|
||||
|
||||
// refusalsKeptFor is the refusals kept in this process for these builds (keepRefusalFor), by id.
|
||||
@@ -693,22 +451,6 @@ type madeKept struct {
|
||||
// madeLeftFor is how long words nobody settled are kept: a plan that ended or moved on never asks for them.
|
||||
const madeLeftFor = time.Hour
|
||||
|
||||
// keepMadeFor keeps what a build left to the records did for the settle that takes it, forgetting words older than
|
||||
// madeLeftFor.
|
||||
func keepMadeFor(id, made string, now time.Time) {
|
||||
if id == "" || made == "" {
|
||||
return
|
||||
}
|
||||
madeLeftMu.Lock()
|
||||
defer madeLeftMu.Unlock()
|
||||
for k, v := range madeLeft {
|
||||
if now.Sub(v.at) > madeLeftFor {
|
||||
delete(madeLeft, k)
|
||||
}
|
||||
}
|
||||
madeLeft[id] = madeKept{made: made, at: now}
|
||||
}
|
||||
|
||||
// takeMadeOf is what the build of this id did, if its outcome was left to the records with words; taken once.
|
||||
func takeMadeOf(id string) string {
|
||||
madeLeftMu.Lock()
|
||||
@@ -721,97 +463,6 @@ func takeMadeOf(id string) string {
|
||||
return k.made
|
||||
}
|
||||
|
||||
// planBuiltSaying is planBuilt keeping what the build did — "reused from <check build>" or "built: <why>" (novox/hq
|
||||
// ADR 0350 §4) — on the module of the walk it answers.
|
||||
func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id, made string) {
|
||||
inv := open.inventory
|
||||
// One controller works the plans at a time (novox/hq issue 213). Not taken, the outcome is still in the build
|
||||
// records, which the holder settles the plan from (issue 214).
|
||||
release, err := holdPlansForOutcome(ctx, inv.HoldPlans)
|
||||
if err != nil {
|
||||
if failed != "" && id != "" && errors.Is(err, inventory.ErrPlansBusy) {
|
||||
// In the store too, so a controller replaced before the look settles still fails the plan (issue 1708).
|
||||
if err := inv.RecordRefused(ctx, id, module, failed); err != nil {
|
||||
fmt.Printf("plans: %s's refusal could not be kept in the store, only in this controller: %v\n", module, err)
|
||||
}
|
||||
}
|
||||
leaveToRecords(module, id, made, failed, err)
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
plans, err := inv.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := range plans {
|
||||
p := &plans[i]
|
||||
if p.Tier >= len(p.Tiers) {
|
||||
continue
|
||||
}
|
||||
inTier := false
|
||||
for _, m := range p.Tiers[p.Tier] {
|
||||
if m == module {
|
||||
inTier = true
|
||||
}
|
||||
}
|
||||
if !inTier {
|
||||
continue
|
||||
}
|
||||
// **The plan's own ask is its outcome, by id** (novox/hq ADR 0219); another build of the module
|
||||
// is, as before, when it was asked at or after the plan's ask (issue 219).
|
||||
// **A module asked under an id is answered by that id's outcome and no other** (novox/hq ADR
|
||||
// 0219): a replay, a rebuild beside the plan, or an older ask finishing late is somebody else's
|
||||
// build, made from other source, and settling the plan with it would send that. A plan from
|
||||
// before ids were kept is matched as it was: by when the build was asked (issue 219). A walk
|
||||
// waiting for its word, or a module it never asked, takes no outcome at all (issue 754).
|
||||
state := p.Modules[module]
|
||||
if !takesOutcome(*p, state, asked, id) {
|
||||
continue
|
||||
}
|
||||
if failed != "" && deletedAtSource(failed) {
|
||||
// Deleted at its source by the merge, not broken (novox/hq ADR 0236): the plan goes on.
|
||||
state.State, state.Why = planDeleted, "deleted at its source: "+firstLine(failed)
|
||||
forgetDeleted(ctx, inv, module, p)
|
||||
} else if failed != "" {
|
||||
state.State = "failed"
|
||||
state.Why = failed
|
||||
p.State = inventory.PlanFailed
|
||||
p.Note = fmt.Sprintf("%s failed to build in tier %d", module, p.Tier)
|
||||
sayUnsent(p, func(m string) bool {
|
||||
u, err := inv.UpgradeOf(ctx, m)
|
||||
return err == nil && u.RollOut
|
||||
})
|
||||
} else {
|
||||
state.State = "built"
|
||||
state.BuiltAt = &now
|
||||
state.Commit = commit
|
||||
state.Made = made
|
||||
}
|
||||
if err := inv.SavePlan(ctx, p); err != nil {
|
||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
||||
continue
|
||||
}
|
||||
if p.State == inventory.PlanFailed {
|
||||
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
||||
}
|
||||
}
|
||||
advanceAfterOutcome(func() { advanceHeld(ctx, open) })
|
||||
}
|
||||
|
||||
// advanceAfterOutcome moves the plans after an outcome was taken into them: **in the serving controller, by asking
|
||||
// the look** (novox/hq issue 1708). The outcome is taken on the event loop, and moving the plans may send machines
|
||||
// their declarations, a release's send retried for minutes among them; done here it held every report, build and
|
||||
// merge behind it (2026-10-21 14:28:40 to 14:32:10). The look runs off the loop, holding the plans itself. Where no
|
||||
// look runs (a command, which has no event loop) the plans are moved at once, as before.
|
||||
func advanceAfterOutcome(advance func()) {
|
||||
if askedToLook() {
|
||||
return
|
||||
}
|
||||
advance()
|
||||
}
|
||||
|
||||
// startedBeside is the id of a started walk open beside this one that it is related to — a merge's walk past
|
||||
// its wait, not the backlog's — with why, or empty: a walk waits only for a started walk it shares something with
|
||||
// (novox/hq ADR 0276 decision 4, ADR 0407 decision 4, issue 973).
|
||||
@@ -2001,7 +1652,7 @@ func sayUnsent(p *inventory.Plan, rollsOut func(string) bool) {
|
||||
|
||||
// planLine is one plan as `status` says it, given the least bound of a tier.
|
||||
func planLine(p inventory.Plan, now time.Time) string {
|
||||
return planLineWith(p, now, pauseView{}, tierAtLeast)
|
||||
return planLineWith(p, now, tierAtLeast)
|
||||
}
|
||||
|
||||
// inTierSince is when a plan began waiting on what it waits on now, which `plans`, `status` and the
|
||||
@@ -2025,23 +1676,22 @@ func inTierSince(p inventory.Plan) time.Time {
|
||||
return since
|
||||
}
|
||||
|
||||
// planLineWith is planLine knowing whether the build seat is paused (novox/hq ADR 0219): a plan
|
||||
// waiting on builds nobody will take until a person resumes the seat says so, and is not late. bound is
|
||||
// planLineWith is planLine with bound, the plan's tier bound: bound is
|
||||
// the plan's tier bound, the one its stalled condition is raised at (tierBounds): LATE is that condition
|
||||
// said on the line (novox/hq issue 296). The machines running what it moves are not named: planLineOn.
|
||||
func planLineWith(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration) string {
|
||||
return planLineOn(p, now, pause, bound, nil)
|
||||
func planLineWith(p inventory.Plan, now time.Time, bound time.Duration) string {
|
||||
return planLineOn(p, now, bound, nil)
|
||||
}
|
||||
|
||||
// planLineOn is planLineWith naming the plan by what it moves and where (planHeadline), given what runs each
|
||||
// module; nil names no machine.
|
||||
func planLineOn(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string) string {
|
||||
return planLineSaid(p, now, pause, bound, running, nil)
|
||||
func planLineOn(p inventory.Plan, now time.Time, bound time.Duration, running func(string) []string) string {
|
||||
return planLineSaid(p, now, bound, running, nil)
|
||||
}
|
||||
|
||||
// planLineSaid is planLineOn saying, of a walk waiting for its delivery's word, what the delivery's owner says
|
||||
// holds it (novox/hq issue 529); nil waits says it as before.
|
||||
func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.Duration, running func(string) []string,
|
||||
func planLineSaid(p inventory.Plan, now time.Time, bound time.Duration, running func(string) []string,
|
||||
waits walkWaits) string {
|
||||
name := planHeadline(p, running)
|
||||
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
||||
@@ -2068,9 +1718,6 @@ func planLineSaid(p inventory.Plan, now time.Time, pause pauseView, bound time.D
|
||||
if waitingOnBackups(p) {
|
||||
return fmt.Sprintf("%s · %s, %s, for %s", name, where, p.Note, since)
|
||||
}
|
||||
if waiting, paused := pausedWaiting(p, pause, now); paused {
|
||||
return fmt.Sprintf("%s · %s, %s", name, where, waiting)
|
||||
}
|
||||
late := ""
|
||||
if since > bound {
|
||||
late = " — LATE"
|
||||
@@ -2141,51 +1788,6 @@ func planHeadline(p inventory.Plan, running func(string) []string) string {
|
||||
return out
|
||||
}
|
||||
|
||||
// planFailedBuild marks the module a failed build was for when the result names no module: by the
|
||||
// repository and path the plan's modules were asked at.
|
||||
//
|
||||
// **By the id first** (novox/hq ADR 0219): a plan keeps the id it asked each module under, so an
|
||||
// outcome that never learnt its module's name — cancelled, killed, failed at the clone — is matched
|
||||
// to the module it was asked for exactly. Repository and path remain for a plan from before ids
|
||||
// were kept.
|
||||
func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult) {
|
||||
asked, _ := link.BuildAskedAt(result.ID)
|
||||
if plans, err := open.inventory.OpenPlans(ctx); err == nil {
|
||||
if module := moduleAskedAs(plans, result.ID); module != "" {
|
||||
planBuilt(ctx, open, module, result.Commit, result.Failed, asked, result.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
entries, err := open.inventory.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path {
|
||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed, asked, result.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// moduleAskedAs is the module an open plan's current tier asked for under this id, or nothing.
|
||||
func moduleAskedAs(plans []inventory.Plan, id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
for _, p := range plans {
|
||||
if p.Tier >= len(p.Tiers) {
|
||||
continue
|
||||
}
|
||||
for _, m := range p.Tiers[p.Tier] {
|
||||
if s := p.Modules[m]; s != nil && s.Build == id {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func repositoryMatches(a, b string) bool {
|
||||
trim := func(s string) string { return strings.ToLower(strings.TrimSuffix(s, ".git")) }
|
||||
return trim(a) == trim(b) || strings.HasSuffix(trim(a), "/"+trim(b)) || strings.HasSuffix(trim(b), "/"+trim(a))
|
||||
@@ -2204,7 +1806,7 @@ type planStatus struct {
|
||||
Late bool `json:"late"`
|
||||
}
|
||||
|
||||
func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds tierBounds) []planStatus {
|
||||
func planStatuses(plans []inventory.Plan, now time.Time, bounds tierBounds) []planStatus {
|
||||
out := make([]planStatus, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
ps := planStatus{ID: p.ID, Repository: p.Repository, Commit: p.Commit, State: p.State,
|
||||
@@ -2217,10 +1819,6 @@ func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds
|
||||
}
|
||||
// A walk waiting for its delivery's word is no tier late (ADR 0239): S16 is its watchdog.
|
||||
ps.Late = !p.Waiting() && now.Sub(ps.Since) > bounds.of(p.Repository)
|
||||
// Paused is a person's decision, not lateness (novox/hq ADR 0219).
|
||||
if waiting, paused := pausedWaiting(p, pause, now); paused {
|
||||
ps.Waiting, ps.Late = waiting, false
|
||||
}
|
||||
}
|
||||
out = append(out, ps)
|
||||
}
|
||||
@@ -2228,13 +1826,13 @@ func planStatuses(plans []inventory.Plan, now time.Time, pause pauseView, bounds
|
||||
}
|
||||
|
||||
// openPlans is the open plans among the recent ones, and how many have waited past the bound.
|
||||
func openPlans(plans []inventory.Plan, now time.Time, pause pauseView, bounds tierBounds) ([]inventory.Plan, int) {
|
||||
func openPlans(plans []inventory.Plan, now time.Time, bounds tierBounds) ([]inventory.Plan, int) {
|
||||
var open []inventory.Plan
|
||||
late := 0
|
||||
for _, p := range plans {
|
||||
if p.Open() {
|
||||
open = append(open, p)
|
||||
if _, paused := pausedWaiting(p, pause, now); paused || p.Waiting() {
|
||||
if p.Waiting() {
|
||||
continue
|
||||
}
|
||||
if now.Sub(inTierSince(p)) > bounds.of(p.Repository) {
|
||||
@@ -2293,7 +1891,7 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
fmt.Printf("%s — %s\n", p.ID, planLineSaid(p, now, buildSeatPause(ctx, inv, []inventory.Plan{p}),
|
||||
fmt.Printf("%s — %s\n", p.ID, planLineSaid(p, now,
|
||||
bounds.of(p.Repository), func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
return on
|
||||
@@ -2485,7 +2083,6 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
fmt.Printf("%-28s %s\n", "", line)
|
||||
}
|
||||
}
|
||||
pause := buildSeatPause(ctx, inv, plans)
|
||||
bounds := readTierBounds(ctx, inv, now)
|
||||
running := func(module string) []string {
|
||||
on, _ := inv.Running(ctx, module)
|
||||
@@ -2497,7 +2094,7 @@ func plansCommand(ctx context.Context, args []string) error {
|
||||
if p.Batch() {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-28s %s%s\n", p.ID, planLineSaid(p, now, pause, bounds.of(p.Repository), running, waits), heldSaid(p, held))
|
||||
fmt.Printf("%-28s %s%s\n", p.ID, planLineSaid(p, now, bounds.of(p.Repository), running, waits), heldSaid(p, held))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
meshfacts "git.novox.be/novox/mesh-sdk/go/facts"
|
||||
"github.com/novox/mesh-controller/internal/builder"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// TestReplay867 is the replay of novox/hq issue 867, written with only what the controller and the build seat had
|
||||
// before the fix, so mesh-lab's prover can lay it over the commit before. A mesh-host change (#114) and a
|
||||
// mesh-controller change (#346) of one delivery group: the node-engine change's own check reads the controller's lock
|
||||
// beside it, which only the controller change brings. It was read beside the other's repository at the commit the mesh
|
||||
// runs, which holds neither change, so the node-engine change could not pass its own merge check. (The controller
|
||||
// change once needed the node-engine's validator word too; the validator is mesh-sdk's now, issue 1391.)
|
||||
//
|
||||
// It runs the whole path: the forge announces each pull request (following.PullUpdated), the controller asks
|
||||
// mesh-delivery which pull requests the group holds, the ask is queued for the build seat, and the build seat's check
|
||||
// (builder.Check, with a container runtime that runs each container's command on this machine) judges it.
|
||||
|
||||
// TestReplay867Recheck is the same pair re-checked through mesh-delivery (`delivery check` with one member): the forge's
|
||||
// announcement is not repeated, so the pull request reaches the controller without its head branch name.
|
||||
|
||||
func replay867(t *testing.T, recheck bool) {
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("no Go toolchain on this machine to build a judge with")
|
||||
}
|
||||
// The check's floor of free space (novox/hq issue 1834) reads the disk the test runs on, which is none of this
|
||||
// replay's business: a build node under the floor failed every mesh-controller check here (issue 1834, note 12).
|
||||
wasFree := builder.FreeBytes
|
||||
builder.FreeBytes = func(string) (uint64, error) { return 500 << 30, nil }
|
||||
t.Cleanup(func() { builder.FreeBytes = wasFree })
|
||||
r := theGroupOfIssue867(t)
|
||||
for _, c := range []struct {
|
||||
what, repo string
|
||||
number int
|
||||
head string
|
||||
module string
|
||||
}{{"the node-engine change", "mesh-host", 114, r.hostHead, "mesh-host"},
|
||||
{"the controller change", "mesh-controller", 346, r.controllerHead, "mesh-controller"}} {
|
||||
v := r.checked(t, c.repo, c.number, c.head, recheck)
|
||||
if v.Gate == nil || v.Gate.Verdict != "pass" || v.Repo == nil || v.Repo.Verdict != "pass" {
|
||||
t.Errorf("%s (%s#%d) did not pass its own merge check beside its group: gate %+v, repository %+v\n%s",
|
||||
c.what, c.repo, c.number, v.Gate, v.Repo, v.Report)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// theIssue867 is the two repositories as the trunk holds them and each pull request's head.
|
||||
type theIssue867 struct {
|
||||
open *stores
|
||||
hostHead, controllerHead string
|
||||
js interface{ Conn() *nats.Conn }
|
||||
judgeCache string
|
||||
registry string
|
||||
hostRepo, controllerRepo string
|
||||
groups string // what mesh-delivery's groups answers
|
||||
}
|
||||
|
||||
func r867Git(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))
|
||||
}
|
||||
|
||||
func r867Commit(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)
|
||||
}
|
||||
}
|
||||
r867Git(t, dir, "add", "-A")
|
||||
r867Git(t, dir, "commit", "--quiet", "-m", "x")
|
||||
return r867Git(t, dir, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// r867Controller is a controller whose judge says who it is and the validator word it was built with.
|
||||
func r867Controller(who, extra string) map[string]string {
|
||||
return map[string]string{
|
||||
"go.mod": "module example.org/judge\n\ngo 1.22\n\nrequire git.novox.be/novox/mesh-sdk/go v0.0.0\n",
|
||||
"vendor/modules.txt": "# git.novox.be/novox/mesh-sdk/go v0.0.0\n## explicit; go 1.22\ngit.novox.be/novox/mesh-sdk/go/validate\n",
|
||||
"vendor/git.novox.be/novox/mesh-sdk/go/validate/v.go": "package validate\n\nconst Word = \"the vendored validator\"\n",
|
||||
"cmd/mesh-controller/main.go": `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/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` + extra + `)
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
func theGroupOfIssue867(t *testing.T) *theIssue867 { return theGroupOfIssue867Into(t, "main") }
|
||||
|
||||
// theGroupOfIssue867Into is the group with both pull requests into base: main, or a release branch both repositories
|
||||
// hold, cut from their trunks.
|
||||
func theGroupOfIssue867Into(t *testing.T, base string) *theIssue867 {
|
||||
t.Helper()
|
||||
open := aMesh(t)
|
||||
r := &theIssue867{open: open, judgeCache: t.TempDir()}
|
||||
root := filepath.Join(t.TempDir(), "novox")
|
||||
controller, host := filepath.Join(root, "mesh-controller"), filepath.Join(root, "mesh-host")
|
||||
r.hostRepo, r.controllerRepo = host, controller
|
||||
for _, d := range []string{controller, host} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r867Git(t, d, "init", "--quiet", "-b", "main")
|
||||
}
|
||||
// The trunks.
|
||||
files := r867Controller("the controller", "")
|
||||
files["lock.txt"] = "foundation: old\n"
|
||||
files["module.json"] = `{"module":"mesh-controller"}`
|
||||
files[builder.CheckScript] = "true\n"
|
||||
r867Commit(t, controller, files)
|
||||
r867Commit(t, host, map[string]string{"validate/v.go": "package validate\n\nconst Word = \"the trunk's validator\"\n",
|
||||
"internal/declaration/d.go": "package declaration\n", "module.json": `{"module":"mesh-host"}`,
|
||||
builder.CheckScript: "set -e\ngrep -q 'foundation: new' \"$MESH_CHECK_BESIDE/mesh-controller/lock.txt\"\n"})
|
||||
if base != "main" {
|
||||
for _, d := range []string{controller, host} {
|
||||
r867Git(t, d, "branch", base, "main")
|
||||
}
|
||||
}
|
||||
// The two pull requests of one group, feat/867.
|
||||
r867Git(t, controller, "checkout", "--quiet", "-b", "feat/867", base)
|
||||
change := r867Controller("the controller", "")
|
||||
change["lock.txt"] = "foundation: new\n"
|
||||
r.controllerHead = r867Commit(t, controller, change)
|
||||
r867Git(t, controller, "checkout", "--quiet", "main")
|
||||
r867Git(t, host, "checkout", "--quiet", "-b", "feat/867", base)
|
||||
r.hostHead = r867Commit(t, host, map[string]string{"notes.txt": "the node-engine change\n"})
|
||||
r867Git(t, host, "checkout", "--quiet", "main")
|
||||
|
||||
followed := func(string) string { return "main" }
|
||||
for _, m := range []struct{ module, dir string }{{"mesh-controller", controller}, {"mesh-host", host}} {
|
||||
// The mesh runs each at its trunk's head, the commit a check reads it at beside another repository's change.
|
||||
running := r867Git(t, m.dir, "rev-parse", "main")
|
||||
if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: m.module, Version: "1"},
|
||||
inventory.Source{Repository: m.dir, Ref: followed(m.module), BuiltFrom: running, Head: running}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if base != "main" {
|
||||
// A module built from the controller's repository follows the release branch, as the mesh runs it there: a pull
|
||||
// request into the release branch moves it, so the composed check's gate runs.
|
||||
running := r867Git(t, controller, "rev-parse", "main")
|
||||
if err := open.inventory.RegisterModule(t.Context(), catalogue.Manifest{Module: "zz-agent", Version: "1"},
|
||||
inventory.Source{Repository: controller, Ref: base, BuiltFrom: running, Head: running}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
holdTheDeliverySeat(t, open)
|
||||
|
||||
// mesh-delivery says the group: two open pull requests into main, each with its head.
|
||||
was := askDeliveryOwner
|
||||
t.Cleanup(func() { askDeliveryOwner = was })
|
||||
askDeliveryOwner = func(_ context.Context, _ *nats.Conn, verb string, _ map[string]any) (json.RawMessage, error) {
|
||||
if verb != "groups" {
|
||||
t.Fatalf("asked the owner %s", verb)
|
||||
}
|
||||
return json.RawMessage(r.groups), nil
|
||||
}
|
||||
r.groups = (`[{"id":"feat/867","members":["` + root + `/mesh-host@` + r.hostHead[:12] + ` (checked)","` +
|
||||
root + `/mesh-controller@` + r.controllerHead[:12] + ` (checked)"],"member_details":[` +
|
||||
`{"id":"novox/mesh-host@` + r.hostHead[:12] + `","repository":"novox/mesh-host","state":"checked","number":114,"head":"` +
|
||||
r.hostHead + `","base":"` + base + `","open":true},` +
|
||||
`{"id":"novox/mesh-controller@` + r.controllerHead[:12] + `","repository":"novox/mesh-controller","state":"checked","number":346,"head":"` +
|
||||
r.controllerHead + `","base":"` + base + `","open":true}]}]`)
|
||||
r.registry = r867Registry(t)
|
||||
r867Runtime(t)
|
||||
return r
|
||||
}
|
||||
|
||||
// checked is a pull request announced by the forge, asked of the build seat by the controller, and checked there.
|
||||
func (r *theIssue867) checked(t *testing.T, repo string, number int, head string, recheck bool) builderVerdict {
|
||||
t.Helper()
|
||||
js, ask := aBuildQueue(t)
|
||||
_ = ask
|
||||
servingBus.Store(js)
|
||||
t.Cleanup(func() { servingBus.Store(nil) })
|
||||
p := link.PullUpdated{Owner: "novox", Repo: repo, Number: number, Base: "main", Head: "feat/867", Commit: head,
|
||||
Paths: []string{"lock.txt", "notes.txt", "cmd/mesh-controller/main.go"}, ModuleDirsSaid: true,
|
||||
MergeCheck: true, MergeCheckSaid: true}
|
||||
if recheck {
|
||||
// As `delivery check --members <one head>` builds it from mesh-delivery's member: no head branch name.
|
||||
p = orderMember{ID: "novox/" + repo + "@" + head[:12], Repository: "novox/" + repo, Base: "main", Head: head,
|
||||
Number: number, Paths: p.Paths, ModuleDirsSaid: true}.pull()
|
||||
p.MergeCheck, p.MergeCheckSaid = true, true
|
||||
}
|
||||
if err := (following{open: r.open, oneShot: true}).PullUpdated(t.Context(), p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q, err := link.ReadQueue(t.Context(), js, link.TheBuildMachine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.Asks) == 0 {
|
||||
t.Fatalf("%s#%d: nothing was asked of the build seat", repo, number)
|
||||
}
|
||||
request := q.Asks[len(q.Asks)-1].Request
|
||||
// What the build seat makes of the ask: the fields the two share, by name.
|
||||
var spec builder.CheckSpec
|
||||
body, _ := json.Marshal(request.Check)
|
||||
if err := json.Unmarshal(body, &spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec.ID, spec.Repository, spec.Ref = request.ID, request.Repository, request.Ref
|
||||
spec.Toolchain, spec.JudgeCache = "golang", r.judgeCache
|
||||
v, err := builder.Check(t.Context(), builder.Command, spec, t.TempDir(), r.registry, builder.GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type builderVerdict = builder.CheckVerdict
|
||||
|
||||
func r867Registry(t *testing.T) string {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(meshfacts.Facts{Format: meshfacts.Format, Taken: time.Now().UTC(),
|
||||
Versions: meshfacts.Versions{Bus: "2.11.17", Store: "17.11"}, Machines: []meshfacts.Machine{{Name: "abcdef", Length: 6}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
manifest, _ := json.Marshal(map[string]any{"schemaVersion": 2, "layers": []map[string]any{
|
||||
{"mediaType": meshfacts.MediaType, "digest": digest, "size": len(body)}}})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/manifests/"):
|
||||
w.Write(manifest)
|
||||
case strings.HasSuffix(req.URL.Path, "/blobs/"+digest):
|
||||
w.Write(body)
|
||||
default:
|
||||
http.NotFound(w, req)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return strings.TrimPrefix(srv.URL, "http://")
|
||||
}
|
||||
|
||||
// r867Runtime 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, and a store and a bus that answer.
|
||||
func r867Runtime(t *testing.T) {
|
||||
t.Helper()
|
||||
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()
|
||||
script := `#!/bin/sh
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
ps|rm|exec) exit 0 ;;
|
||||
port) echo "` + bus.Addr().String() + `"; exit 0 ;;
|
||||
run)
|
||||
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
|
||||
cd "$workdir" && exec "$@" ;;
|
||||
esac
|
||||
exit 1
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("GOTOOLCHAIN", "local")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// orRoot names a module's directory for a sentence: the repository's root when it is empty.
|
||||
func orRoot(dir string) string {
|
||||
if dir == "" {
|
||||
return "the repository's root"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// ownerOf and repoOf are the two halves of an owner/repository name.
|
||||
func ownerOf(repository string) string {
|
||||
owner, _, _ := strings.Cut(repository, "/")
|
||||
return owner
|
||||
}
|
||||
|
||||
func repoOf(repository string) string {
|
||||
_, repo, _ := strings.Cut(repository, "/")
|
||||
return repo
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// **A check that ran again says so on the pull request** (novox/hq issue 594): a build agent replaced
|
||||
// mid-check leaves the ask to be handed out again, and the verdict that finally comes is from a second
|
||||
// run. Each layer's summary — what the forge shows as the check's line — names the restart.
|
||||
func TestARestartedCheckSaysSoOnEveryLayer(t *testing.T) {
|
||||
asked := &link.CheckRequest{Owner: "novox", Repo: "mesh-controller", Number: 279}
|
||||
gate := &link.CheckLayer{Verdict: "pass", Summary: "every node composes"}
|
||||
repo := &link.CheckLayer{Verdict: "pass", Summary: "merge-check.sh passed"}
|
||||
c := checkedOf(link.BuildResult{ID: "b", Ref: "abc", On: "shanks", Checked: asked, Delivery: 2,
|
||||
Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes", Gate: gate, RepoCheck: repo}})
|
||||
for name, said := range map[string]string{"summary": c.Summary, "gate": c.Gate.Summary,
|
||||
"repository check": c.RepoCheck.Summary} {
|
||||
// The verdict's own words first, then the marker: the forge clips a status to a few words.
|
||||
if !strings.HasSuffix(said, " (run 2)") || strings.HasPrefix(said, "(run") {
|
||||
t.Errorf("the %s says %q: a check run twice reads as run once, or its marker pushes its words out", name, said)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(c.Report, "restarted: handed out 2 times") {
|
||||
t.Errorf("the report does not open with the restart: %q", c.Report)
|
||||
}
|
||||
if gate.Summary != "every node composes" {
|
||||
t.Errorf("the build's own outcome was changed under it: %q", gate.Summary)
|
||||
}
|
||||
|
||||
// Taken once, nothing is added.
|
||||
once := checkedOf(link.BuildResult{ID: "b", Ref: "abc", Checked: asked, Delivery: 1,
|
||||
Check: &link.CheckOutcome{Verdict: "pass", Summary: "every node composes"}})
|
||||
if strings.Contains(once.Summary, "(run") || strings.Contains(once.Report, "restarted") {
|
||||
t.Errorf("a check run once says %q", once.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// And the queue says it of the ask in flight.
|
||||
func TestTheQueueSaysAnAskInFlightRestarted(t *testing.T) {
|
||||
now := time.Date(2026, 10, 13, 0, 16, 0, 0, time.UTC)
|
||||
q := link.Queue{Seat: link.TheBuildMachine, Delivered: 1, AckPending: 1, MaxDeliver: 5, Asks: []link.QueuedAsk{
|
||||
{Seq: 1, ID: "build-279", Repository: "ssh://forge/novox/mesh-controller", AskedAt: now.Add(-7 * time.Minute),
|
||||
State: link.AskInFlight, On: "shanks", Started: now.Add(-time.Minute), Delivery: 2},
|
||||
}}
|
||||
text := queueText(q, now)
|
||||
if !strings.Contains(text, "handed out 2 times") {
|
||||
t.Fatalf("the queue does not say the ask was handed out again:\n%s", text)
|
||||
}
|
||||
if a := queueAnswerOf(q, now).Asks[0]; a.Delivery != 2 {
|
||||
t.Errorf("the queue's data says delivery %d, want 2", a.Delivery)
|
||||
}
|
||||
}
|
||||
@@ -67,18 +67,8 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) {
|
||||
|
||||
// The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept.
|
||||
var mu sync.Mutex
|
||||
var asks []string
|
||||
var sends [][]string
|
||||
asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it
|
||||
wasAsk := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond))
|
||||
asks = append(asks, source.Repository+"#"+id)
|
||||
return id, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = wasAsk })
|
||||
asked := asksRecorded(t) // each module taken from a done key as the walk reaches it, said as repository#build id
|
||||
digests := 0
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
@@ -194,7 +184,7 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) {
|
||||
eventually(t, "tier 0 sent to both machines and tier 1 asked, on the save alone", func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(sends) == 1 && len(asks) == 1 && plan().Tier == 1
|
||||
return len(sends) == 1 && len(*asked) == 1 && plan().Tier == 1
|
||||
})
|
||||
eventually(t, "a key on the bus for each machine tier 0 was sent to", func() bool { return len(heldKeys()) == 2 })
|
||||
|
||||
@@ -216,15 +206,7 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) {
|
||||
return !anchor
|
||||
})
|
||||
|
||||
// tier 1's build comes in, as the build machine's outcome is taken in.
|
||||
b := plan().Modules["b"]
|
||||
if b == nil || b.Build == "" {
|
||||
t.Fatalf("tier 1 was not asked: %+v", b)
|
||||
}
|
||||
askedAt, _ := link.BuildAskedAt(b.Build)
|
||||
record(b.Build, "b", "c2", askedAt)
|
||||
register("b", "c2", askedAt)
|
||||
planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build)
|
||||
// tier 1's module is taken from its key by the new controller's look.
|
||||
eventually(t, "tier 1 sent to both machines", func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
@@ -246,8 +228,8 @@ func TestARolloutAdvancesOnEventsAndAReplacedControllerResumesIt(t *testing.T) {
|
||||
// No tier asked twice and none skipped: one ask for tier 1 (tier 0 was built when the plan was kept), each tier sent
|
||||
// once to both machines, and said as sent once for each.
|
||||
mu.Lock()
|
||||
if len(asks) != 1 || !strings.HasPrefix(asks[0], "novox/b#") {
|
||||
t.Errorf("asks = %v; want tier 1's module asked once", asks)
|
||||
if len(*asked) != 1 || !strings.HasPrefix((*asked)[0], "novox/mesh-catalog#key-") {
|
||||
t.Errorf("taken from keys = %v; want tier 1's module taken once", *asked)
|
||||
}
|
||||
if len(sends) != 2 || strings.Join(sends[0], ",") != "anchor,laptop" || strings.Join(sends[1], ",") != "anchor,laptop" {
|
||||
t.Errorf("sends = %v; want both machines sent each tier once", sends)
|
||||
|
||||
@@ -79,7 +79,7 @@ func leaveSameAsRunning(ctx context.Context, inv *inventory.Inventory, plan *inv
|
||||
now, held := map[string]string{}, map[string]string{}
|
||||
for m := range plan.Modules {
|
||||
e, known := byName[m]
|
||||
if !known || isBuildersOwn(m) {
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
v, err := keys.ModuleAt(ctx, m, plan.CommitOn(e.Source.Repository, followedBranch(e.Source.Ref)))
|
||||
|
||||
@@ -58,11 +58,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
||||
if removedVerbs[verb] {
|
||||
return nil, removedByName(verb)
|
||||
}
|
||||
if verb == "replay" {
|
||||
if v, given := args["register"]; given && v != false && v != "false" {
|
||||
return nil, removedByName("replay --register")
|
||||
}
|
||||
}
|
||||
a, err := readArguments(verb, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -99,7 +94,8 @@ type verbArguments struct {
|
||||
|
||||
// removedVerbs are the controller seat's verbs R4e removed, kept in its table with no input so a caller is answered
|
||||
// by name (ADR 0501 §5, ADR 0503 §3, ADR 0450 §8).
|
||||
var removedVerbs = map[string]bool{"build": true, "rebuild": true, "forget-checks": true, "delivery-plan": true}
|
||||
var removedVerbs = map[string]bool{"build": true, "rebuild": true, "forget-checks": true, "delivery-plan": true,
|
||||
"delivery-check": true, "queue": true, "cancel": true, "clear": true, "kill": true, "replay": true, "pause": true, "resume": true}
|
||||
|
||||
// controllerVerb is this binary's own definition of a verb: what it runs is what it declares, so the
|
||||
// arguments are checked against the table compiled beside argvFor, not a row a newer or older build
|
||||
@@ -341,8 +337,9 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
case "seats":
|
||||
return []string{"seats", "--json"}, nil
|
||||
case "builds":
|
||||
if id := str("log"); id != "" {
|
||||
return []string{"builds", "--log", id}, nil
|
||||
if str("log") != "" {
|
||||
return nil, errors.New("a build's log is not read here any more: the build seat is retired (ADR 0525 clause 7), " +
|
||||
"and a build runs on a build module, whose step key names where it stands (ADR 0501)")
|
||||
}
|
||||
argv := []string{"builds"}
|
||||
if n := str("limit"); n != "" {
|
||||
@@ -427,23 +424,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
return append([]string{"delivery", "order"}, members...), nil
|
||||
case "delivery-check":
|
||||
if err := need("members"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members, err := membersFlags(str("members"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := append([]string{"delivery", "check"}, members...)
|
||||
if g := str("group"); g != "" {
|
||||
argv = append([]string{"delivery", "check", "--group", g}, members...)
|
||||
}
|
||||
// Marked ready (novox/hq ADR 0406 §3): the check is queued before every other check.
|
||||
if r := strings.ToLower(str("ready")); r != "" && r != "false" && r != "no" && r != "0" {
|
||||
argv = append(argv, "--ready")
|
||||
}
|
||||
return argv, nil
|
||||
case "deliver":
|
||||
if err := need("plan"); err != nil {
|
||||
return nil, err
|
||||
@@ -484,10 +464,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
argv = append(argv, "-n", n)
|
||||
}
|
||||
return argv, nil
|
||||
// The build queue (novox/hq ADR 0219).
|
||||
case "queue":
|
||||
// As data, the table its output (novox/hq issue 455).
|
||||
return []string{"queue", "--json"}, nil
|
||||
case "try":
|
||||
// A branch tried before its pull request (novox/hq issue 686): asked, or read by the id it answered.
|
||||
if id := str("id"); id != "" {
|
||||
@@ -510,27 +486,6 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
||||
argv = append(argv, "--store-only")
|
||||
}
|
||||
return argv, nil
|
||||
case "cancel", "kill":
|
||||
if err := need("id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{verb, str("id")}, nil
|
||||
case "clear":
|
||||
if on("dead") {
|
||||
return []string{"clear", "--dead"}, nil
|
||||
}
|
||||
return []string{"clear"}, nil
|
||||
case "replay":
|
||||
if err := need("id"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A dry run alone (ADR 0429 §18): register is refused by name before this (argvFor).
|
||||
return []string{"replay", str("id")}, nil
|
||||
case "pause", "resume":
|
||||
if n := str("node"); n != "" {
|
||||
return []string{verb, n}, nil
|
||||
}
|
||||
return []string{verb}, nil
|
||||
case "plan":
|
||||
if err := need("node"); err != nil {
|
||||
return nil, err
|
||||
@@ -1079,8 +1034,6 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co
|
||||
"move-config": true,
|
||||
// The delivery's owner's verbs answer JSON where they read (plan, order, check, walks) — novox/hq ADR 0239.
|
||||
"delivery": true,
|
||||
// The build queue, each ask with its position among the waiting (novox/hq issue 455).
|
||||
"queue": true,
|
||||
// A branch tried before its pull request (novox/hq issue 686).
|
||||
"try": true,
|
||||
// A plan followed (novox/hq issue 229): `plans wait` answers JSON; its other forms say text, and so answer no data.
|
||||
@@ -1089,7 +1042,7 @@ var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true, "co
|
||||
// tableBesideVerbs are the JSON verbs whose document carries the table a person reads as its `text`: the
|
||||
// verb answers that table as its output, as it did before it answered data, and the rest as `answer` —
|
||||
// both from the one read, so they never disagree (novox/hq issue 455).
|
||||
var tableBesideVerbs = map[string]bool{"queue": true, "plans": true}
|
||||
var tableBesideVerbs = map[string]bool{"plans": true}
|
||||
|
||||
// overviewVerbs are the JSON verbs whose answer is said once, as data, and not again as the text the
|
||||
// command printed: the overviews, which grow with the mesh (novox/hq issue 314). Every reader of theirs
|
||||
@@ -1254,10 +1207,6 @@ func readHere(ctx context.Context, argv []string) (verbAnswer, bool) {
|
||||
if handActConn != nil || servingBus.Load() != nil {
|
||||
read = listHandActs
|
||||
}
|
||||
case "queue":
|
||||
if servingBus.Load() != nil {
|
||||
read = listQueue
|
||||
}
|
||||
}
|
||||
if read == nil {
|
||||
return verbAnswer{}, false
|
||||
@@ -1692,7 +1641,7 @@ func subIn(rest []string, subs ...string) bool {
|
||||
var commandReadForms = map[string]func(rest []string) bool{
|
||||
"status": flagsOnly, "version": flagsOnly, "help": flagsOnly, "seats": flagsOnly, "healers": flagsOnly,
|
||||
"hand-acts": flagsOnly, "durations": flagsOnly, "collection": flagsOnly, "images": flagsOnly,
|
||||
"artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly, "queue": flagsOnly,
|
||||
"artifacts": flagsOnly, "data": flagsOnly, "builds": flagsOnly,
|
||||
// The switch's list reads the builder's keys and the builds held, and moves nothing (issue 1851, A13).
|
||||
"switch": flagsOnly,
|
||||
// `plan <node>` previews a node's declaration; it sends nothing.
|
||||
|
||||
@@ -10,15 +10,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// `builds` given a build's id reads that build's log from the bus rather than listing builds
|
||||
// (novox/hq ADR 0157).
|
||||
func TestBuildsWithAnIdReadsThatBuildsLog(t *testing.T) {
|
||||
argv, err := argvFor("builds", map[string]any{"log": "build-17"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(argv, " ") != "builds --log build-17" {
|
||||
t.Fatalf("builds with a log id became %q", strings.Join(argv, " "))
|
||||
// `builds` given a build's id no longer reads a log from the bus: the build seat is retired (ADR 0525 clause 7).
|
||||
func TestBuildsWithAnIdIsRefusedForTheRetiredSeat(t *testing.T) {
|
||||
if _, err := argvFor("builds", map[string]any{"log": "build-17"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "build seat is retired") {
|
||||
t.Fatalf("builds with a log id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +121,13 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) {
|
||||
{"build", map[string]any{"repository": "novox/x", "path": "modules/x"}, "`build` is removed"},
|
||||
{"build", map[string]any{}, "`build` is removed"},
|
||||
{"rebuild", map[string]any{"what": "gitea"}, "`rebuild` is removed"},
|
||||
{"replay", map[string]any{"id": "build-1", "register": "true", "older": "true"}, "`replay --register` is removed"},
|
||||
{"replay", map[string]any{"id": "build-1"}, "`replay` is removed"},
|
||||
{"queue", map[string]any{}, "`queue` is removed"},
|
||||
{"cancel", map[string]any{"id": "build-1"}, "`cancel` is removed"},
|
||||
{"clear", map[string]any{"dead": "true"}, "`clear` is removed"},
|
||||
{"kill", map[string]any{"id": "build-1"}, "`kill` is removed"},
|
||||
{"pause", map[string]any{"node": "ace"}, "`pause` is removed"},
|
||||
{"resume", map[string]any{}, "`resume` is removed"},
|
||||
// 1318: delivery-plan and forget-checks go too.
|
||||
{"delivery-plan", map[string]any{"repository": "novox/a", "paths": "x"}, "`delivery-plan` is removed"},
|
||||
{"forget-checks", map[string]any{"owner": "novox", "repo": "a", "number": "7"}, "`forget-checks` is removed"},
|
||||
@@ -135,10 +137,7 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) {
|
||||
t.Errorf("%s %v: %v %v, want refused saying %q", c.verb, c.args, argv, err, c.said)
|
||||
}
|
||||
}
|
||||
if argv, err := argvFor("replay", map[string]any{"id": "build-1"}); err != nil || strings.Join(argv, " ") != "replay build-1" {
|
||||
t.Errorf("replay as a dry run: %v %v", argv, err)
|
||||
}
|
||||
for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery-plan"} {
|
||||
for _, removed := range []string{"build", "rebuild", "forget-checks", "delivery-plan", "queue", "replay"} {
|
||||
err := removedByName(removed)
|
||||
if err == nil || !strings.Contains(err.Error(), "`"+removed+"` is removed") || !strings.Contains(err.Error(), "ADR 05") {
|
||||
t.Errorf("%s: %v", removed, err)
|
||||
@@ -148,10 +147,6 @@ func TestTheRemovedVerbsAreRefusedByName(t *testing.T) {
|
||||
!strings.Contains(err.Error(), "`delivery-plan` is removed") {
|
||||
t.Errorf("delivery plan: %v", err)
|
||||
}
|
||||
err := replayCommand(context.Background(), []string{"build-1", "--register"})
|
||||
if err == nil || !strings.Contains(err.Error(), "`replay --register` is removed") {
|
||||
t.Errorf("replay --register: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What `tools` answers is the seats' records, with each verb's schema.
|
||||
@@ -232,7 +227,7 @@ func TestTheCommandVerbOnlyReads(t *testing.T) {
|
||||
}
|
||||
for _, line := range []string{
|
||||
"status --json", "version", "seats --json", "healers", "hand-acts --days 3", "durations", "collection",
|
||||
"images", "artifacts --collected", "data --machine a", "builds --log b", "queue", "plan ace --diff",
|
||||
"images", "artifacts --collected", "data --machine a", "builds", "plan ace --diff",
|
||||
"plans", "plans plan-1", "doctor", "doctor probes", "doctor signals", "conditions", "conditions list",
|
||||
"conditions show c", "conditions history", "node list", "node show ace", "module list",
|
||||
"settings show m", "settings preferences", "retire list", "cleanup list", "delivery plan --repository r",
|
||||
|
||||
@@ -62,10 +62,6 @@ const (
|
||||
tierAtLeast = planWaitBound
|
||||
// loopDeafAfter is how long the event loop may take nothing while its consumers hold some (S4).
|
||||
loopDeafAfter = 2 * time.Minute
|
||||
// askAtLeast is the least a build ask is given, and askDefault the bound while nothing is
|
||||
// measured (S6): the build seat declares no timeout of its own.
|
||||
askAtLeast = 20 * time.Minute
|
||||
askDefault = time.Hour
|
||||
// callDefault is the bound of a verb that declares none (S7); push's and build's are longer.
|
||||
callDefault = 10 * time.Minute
|
||||
// advisoryQuiet is how long the bus must be quiet about a thing before its advisory clears (S9).
|
||||
@@ -114,9 +110,8 @@ var signalsTable = []signalRow{
|
||||
return newestOf(f.machines, func(m machineFacts) time.Time { return m.reportedAt })
|
||||
}},
|
||||
{Row: "S3", Signal: "plan tier progress", Emitter: "controller's plan", Trigger: "each tier entered",
|
||||
Bound: "max(30 min, 3 × the p90 of that repository's measured tiers), provisional; not while the " +
|
||||
"build seat is paused under it",
|
||||
Kind: "stalled", Severity: conditions.Warning, Phase: 1,
|
||||
Bound: "max(30 min, 3 × the p90 of that repository's measured tiers), provisional",
|
||||
Kind: "stalled", Severity: conditions.Warning, Phase: 1,
|
||||
needs: func(f *signalFacts) error { return f.plansErr }, watch: watchPlans,
|
||||
newest: func(f *signalFacts) time.Time {
|
||||
return newestOf(f.plans, func(p planFacts) time.Time { return p.entered })
|
||||
@@ -126,12 +121,6 @@ var signalsTable = []signalRow{
|
||||
Kind: "controller-deaf", Severity: conditions.Urgent, Phase: 1,
|
||||
needs: func(f *signalFacts) error { return f.loopErr }, watch: watchLoop,
|
||||
newest: func(f *signalFacts) time.Time { return f.loop.took }},
|
||||
{Row: "S6", Signal: "a build asked → its outcome", Emitter: "build seat", Trigger: "each ask",
|
||||
Bound: "max(20 min, 3 × the p90 of measured builds), 1 h while nothing is measured, provisional — the " +
|
||||
"build seat declares no timeout; and an ask the queue gave up on (dead) at once",
|
||||
Kind: "ask-lost", Severity: conditions.Warning, Phase: 1,
|
||||
needs: func(f *signalFacts) error { return f.asksErr }, watch: watchAsks,
|
||||
newest: func(f *signalFacts) time.Time { return newestOf(f.asks, func(a askFacts) time.Time { return a.since }) }},
|
||||
{Row: "S7", Signal: "a call running → finished", Emitter: "controller", Trigger: "each call",
|
||||
Bound: "the verb's bound: push, rotate and command 30 min, assign and unassign 15 min, doctor 3 min, " +
|
||||
"any other 10 min",
|
||||
@@ -407,9 +396,9 @@ func watchReports(f *signalFacts) []conditions.Observation {
|
||||
func watchPlans(f *signalFacts) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, p := range f.plans {
|
||||
// Under a paused build seat, or held only by the bus's planned step (S17, novox/hq issue 336): a wait
|
||||
// Held only by the bus's planned step (S17, novox/hq issue 336): a wait
|
||||
// for a person, said as itself, not a walk running late.
|
||||
if p.paused || p.bus {
|
||||
if p.bus {
|
||||
continue
|
||||
}
|
||||
in := f.now.Sub(p.entered)
|
||||
@@ -521,32 +510,6 @@ func watchLoop(f *signalFacts) []conditions.Observation {
|
||||
Said: fmt.Sprintf("%d pending (%s), last taken %s", f.loop.pending, f.loop.where, since.UTC().Format(time.RFC3339))}}
|
||||
}
|
||||
|
||||
func watchAsks(f *signalFacts) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, a := range f.asks {
|
||||
var said string
|
||||
switch {
|
||||
case a.state == link.AskDead:
|
||||
said = fmt.Sprintf("the %s queue handed it out as often as it may and it was never settled", a.seat)
|
||||
case a.state == link.AskInFlight && f.now.Sub(a.since) > a.bound:
|
||||
said = fmt.Sprintf("in flight on %s for %s (bound %s)", orSomewhere(a.on), ago(f.now.Sub(a.since)), ago(a.bound))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
out = append(out, conditions.Observation{Scope: conditions.ScopeBuild, ID: a.id, Kind: "ask-lost",
|
||||
Token: "lost", Machine: a.on, Severity: conditions.Warning,
|
||||
Summary: fmt.Sprintf("the build %s of %s has no outcome: %s", a.id, a.what, said), Said: said})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orSomewhere(node string) string {
|
||||
if node == "" {
|
||||
return "a machine that did not say which"
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func watchCalls(f *signalFacts) []conditions.Observation {
|
||||
var out []conditions.Observation
|
||||
for _, c := range f.calls {
|
||||
@@ -738,8 +701,6 @@ func consumerWho(stream, consumer string) string {
|
||||
switch {
|
||||
case consumer == broker.ControllerName || consumer == broker.MergesConsumerName:
|
||||
return "the controller"
|
||||
case broker.IsCheckStream(stream):
|
||||
return "the holder of " + broker.TheBuildSeat
|
||||
case strings.HasPrefix(stream, "SEAT_") && strings.HasSuffix(consumer, "_worker"):
|
||||
seat := strings.ToLower(strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(consumer, "SEAT_"), "_worker"), "_", "-"))
|
||||
return "the holder of " + seat
|
||||
|
||||
@@ -71,16 +71,6 @@ var suppressions = map[string]suppression{
|
||||
inside: func(f *signalFacts) { f.loop.took = f.now.Add(-119 * time.Second) },
|
||||
past: func(f *signalFacts) { f.loop.took = f.now.Add(-121 * time.Second) },
|
||||
},
|
||||
"S6": {
|
||||
inside: func(f *signalFacts) {
|
||||
f.asks = []askFacts{{id: "build-1", seat: "node-build-agent", what: "novox/app", state: link.AskInFlight,
|
||||
on: "anchor", since: f.now.Add(-59 * time.Minute), bound: time.Hour}}
|
||||
},
|
||||
past: func(f *signalFacts) {
|
||||
f.asks = []askFacts{{id: "build-1", seat: "node-build-agent", what: "novox/app", state: link.AskInFlight,
|
||||
on: "anchor", since: f.now.Add(-61 * time.Minute), bound: time.Hour}}
|
||||
},
|
||||
},
|
||||
"S7": {
|
||||
inside: func(f *signalFacts) {
|
||||
f.calls = []link.Call{{ID: "call-1", Seat: "mesh-controller", Verb: "push", Started: f.now.Add(-29 * time.Minute)}}
|
||||
|
||||
@@ -63,18 +63,8 @@ func TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToT
|
||||
|
||||
// The build machine and the machines, as far as the plan meets them: an ask is counted, a send is recorded and kept.
|
||||
var mu sync.Mutex
|
||||
var asks []string
|
||||
var sends [][]string
|
||||
asksOfEveryModule(t) // the one ask left is the builder's own (ADR 0453 §5): this test is of what a walk does around it
|
||||
wasAsk := askABuild
|
||||
askABuild = func(_ context.Context, source buildSource, _, _ string) (string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
id := link.NewBuildID(time.Now().Add(time.Duration(len(asks)) * time.Millisecond))
|
||||
asks = append(asks, source.Repository+"#"+id)
|
||||
return id, nil
|
||||
}
|
||||
t.Cleanup(func() { askABuild = wasAsk })
|
||||
asksRecorded(t) // each module is taken from a done key as the walk reaches it
|
||||
digests := 0
|
||||
wasSend := sendRollout
|
||||
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
|
||||
@@ -200,13 +190,7 @@ func TestAPlanPassesATierWhoseMachineNeverReportsAfterTheBoundAndSendsTheNextToT
|
||||
if !strings.Contains(moved, "laptop") || strings.Contains(moved, "anchor") {
|
||||
t.Errorf("1160/AC5: the plan says %q; want laptop named silent and not anchor", moved)
|
||||
}
|
||||
// The ask for tier 1's build is the next step, and records its build id on the module.
|
||||
eventually(t, "tier 1 asked", func() bool { m := plan().Modules["b"]; return m != nil && m.Build != "" })
|
||||
b := plan().Modules["b"]
|
||||
askedAt, _ := link.BuildAskedAt(b.Build)
|
||||
record(b.Build, "b", "c2", askedAt)
|
||||
register("b", "c2", askedAt)
|
||||
planBuilt(ctx, open, "b", "c2", "", askedAt, b.Build)
|
||||
// Tier 1's module is taken from its key, which the walk's look does by itself.
|
||||
eventually(t, "tier 1 sent to anchor alone", func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -50,47 +48,6 @@ func onASeat(repository string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloneFrom is the URL a build machine clones for a source.
|
||||
//
|
||||
// A URL is itself. A path on a seat is composed from the seat's holder as the mesh sees it now —
|
||||
// the same view planning takes of every machine, so the forge a build clones from is the forge the
|
||||
// mesh says holds the seat.
|
||||
func cloneFrom(ctx context.Context, source buildSource) (string, error) {
|
||||
if source.Seat == "" {
|
||||
return source.Repository, nil
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer open.Close()
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
world, err := restOfTheMesh(ctx, open, shelf, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return clonedFromSeat(world, source.Seat, source.Repository)
|
||||
}
|
||||
|
||||
// clonedFromSeat composes the clone URL for a repository on a seat's holder.
|
||||
//
|
||||
// **Refused, never defaulted, at every step that has no answer.** Nobody holding the seat is a mesh
|
||||
// without a forge of its own: it builds from external repositories and must say so rather than fail
|
||||
// to clone. A holder off the private network cannot be reached by any build machine. A holder that
|
||||
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
|
||||
// address guessed, which is the thing this exists to stop.
|
||||
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
|
||||
base, err := seatBase(world, seatName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
|
||||
return fmt.Sprintf("%s/%s.git", base, path), nil
|
||||
}
|
||||
|
||||
// seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning.
|
||||
func seatBase(world catalogue.World, seatName string) (string, error) {
|
||||
seat, known := catalogue.SeatNamed(seatName)
|
||||
@@ -132,41 +89,6 @@ func seatBase(world catalogue.World, seatName string) (string, error) {
|
||||
return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil
|
||||
}
|
||||
|
||||
// seatBases is the clone base of every seat a recipe's context may name, for a build request
|
||||
// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not
|
||||
// name it at all, and if it does the builder refuses with the seat's name.
|
||||
//
|
||||
// **What cannot be read is said, not passed over as no seats** (novox/hq to-be 45 Phase 2, the
|
||||
// empty-on-error lint): the build still goes ahead — one that names no seat needs none — and one that
|
||||
// does is refused naming it, but the reason is the store, and that is said here where it is known.
|
||||
func seatBases(ctx context.Context) map[string]string {
|
||||
unread := func(what string, err error) map[string]string {
|
||||
fmt.Fprintf(os.Stderr, "could not read %s, so a build naming a seat's clone base will be told that "+
|
||||
"seat is not held: %v\n", what, err)
|
||||
return nil
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return unread("the mesh's store", err)
|
||||
}
|
||||
defer open.Close()
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return unread("the catalogue", err)
|
||||
}
|
||||
world, err := restOfTheMesh(ctx, open, shelf, "")
|
||||
if err != nil {
|
||||
return unread("who holds which seat", err)
|
||||
}
|
||||
bases := map[string]string{}
|
||||
for _, seatName := range []string{gitSeat} {
|
||||
if base, err := seatBase(world, seatName); err == nil {
|
||||
bases[seatName] = base
|
||||
}
|
||||
}
|
||||
return bases
|
||||
}
|
||||
|
||||
// servedPort is a served port as text, however the manifest and the node's settings carried it.
|
||||
func servedPort(v any) string {
|
||||
switch p := v.(type) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
@@ -22,68 +21,6 @@ func forgeHolding(port any) catalogue.World {
|
||||
}
|
||||
}
|
||||
|
||||
func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) {
|
||||
got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "http://anchor.internal:3000/novox/mesh-catalog.git" {
|
||||
t.Fatalf("cloned from %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) {
|
||||
// The whole point: the node gave the forge another port, and the same recorded path clones
|
||||
// from the new one. Nothing recorded contained the old one to be wrong.
|
||||
got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, ":3100/") {
|
||||
t.Fatalf("the moved port was not followed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) {
|
||||
_, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog")
|
||||
if err == nil {
|
||||
t.Fatal("a repository was cloned from a forge the mesh does not have")
|
||||
}
|
||||
for _, want := range []string{"nobody holds the git seat", "without --self"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) {
|
||||
// Unaffected by the seat, held or not: GitHub and GitLab are the ordinary cases.
|
||||
given := "https://github.com/someone/something.git"
|
||||
got, err := cloneFrom(t.Context(), buildSource{Repository: given})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != given {
|
||||
t.Fatalf("an external repository became %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) {
|
||||
world := forgeHolding(float64(3000))
|
||||
world.Offered["git"][1].At = ""
|
||||
if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil ||
|
||||
!strings.Contains(err.Error(), "private network") {
|
||||
t.Fatalf("a forge nothing can reach was cloned from: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) {
|
||||
// A default port would be the forge's address guessed, which is what this exists to stop.
|
||||
if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil {
|
||||
t.Fatal("a port was guessed for a forge that serves none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"https://github.com/someone/something.git",
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestTheStatesAnnouncedGrantTheReads(t *testing.T) {
|
||||
}
|
||||
becomes(t, 10*time.Second, "both words kept", func() bool {
|
||||
a, err := readAnnouncements(ctx)
|
||||
return err == nil && len(a) == 2 && a["owner"].Readable("open") && a[broker.ControllerSeat].Readable(broker.PlansBuilding)
|
||||
return err == nil && len(a) == 2 && a["owner"].Readable("open") && a[broker.ControllerSeat].Readable(broker.ChainBuilds)
|
||||
})
|
||||
|
||||
records, err := inv.BusRecords(ctx)
|
||||
|
||||
@@ -152,14 +152,14 @@ func printStatus(asked answers) error {
|
||||
|
||||
printPending(asked.pending, asked.pendingUnread)
|
||||
|
||||
if open, late := openPlans(asked.plans, time.Now(), asked.paused, asked.tierBounds); len(open) > 0 {
|
||||
if open, late := openPlans(asked.plans, time.Now(), asked.tierBounds); len(open) > 0 {
|
||||
fmt.Printf("%d plan(s) open", len(open))
|
||||
if late > 0 {
|
||||
fmt.Printf(", %d in a tier past its bound", late)
|
||||
}
|
||||
fmt.Println(":")
|
||||
for _, p := range open {
|
||||
fmt.Printf(" %s\n", planLineWith(p, time.Now(), asked.paused, asked.tierBounds.of(p.Repository)))
|
||||
fmt.Printf(" %s\n", planLineWith(p, time.Now(), asked.tierBounds.of(p.Repository)))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -393,16 +393,6 @@ func firstLine(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// builds keeps what a builder said, for the serving control plane.
|
||||
//
|
||||
// A type of its own rather than a method on the enrolment, because they are unrelated things
|
||||
// arriving on one queue and an implementation of one should not have to say anything about the
|
||||
// other.
|
||||
type builds struct {
|
||||
inv *inventory.Inventory
|
||||
open *stores
|
||||
}
|
||||
|
||||
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
|
||||
//
|
||||
// **One reading, for every way of saying it** (novox/hq 03-DESIGN/01-to-be/11-a-board.md). There
|
||||
@@ -511,7 +501,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
||||
return answers{}, err
|
||||
}
|
||||
// Whether the build seat takes work, for a plan waiting on it (novox/hq ADR 0219).
|
||||
out.paused = buildSeatPause(ctx, inv, out.plans)
|
||||
// And how long a tier may take, the bound a plan is late at (novox/hq issue 296).
|
||||
out.tierBounds = readTierBounds(ctx, inv, time.Now())
|
||||
// And how many repairs were done by hand this week (novox/hq to-be 45 §7) — where there is a bus
|
||||
|
||||
@@ -160,7 +160,7 @@ func switchList(ctx context.Context, inv *inventory.Inventory, keys switchReads)
|
||||
var out []switchLine
|
||||
for _, e := range entries {
|
||||
m := e.Manifest.Module
|
||||
if e.Provided || e.Source.Repository == "" || isBuildersOwn(m) {
|
||||
if e.Provided || e.Source.Repository == "" {
|
||||
continue
|
||||
}
|
||||
trunk, err := keys.ModuleAt(ctx, m, "")
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// refusalKeeper keeps a refused registration in the store (*inventory.Inventory, or a test's).
|
||||
type refusalKeeper interface {
|
||||
RecordRefused(ctx context.Context, id, module, why string) error
|
||||
}
|
||||
|
||||
// refusedOutcome is how a refused build reaches the plans: built takes it in by its module (planBuilt), failedBuild
|
||||
// by the id the plan asked it under (planFailedBuild).
|
||||
type refusedOutcome struct {
|
||||
built func(module, why string)
|
||||
failedBuild func(result link.BuildResult)
|
||||
}
|
||||
|
||||
// takeRefused takes in a build heard and recorded, and not registered (novox/hq issue 1713).
|
||||
//
|
||||
// **The refusal is kept by the build's id, whatever the plans' state**: in the store (RecordRefused) and in this
|
||||
// process (keepRefusalFor). Taking it into the plans keeps it only when a plan is open or the plans are held, and a
|
||||
// tier's first failure fails its plan, which no outcome reaches after that: the build record says the builder's words,
|
||||
// which worked, and a Retry settled from it sent the module's old version and reported the plan done (on 2026-10-23
|
||||
// 12:01, plan-1792748906937927069 sent mesh-build-go, mesh-build-node, mosquitto and ssh-client so).
|
||||
//
|
||||
// **A build whose module is not known fails the plan that asked it by its id** (planFailedBuild, moduleAskedAs), as
|
||||
// a failed build that names no module does: a manifest that could not be read is no reason for the plan not to hear.
|
||||
func takeRefused(ctx context.Context, keeper refusalKeeper, result link.BuildResult, module, why string,
|
||||
plans refusedOutcome) {
|
||||
if result.ID != "" {
|
||||
if err := keeper.RecordRefused(ctx, result.ID, module, why); err != nil {
|
||||
fmt.Printf("%s: the refusal could not be kept in the store, only in this controller: %v\n", result.ID, err)
|
||||
}
|
||||
keepRefusalFor(result.ID, why, time.Now())
|
||||
}
|
||||
if module != "" {
|
||||
plans.built(module, why)
|
||||
return
|
||||
}
|
||||
failed := result
|
||||
failed.Failed = why
|
||||
plans.failedBuild(failed)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user