diff --git a/cmd/mesh-controller/release_plan.go b/cmd/mesh-controller/release_plan.go index 92521e9..d748461 100644 --- a/cmd/mesh-controller/release_plan.go +++ b/cmd/mesh-controller/release_plan.go @@ -111,7 +111,9 @@ func isBaseOf(base, to string, edges []inventory.Edge, in map[string]bool) bool } // reachableFrom is the moved modules plus everything that depends on them, through every layer: -// what a merge rebuilds. +// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine +// is not changed by a new build machine, so a built-by edge orders and gates a plan and never +// widens it — the first plan of 2026-10-01 took the whole catalogue along for a controller change. func reachableFrom(moved []string, edges []inventory.Edge) []string { in := map[string]bool{} for _, m := range moved { @@ -120,6 +122,9 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string { for grew := true; grew; { grew = false for _, e := range edges { + if e.Kind == inventory.EdgeBuiltBy { + continue + } if in[e.To] && !in[e.From] { in[e.From] = true grew = true @@ -571,6 +576,23 @@ func plansCommand(ctx context.Context, args []string) error { } return nil } + if len(positionals) == 2 && positionals[0] == "stop" { + p, err := inv.PlanByID(ctx, positionals[1]) + if err != nil { + return err + } + if !p.Open() { + return fmt.Errorf("%s is already %s", p.ID, p.State) + } + p.State = inventory.PlanFailed + p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier) + if err := inv.SavePlan(ctx, p); err != nil { + return err + } + fmt.Printf("%s stopped at tier %d of %d; what was asked still builds and registers, nothing further is asked\n", + p.ID, p.Tier, len(p.Tiers)) + return nil + } plans, err := inv.RecentPlans(ctx, *limit) if err != nil { return err diff --git a/cmd/mesh-controller/release_plan_test.go b/cmd/mesh-controller/release_plan_test.go index 554f378..c2eeb27 100644 --- a/cmd/mesh-controller/release_plan_test.go +++ b/cmd/mesh-controller/release_plan_test.go @@ -18,6 +18,7 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) { {From: "shop-plugin", To: "shop", Kind: inventory.EdgeDeclared}, // a code dependency: the proxy packages the controller's source — same tier {From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages}, + {From: "builder", To: "mesh-controller", Kind: inventory.EdgePackages}, // runtime dependencies: everything source-built is built by the builder {From: "shop", To: "builder", Kind: inventory.EdgeBuiltBy}, {From: "postgres", To: "builder", Kind: inventory.EdgeBuiltBy}, @@ -30,7 +31,9 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) { } // The runtime image moved: everything on it, and what is built by what is on it. set := reachableFrom([]string{"mesh-tools"}, edges) - want := []string{"builder", "mesh-controller", "mesh-tools", "postgres", "route-proxy", "shop", "shop-plugin"} + // What stands on the runtime, and the builder that stands on it; not the controller, which the + // builder merely builds, nor the proxy that packages the controller. + want := []string{"builder", "mesh-tools", "postgres", "shop", "shop-plugin"} if len(set) != len(want) { t.Fatalf("reachable from the runtime: %v, want %v", set, want) } @@ -44,26 +47,31 @@ func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) { if pos["mesh-tools"] != 0 || pos["builder"] != 1 { t.Fatalf("the runtime then the builder: %v", tiers) } - if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"] && pos["mesh-controller"] > pos["builder"]) { + if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) { t.Fatalf("what the builder builds comes after the builder: %v", tiers) } if pos["shop-plugin"] <= pos["shop"] { t.Fatalf("a plugin after what it is declared on: %v", tiers) } - if pos["route-proxy"] != pos["mesh-controller"] { - t.Fatalf("a code dependency is rebuilt in the same tier as its source: %v", tiers) - } if hasCycle(tiers, edges) { t.Fatalf("no cycle here: %v", tiers) } // The controller alone moved: the proxy with it, nothing else. small := reachableFrom([]string{"mesh-controller"}, edges) - if len(small) != 2 { + if len(small) != 3 { t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small) } - if tiers := tiersOf(small, edges); len(tiers) != 1 { - t.Fatalf("both in one tier: %v", tiers) + // The builder packages the controller's source (same tier by that edge) and the controller is + // built by the builder (next tier by that one): the builder first, then the controller and the + // proxy together — a code dependency in one tier, a runtime dependency across tiers. + smallTiers := tiersOf(small, edges) + if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 { + t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers) + } + // The builder alone moved: the builder, and nothing it builds. + if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 { + t.Fatalf("a build machine change rebuilds the build machine alone: %v", only) } // Only a runtime dependency gates on deployment, and only when the module rolls out. diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index e95a5dc..08922ca 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -70,6 +70,9 @@ func argvFor(verb string, args map[string]any) ([]string, error) { } return []string{"builds"}, nil case "plans": + if id := str("stop"); id != "" { + return []string{"plans", "stop", id}, nil + } if id := str("id"); id != "" { return []string{"plans", id}, nil } diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index 71aeea4..0f1a473 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -93,7 +93,10 @@ var ControllerVerbs = []Verb{ }, nil)}, {Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " + "the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.", - Input: schema(map[string]string{"id": "a plan's id (as `plans` lists them): that plan, tier by tier"}, nil)}, + Input: schema(map[string]string{ + "id": "a plan's id (as `plans` lists them): that plan, tier by tier", + "stop": "a plan's id: stop it — what was asked still builds, nothing further is asked", + }, nil)}, {Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.", Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, {Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",