Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef26d4cb0f | ||
|
|
d9a730307c | ||
|
|
9ca7952d5e | ||
|
|
58cb586c37 | ||
|
|
c3c56a69e2 | ||
|
|
63b87b6f7b | ||
|
|
997a4925b0 | ||
|
|
077ddf0eb8 | ||
|
|
c58516f819 | ||
|
|
54b04a7604 | ||
|
|
af025562c7 | ||
|
|
51c8c7ec52 |
@@ -33,12 +33,14 @@ var deliveryOwnerWithin = 10 * time.Second
|
||||
|
||||
// stalledLine is one delivery past its bound, as mesh-delivery's `stalled` says it.
|
||||
type stalledLine struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
For string `json:"for"`
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
ID string `json:"id"`
|
||||
// Number is the pull request's, for a line of a head the forge never announced (novox/hq issue 347).
|
||||
Number int `json:"number,omitempty"`
|
||||
State string `json:"state"`
|
||||
For string `json:"for"`
|
||||
Bound string `json:"bound"`
|
||||
H2 string `json:"h2"`
|
||||
Says string `json:"says"`
|
||||
}
|
||||
|
||||
// operatorsOnly is whether the table leaves H2 nothing to do for the line: the state is the operator's.
|
||||
|
||||
@@ -209,7 +209,9 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
||||
return healthNotYet, fmt.Sprintf("%s reported %q", machine, r.Outcome)
|
||||
}
|
||||
// **No new condition about it**: about the machine itself, or naming the module on that machine,
|
||||
// raised since the judging began. The gate's own are not evidence about the build.
|
||||
// raised since the judging began. The gate's own are not evidence about the build. A fault that was
|
||||
// there at the send and reopened since is not new; one that had cleared before the send and came back
|
||||
// after it is (OpenAt, novox/hq issue 348).
|
||||
if f.judged {
|
||||
if f.openErr != nil {
|
||||
return healthNotYet, "what is wrong cannot be read, so whether the build made anything wrong is not known: " +
|
||||
@@ -219,7 +221,7 @@ func judgeHealth(module, component string, m catalogue.Manifest, machine string,
|
||||
// A wait for a person's new login, or for a directory used as found to be handed over, is the module's
|
||||
// reading, not a fault raised since the send: the gate reads it from the statement below (ADR 0254,
|
||||
// novox/hq issue 339).
|
||||
if c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
|
||||
if c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindReloginNeeded || c.Kind == kindUsedAsFound {
|
||||
continue
|
||||
}
|
||||
onIt := c.Subject.Machine == machine || slices.Contains(c.Subject.Also, machine) ||
|
||||
@@ -331,7 +333,7 @@ func aboutTheMachine(machine string, moved []string, since time.Time, f gateFact
|
||||
aboutIt := c.Subject.Scope == conditions.ScopeMachine && (c.Subject.ID == machine || c.Subject.Machine == machine ||
|
||||
slices.Contains(c.Subject.Also, machine))
|
||||
// A directory used as found waits for a person, whatever the send did (novox/hq issue 339).
|
||||
if !aboutIt || c.Source == gateProbe || c.Raised.Before(since) || c.Kind == kindUsedAsFound {
|
||||
if !aboutIt || c.Source == gateProbe || c.OpenAt(since) || c.Kind == kindUsedAsFound {
|
||||
kept = append(kept, c)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/conditions"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/lease"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq issue 348: on 2026-10-09 the control node's resolver stopped answering on its private address
|
||||
// at 10:57:57 UTC; the machine's network condition was raised at 10:58:45. The node-engine and the
|
||||
// controller were sent at 10:59:34. The new node-engine's first statement judged the names once — unknown,
|
||||
// "one look failed; a second decides" — and that statement cleared the condition, though its last evidence
|
||||
// still said "connection refused". The next look raised it again at 11:00:23, after the send, and both
|
||||
// builds failed their gate at 11:10 with "raised since it was sent" and were put back, for a fault that
|
||||
// began before they were sent.
|
||||
|
||||
// namesRefused is the control node's names part as its node-engine said it in the outage: its own
|
||||
// resolver, at its own address, refusing.
|
||||
func namesRefused(state string, streak int) link.NetworkPart {
|
||||
p := link.NetworkPart{Part: link.PartNames, State: state, Since: h0, Streak: streak}
|
||||
if state == link.StateUnhealthy {
|
||||
p.Reason = "1 of its 2 resolvers do not answer as the mesh's do"
|
||||
p.Said = "10.77.0.1 — anchor.internal (IPv4): read udp 10.77.0.1:35244->10.77.0.1:53: read: connection refused"
|
||||
p.Toward = []string{"10.77.0.1"}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func networkSaying(parts ...link.NetworkPart) *link.NetworkHealth {
|
||||
state := link.StateHealthy
|
||||
for _, p := range parts {
|
||||
switch {
|
||||
case p.State == link.StateUnhealthy:
|
||||
state = link.StateUnhealthy
|
||||
case p.State == link.StateUnknown && state == link.StateHealthy:
|
||||
state = link.StateUnknown
|
||||
}
|
||||
}
|
||||
return &link.NetworkHealth{State: state, Since: h0, Parts: parts}
|
||||
}
|
||||
|
||||
// TestReplay348 replays the statements of the outage: the condition raised before the send is not
|
||||
// cleared by the restarted engine's first, undecided statement, and the gate does not count it against
|
||||
// the builds sent after it began.
|
||||
func TestReplay348(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
inv, k := open.inventory, conditionsFrom
|
||||
say := func(at time.Time, n *link.NetworkHealth) {
|
||||
t.Helper()
|
||||
if err := stateHealth(ctx, inv, k, "anchor", link.Health{Contract: link.ReadinessContract, At: at, Network: n}, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
network := func() (conditions.Condition, bool) {
|
||||
t.Helper()
|
||||
list, err := k.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.Key == "machine.anchor.network" {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return conditions.Condition{}, false
|
||||
}
|
||||
|
||||
// 10:58:45 — the second failing look: raised.
|
||||
say(h0, networkSaying(namesRefused(link.StateUnhealthy, 2)))
|
||||
raised, ok := network()
|
||||
if !ok {
|
||||
t.Fatal("the resolver refusing on the control node raised nothing")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
sent := time.Now().UTC()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// 10:59:42 — the restarted engine's first statement: one look failed, a second decides.
|
||||
say(h0.Add(time.Minute), networkSaying(namesRefused(link.StateUnknown, 1)))
|
||||
if _, ok := network(); !ok {
|
||||
t.Fatal("a statement that judged nothing yet cleared the condition: the restarted engine's first look " +
|
||||
"said the fault was gone while it still refused")
|
||||
}
|
||||
// 11:00:23 — its second look: unhealthy again, the same raising.
|
||||
say(h0.Add(2*time.Minute), networkSaying(namesRefused(link.StateUnhealthy, 2)))
|
||||
again, ok := network()
|
||||
if !ok || !again.Raised.Equal(raised.Raised) || again.Count != 1 {
|
||||
t.Fatalf("the same raising was not kept: raised %s (first %s), count %d", again.Raised, raised.Raised, again.Count)
|
||||
}
|
||||
|
||||
// The gate on the control node, for a build sent after the fault began.
|
||||
open2, err := k.Open(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := gateFacts{judged: true, open: open2}
|
||||
if w := aboutTheMachine("anchor", []string{"mesh-host"}, sent, f); w.whole != "" || len(w.on) != 0 {
|
||||
t.Fatalf("a fault from before the send held the build: %+v", w)
|
||||
}
|
||||
|
||||
// Decided healthy: cleared.
|
||||
say(h0.Add(3*time.Minute), networkSaying(link.NetworkPart{Part: link.PartNames, State: link.StateHealthy, Since: h0}))
|
||||
if c, ok := network(); ok {
|
||||
t.Fatalf("a statement that decides the names healthy left %s open", c.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// A fault there at the send, cleared and reopened after it, is not raised since the send; one that cleared
|
||||
// before the send and came back after it is — a send that breaks a recovered machine fails its gate (review
|
||||
// of mesh-controller PR 179, A2). Read through OpenAt, by both of the gate's readings.
|
||||
func TestAFaultThatFlappedAfterTheSendIsNotTheSendsAndOneThatRecoveredBeforeItIs(t *testing.T) {
|
||||
since := h0
|
||||
network := func(first time.Time, gaps ...conditions.Gap) conditions.Condition {
|
||||
raised := since.Add(time.Minute)
|
||||
if len(gaps) > 0 {
|
||||
raised = gaps[len(gaps)-1].Reopened
|
||||
}
|
||||
return conditions.Condition{Key: "machine.anchor.network", Kind: kindMachineNetwork,
|
||||
Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: "anchor", Machine: "anchor"},
|
||||
Summary: "anchor's network is not healthy", Source: sourceNetwork, First: first, Gaps: gaps, Raised: raised}
|
||||
}
|
||||
held := func(c conditions.Condition) bool {
|
||||
return aboutTheMachine("anchor", []string{"mesh-controller"}, since, gateFacts{judged: true,
|
||||
open: []conditions.Condition{c}}).whole != ""
|
||||
}
|
||||
// The day's case: raised before the send, cleared 8 s after it, reopened 49 s after it.
|
||||
flapped := network(since.Add(-49*time.Second),
|
||||
conditions.Gap{Cleared: since.Add(8 * time.Second), Reopened: since.Add(49 * time.Second)})
|
||||
if held(flapped) {
|
||||
t.Fatal("a fault there at the send, flapping after it, held the machine")
|
||||
}
|
||||
// Recovered before the send, broken again after it: the send's.
|
||||
recovered := network(since.Add(-time.Hour),
|
||||
conditions.Gap{Cleared: since.Add(-30 * time.Second), Reopened: since.Add(20 * time.Second)})
|
||||
if !held(recovered) {
|
||||
t.Fatal("a machine recovered at the send and broken after it passed the gate")
|
||||
}
|
||||
// An older gap, before the send, and the fault there at the send: not the send's.
|
||||
twice := network(since.Add(-time.Hour),
|
||||
conditions.Gap{Cleared: since.Add(-50 * time.Minute), Reopened: since.Add(-45 * time.Minute)},
|
||||
conditions.Gap{Cleared: since.Add(10 * time.Second), Reopened: since.Add(30 * time.Second)})
|
||||
if held(twice) {
|
||||
t.Fatal("a fault there at the send, with an older gap, held the machine")
|
||||
}
|
||||
// Raised after the send, never cleared: the send's.
|
||||
if !held(network(time.Time{})) {
|
||||
t.Fatal("a fault raised after the send held nothing")
|
||||
}
|
||||
// And a module's own, through judgeHealth.
|
||||
at := since.Add(2 * time.Minute)
|
||||
g := gateFacts{judged: true, now: at, reports: map[string]inventory.Reported{"anchor": {Node: "anchor",
|
||||
Outcome: inventory.OutcomeApplied, At: &at, Current: true}}, engines: map[string]string{},
|
||||
served: map[string]served{}, rolledBack: map[string][]lease.Rollback{},
|
||||
open: []conditions.Condition{{Key: "provider.app.anchor.x.failing", Subject: conditions.Subject{
|
||||
Scope: conditions.ScopeProvider, ID: "app.anchor.x", Machine: "anchor"}, Summary: "failing",
|
||||
First: since.Add(-time.Hour), Raised: since.Add(time.Minute),
|
||||
Gaps: []conditions.Gap{{Cleared: since.Add(5 * time.Second), Reopened: since.Add(time.Minute)}}}}}
|
||||
if _, why := judgeHealth("app", "", catalogue.Manifest{Module: "app"}, "anchor", since, g); strings.HasPrefix(why, "raised since it was sent") {
|
||||
t.Fatalf("a module's own fault there at the send: %s", why)
|
||||
}
|
||||
g.open[0].Gaps[0].Cleared = since.Add(-5 * time.Second)
|
||||
if _, why := judgeHealth("app", "", catalogue.Manifest{Module: "app"}, "anchor", since, g); !strings.HasPrefix(why, "raised since it was sent") {
|
||||
t.Fatalf("a module's own fault, recovered at the send and back after it, was not counted: %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
// Only an undecided part holds a condition that names it; a condition about another part clears, and a
|
||||
// statement unknown as a whole holds every part (review of PR 179, A4). Pure.
|
||||
func TestAnUndecidedPartHoldsOnlyWhatNamesIt(t *testing.T) {
|
||||
f := netFacts(map[string]*inventory.NetworkHealth{
|
||||
"anchor": aNetwork(link.StateUnknown, inventory.NetworkPart{Part: link.PartNames, State: link.StateUnknown, Streak: 1},
|
||||
inventory.NetworkPart{Part: link.PartRoute, State: link.StateHealthy}),
|
||||
"laptop": aNetwork(link.StateHealthy, inventory.NetworkPart{Part: link.PartNames, State: link.StateHealthy}),
|
||||
"spare": aNetwork(link.StateStarting, inventory.NetworkPart{Part: link.PartTunnel, State: link.StateHealthy}),
|
||||
"other": aNetwork(link.StateStarting, inventory.NetworkPart{Part: link.PartTunnel, State: link.StateStarting}),
|
||||
})
|
||||
u := undecidedParts(f)
|
||||
if !u["anchor"][link.PartNames] || u["anchor"][link.PartRoute] || u["laptop"] != nil || !u["spare"]["*"] ||
|
||||
!u["other"][link.PartTunnel] || u["other"]["*"] {
|
||||
t.Fatalf("undecided: %v", u)
|
||||
}
|
||||
about := func(machine, said string, also ...string) conditions.Condition {
|
||||
return conditions.Condition{Subject: conditions.Subject{Scope: conditions.ScopeMachine, ID: machine,
|
||||
Machine: machine, Also: also}, Evidence: []conditions.Evidence{{Said: said}}}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
c conditions.Condition
|
||||
held bool
|
||||
}{
|
||||
{about("anchor", "names since 2026-10-09 10:58:45 UTC: 10.77.0.1 — refused"), true},
|
||||
{about("anchor", "route since 2026-10-09 10:58:45 UTC: no default route"), false},
|
||||
{about("laptop", "names since 2026-10-09 10:58:45 UTC: refused"), false},
|
||||
{about("spare", "route since …: no default route"), true},
|
||||
{about("hub", "anchor: names: refused", "anchor"), true},
|
||||
{about("hub", "anchor: tunnel: no handshake", "anchor"), false},
|
||||
} {
|
||||
if got := heldUndecided(c.c, u); got != c.held {
|
||||
t.Errorf("%s %q held %v, want %v", c.c.Subject.Machine, c.c.Evidence[0].Said, got, c.held)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A release walks its modules without a record per module: D10 counts what its tier names as rolling,
|
||||
// so the node-engine a release walks is not "behind, and no plan is rolling it out" on its first machine.
|
||||
func TestAReleaseRollsOutWhatItsTierNames(t *testing.T) {
|
||||
plans := []inventory.Plan{
|
||||
{ID: "release-1", State: inventory.PlanRolling, Tiers: [][]string{{"mesh-host"}}, Modules: map[string]*inventory.PlanModule{}},
|
||||
{ID: "plan-2", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{"letta": {}}},
|
||||
}
|
||||
got := rollingModules(plans)
|
||||
if !got["mesh-host"] || !got["letta"] || len(got) != 2 {
|
||||
t.Fatalf("rolling: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
"github.com/novox/mesh-controller/internal/link"
|
||||
)
|
||||
|
||||
// novox/hq issue 349: on 2026-10-09 the plan of mesh-catalog at a082615b (the merge of a security fix to the
|
||||
// forge's module) was "superseded at tier 0 by" the plan at 8ff8197a, the merge before it, which the
|
||||
// catch-up acted on late; what the later plan had not built was folded into a plan at the commit before the
|
||||
// fix. Plans of one branch are ordered by when the forge made their merges, and a merge older than an open
|
||||
// plan of its branch is planned at that plan's commit.
|
||||
|
||||
// TestTwoMergesActedOnInReverseOrderBuildTheNewerCommit replays it: the later merge acted on first, the
|
||||
// earlier one second (the catch-up). One plan is left open, at the later commit, and it builds both.
|
||||
func TestTwoMergesActedOnInReverseOrderBuildTheNewerCommit(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
asksWithPaths(t)
|
||||
for _, m := range []string{"gitea", "notes"} {
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m, Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
at := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second)
|
||||
fix := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "a082615bfix",
|
||||
MergedAt: at.Add(2 * time.Minute).Format(time.RFC3339Nano),
|
||||
Paths: []string{"modules/gitea/module.json"}, ModuleDirs: []string{"modules/gitea"}, ModuleDirsSaid: true}
|
||||
before := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "8ff8197abefore",
|
||||
MergedAt: at.Format(time.RFC3339Nano),
|
||||
Paths: []string{"modules/notes/module.json"}, ModuleDirs: []string{"modules/notes"}, ModuleDirsSaid: true}
|
||||
for _, m := range []link.SourceMoved{fix, before} {
|
||||
if err := (following{open: open}).SourceMoved(ctx, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
plans, err := open.inventory.OpenPlans(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plans) != 1 {
|
||||
var said []string
|
||||
for _, p := range plans {
|
||||
said = append(said, p.ID+" "+p.Commit+" "+p.Note)
|
||||
}
|
||||
t.Fatalf("open plans: %s", strings.Join(said, "; "))
|
||||
}
|
||||
p := plans[0]
|
||||
if p.Commit != fix.Commit {
|
||||
t.Fatalf("the open plan builds %s, not the newer commit %s", p.Commit, fix.Commit)
|
||||
}
|
||||
for _, m := range []string{"gitea", "notes"} {
|
||||
if _, has := p.Modules[m]; !has {
|
||||
t.Fatalf("the open plan at the newer commit does not build %s: %v", m, p.Modules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A late older merge after the newer plan is done (review of PR 179, A1): what it moved is built from the
|
||||
// newer commit, and what the newer merge already looked at is not built again at the older one.
|
||||
func TestALateMergeAfterTheNewerPlanEndedBuildsTheNewerCommit(t *testing.T) {
|
||||
open := aCatalogueMesh(t)
|
||||
ctx := t.Context()
|
||||
asksWithPaths(t)
|
||||
for _, m := range []string{"gitea", "notes"} {
|
||||
if err := open.inventory.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"},
|
||||
inventory.Source{Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/" + m, Ref: "main",
|
||||
BuiltFrom: "c0", Head: "c0"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
at := time.Now().UTC().Add(-20 * time.Minute).Truncate(time.Second)
|
||||
fix := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "a082615bfix",
|
||||
MergedAt: at.Add(2*time.Minute + 500*time.Millisecond).Format(time.RFC3339Nano),
|
||||
Paths: []string{"modules/gitea/module.json"}, ModuleDirs: []string{"modules/gitea"}, ModuleDirsSaid: true}
|
||||
if err := (following{open: open}).SourceMoved(ctx, fix); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plans, err := open.inventory.OpenPlans(ctx)
|
||||
if err != nil || len(plans) != 1 {
|
||||
t.Fatalf("%v %v", plans, err)
|
||||
}
|
||||
done := plans[0]
|
||||
done.State = inventory.PlanDone
|
||||
if err := open.inventory.SavePlan(ctx, &done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := open.inventory.PlanByID(ctx, done.ID); err != nil || !got.Merged.Equal(at.Add(2*time.Minute+500*time.Millisecond)) {
|
||||
t.Fatalf("the merge time kept is %s, not to the nanosecond (%v)", got.Merged, err)
|
||||
}
|
||||
before := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "8ff8197abefore",
|
||||
MergedAt: at.Format(time.RFC3339Nano),
|
||||
Paths: []string{"modules/notes/module.json", "modules/gitea/module.json"},
|
||||
ModuleDirs: []string{"modules/notes", "modules/gitea"}, ModuleDirsSaid: true}
|
||||
if err := (following{open: open}).SourceMoved(ctx, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plans, err = open.inventory.OpenPlans(ctx)
|
||||
if err != nil || len(plans) != 1 {
|
||||
t.Fatalf("open plans after the late merge: %+v %v", plans, err)
|
||||
}
|
||||
p := plans[0]
|
||||
if p.Commit != fix.Commit {
|
||||
t.Fatalf("the late merge was planned at %s, not the newer commit %s", p.Commit, fix.Commit)
|
||||
}
|
||||
if _, has := p.Modules["notes"]; !has {
|
||||
t.Fatalf("what only the late merge moved is not built: %v", p.Modules)
|
||||
}
|
||||
if _, has := p.Modules["gitea"]; has {
|
||||
t.Fatalf("what the newer merge already built is built again: %v", p.Modules)
|
||||
}
|
||||
}
|
||||
|
||||
// Pure: the branch's order is the merges', where both plans know it; and a later merge of the branch is
|
||||
// found in any state, never a release's, another repository's or another branch's.
|
||||
func TestTheBranchOrderIsTheMerges(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 9, 10, 0, 0, 0, time.UTC)
|
||||
newerMerge := inventory.Plan{ID: "plan-1", Repository: "novox/mesh-catalog", Branch: "main", Commit: "a082615b",
|
||||
Merged: t0.Add(time.Minute), Created: t0.Add(2 * time.Minute), State: inventory.PlanRolling}
|
||||
olderMerge := inventory.Plan{ID: "plan-2", Repository: "novox/mesh-catalog", Branch: "main", Commit: "8ff8197a",
|
||||
Merged: t0, Created: t0.Add(10 * time.Minute), State: inventory.PlanBuilding}
|
||||
if earlierOnTheBranch(newerMerge, olderMerge) || !earlierOnTheBranch(olderMerge, newerMerge) {
|
||||
t.Fatal("ordered by when the plans were made, not by when the merges were")
|
||||
}
|
||||
if _, closed := supersededBy(olderMerge, []inventory.Plan{newerMerge}, func(string) bool { return true }); len(closed) != 0 {
|
||||
t.Fatalf("the plan of an older merge superseded a newer one: %s", closed[0].Note)
|
||||
}
|
||||
unknown := newerMerge
|
||||
unknown.Merged = time.Time{}
|
||||
if !earlierOnTheBranch(unknown, olderMerge) {
|
||||
t.Fatal("without a merge time the plans' own order does not stand")
|
||||
}
|
||||
same := olderMerge
|
||||
same.Merged = newerMerge.Merged
|
||||
if !earlierOnTheBranch(newerMerge, same) || earlierOnTheBranch(same, newerMerge) {
|
||||
t.Fatal("one merge time: the plans' own order does not stand")
|
||||
}
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "8ff8197a",
|
||||
MergedAt: t0.Add(500 * time.Millisecond).Format(time.RFC3339Nano)}
|
||||
newerMerge.State = inventory.PlanDone
|
||||
if !laterOnTheBranch(m, newerMerge) {
|
||||
t.Fatal("a later merge of the branch, its plan done, was not found")
|
||||
}
|
||||
for name, change := range map[string]func(p *inventory.Plan, m *link.SourceMoved){
|
||||
"another branch": func(_ *inventory.Plan, m *link.SourceMoved) { m.Base = "release" },
|
||||
"another repository": func(p *inventory.Plan, _ *link.SourceMoved) { p.Repository = "novox/mesh-controller" },
|
||||
"a release": func(p *inventory.Plan, _ *link.SourceMoved) { p.Release = &inventory.PlanRelease{} },
|
||||
"no merge time": func(p *inventory.Plan, _ *link.SourceMoved) { p.Merged = time.Time{} },
|
||||
"the same commit": func(p *inventory.Plan, m *link.SourceMoved) { m.Commit = p.Commit },
|
||||
"an older merge": func(p *inventory.Plan, _ *link.SourceMoved) { p.Merged = t0 },
|
||||
"the same moment": func(p *inventory.Plan, _ *link.SourceMoved) { p.Merged = t0.Add(500 * time.Millisecond) },
|
||||
"an unreadable time": func(_ *inventory.Plan, m *link.SourceMoved) { m.MergedAt = "yesterday" },
|
||||
} {
|
||||
p, mm := newerMerge, m
|
||||
change(&p, &mm)
|
||||
if laterOnTheBranch(mm, p) {
|
||||
t.Errorf("%s was taken as a later merge of the branch", name)
|
||||
}
|
||||
}
|
||||
// To the nanosecond: two merges within a second keep their order.
|
||||
m.MergedAt = t0.Add(time.Minute + 200*time.Millisecond).Format(time.RFC3339Nano)
|
||||
if !laterOnTheBranch(m, inventory.Plan{Repository: "novox/mesh-catalog", Branch: "main", Commit: "x",
|
||||
Merged: t0.Add(time.Minute + 700*time.Millisecond)}) {
|
||||
t.Fatal("merges within one second lost their order")
|
||||
}
|
||||
}
|
||||
|
||||
// A merge time with a fraction of a second is kept whole in its plan, and two merges within one second keep
|
||||
// their order through the plans and the lookup (review of PR 179).
|
||||
func TestAMergeTimeKeepsItsFractionOfASecond(t *testing.T) {
|
||||
at := "2026-10-09T10:57:52.123456789Z"
|
||||
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c1", MergedAt: at}, nil, nil)
|
||||
want := time.Date(2026, 10, 9, 10, 57, 52, 123456789, time.UTC)
|
||||
if !p.Merged.Equal(want) {
|
||||
t.Fatalf("the plan's merge time is %s, not %s", p.Merged, want)
|
||||
}
|
||||
earlier := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c0",
|
||||
MergedAt: "2026-10-09T10:57:52.123456788Z"}, nil, nil)
|
||||
earlier.Created = p.Created.Add(time.Second) // made after, merged before
|
||||
if !earlierOnTheBranch(earlier, p) || earlierOnTheBranch(p, earlier) {
|
||||
t.Fatal("two merges a nanosecond apart lost their order")
|
||||
}
|
||||
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "c0", MergedAt: "2026-10-09T10:57:52.123456788Z"}
|
||||
if !laterOnTheBranch(m, p) {
|
||||
t.Fatal("a merge a nanosecond later was not found as the later one")
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,9 @@ func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.
|
||||
problems = append(problems, err.Error())
|
||||
}
|
||||
}
|
||||
undecided := undecidedParts(f)
|
||||
for _, c := range open {
|
||||
if !slices.Contains(networkKinds, c.Kind) || said[c.Key] {
|
||||
if !slices.Contains(networkKinds, c.Kind) || said[c.Key] || heldUndecided(c, undecided) {
|
||||
continue
|
||||
}
|
||||
why := "no machine says it any more"
|
||||
@@ -116,6 +117,59 @@ func judgeNetworks(ctx context.Context, inv *inventory.Inventory, k *conditions.
|
||||
return nil
|
||||
}
|
||||
|
||||
// undecidedParts is, per machine, every part of its newest statement not yet judged: starting, or unknown
|
||||
// — one look failed and a second decides (novox/hq issue 348). Such a part does not say its fault is gone.
|
||||
// A statement whose parts are all decided but whose whole is unknown or starting holds every part. Pure.
|
||||
//
|
||||
// On 2026-10-09 the control node's resolver refused every question from 10:58 to 11:18 UTC. A build of
|
||||
// the node-engine sent at 10:59:34 restarted it; its first statement judged the names once (unknown, "one
|
||||
// look failed; a second decides"), and that statement cleared the control node's network condition while
|
||||
// its last evidence still said "connection refused". The second look raised it again forty seconds later —
|
||||
// after the send — and the gate failed the build for a fault from before it.
|
||||
func undecidedParts(f networkFacts) map[string]map[string]bool {
|
||||
out := map[string]map[string]bool{}
|
||||
for m, h := range f.healths {
|
||||
if h.Network == nil {
|
||||
continue
|
||||
}
|
||||
parts := map[string]bool{}
|
||||
for _, p := range h.Network.Parts {
|
||||
if p.State == link.StateUnknown || p.State == link.StateStarting {
|
||||
parts[p.Part] = true
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 && (h.Network.State == link.StateUnknown || h.Network.State == link.StateStarting) {
|
||||
parts["*"] = true
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
out[m] = parts
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heldUndecided says an open network condition is kept rather than cleared: a part its newest evidence
|
||||
// names is undecided in the newest statement of a machine it is about. A condition about other parts
|
||||
// clears as before. Pure.
|
||||
func heldUndecided(c conditions.Condition, undecided map[string]map[string]bool) bool {
|
||||
said := ""
|
||||
if len(c.Evidence) > 0 {
|
||||
said = c.Evidence[0].Said
|
||||
}
|
||||
for _, m := range append([]string{c.Subject.Machine}, c.Subject.Also...) {
|
||||
parts := undecided[m]
|
||||
if parts["*"] {
|
||||
return true
|
||||
}
|
||||
for part := range parts {
|
||||
if strings.Contains(said, part+" since ") || strings.Contains(said, part+": ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pointed is one machine's failing part that points at another machine.
|
||||
type pointed struct {
|
||||
from string
|
||||
|
||||
@@ -71,8 +71,14 @@ func judgeCLI(node string, asked link.CLIAsked, nodes []inventory.Node, control
|
||||
"terminal is the one control-node's operator account", len(control))}
|
||||
}
|
||||
if control[0] == node {
|
||||
return cliVerdict{terminal: true, why: fmt.Sprintf("the controller's terminal: %s on the control-node %s",
|
||||
asked.Account, node)}
|
||||
// **A person at a terminal, not a service of the operator's** (review of ADR 0272): the tool runner and the
|
||||
// account's user units run as the operator too, and only a login session is the terminal.
|
||||
if asked.Session == "" {
|
||||
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: %s on %s asked from no login session — a "+
|
||||
"service or a user unit running as the operator, not a person at a terminal", asked.Account, node)}
|
||||
}
|
||||
return cliVerdict{terminal: true, why: fmt.Sprintf("the controller's terminal: %s on the control-node %s, "+
|
||||
"login session %s", asked.Account, node, asked.Session)}
|
||||
}
|
||||
return cliVerdict{why: fmt.Sprintf("not the controller's terminal: agents on %s may run as %s, so a "+
|
||||
"terminal-only change is made from the control-node %s (novox/hq ADR 0272)", node, asked.Account, control[0])}
|
||||
@@ -148,7 +154,7 @@ func runForMeshCLI(ctx context.Context, node string, asked link.CLIAsked, v cliV
|
||||
verb, line = cliVerb, composed
|
||||
}
|
||||
cmd := selfCommand(ctx, line)
|
||||
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb)
|
||||
cmd.Env = commandEnvironment(fmt.Sprintf("%s through mesh-cli on %s", asked.Account, node), verb, v.terminal)
|
||||
// No standard input: a command that reads one gets nothing, and fails saying so (ADR 0272 §5).
|
||||
cmd.Stdin = nil
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
@@ -25,7 +25,7 @@ var cliNodes = []inventory.Node{
|
||||
}
|
||||
|
||||
func asked(account string, uid uint32, line ...string) link.CLIAsked {
|
||||
return link.CLIAsked{Line: line, Account: account, UID: uid}
|
||||
return link.CLIAsked{Line: line, Account: account, UID: uid, Session: "session-1.scope"}
|
||||
}
|
||||
|
||||
// Who is the controller's terminal, and who is an ordinary call or refused (novox/hq ADR 0272 §4).
|
||||
@@ -264,3 +264,42 @@ func TestTheCallsVerbNeverShowsAMeshCLILinesAnswer(t *testing.T) {
|
||||
t.Fatalf("calls showed a mesh-cli line's answer: %s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// **Only the terminal's line carries the terminal's mark** (review of ADR 0272): a mark the serving controller's
|
||||
// environment holds — leaked, or set by anything — is stripped from every other command line it runs, a verb's and
|
||||
// an ordinary mesh-cli line alike, so neither reads as the terminal.
|
||||
func TestTheTerminalsMarkIsStrippedFromEveryOtherLine(t *testing.T) {
|
||||
t.Setenv(echoEnvironment, "1")
|
||||
t.Setenv(cliTerminalVar, "1")
|
||||
t.Setenv(servedVar, "1")
|
||||
for _, env := range [][]string{commandEnvironment("someone", "status", false)} {
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, cliTerminalVar+"=") {
|
||||
t.Fatalf("a verb's line carries the terminal's mark: %s", kv)
|
||||
}
|
||||
}
|
||||
}
|
||||
a := runForMeshCLI(context.Background(), "laptop", asked("operator", 1000, "status"), cliVerdict{why: "not the terminal"})
|
||||
if got := string(a.Stdout); !strings.Contains(got, "terminal=false") || !strings.Contains(got, `verb="mesh-cli"`) {
|
||||
t.Fatalf("an ordinary line with the mark in the serving environment ran as %s", got)
|
||||
}
|
||||
a = runForMeshCLI(context.Background(), "control", asked("operator", 1000, "status"), cliVerdict{terminal: true})
|
||||
if got := string(a.Stdout); !strings.Contains(got, "terminal=true") {
|
||||
t.Fatalf("the terminal's line ran as %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Only a login session is the terminal** (review of ADR 0272): the operator's account on the control-node, asking
|
||||
// from a service — the tool runner, a user unit — and not a login session, is an ordinary call.
|
||||
func TestOnlyALoginSessionIsTheTerminal(t *testing.T) {
|
||||
control := []string{"control"}
|
||||
v := judgeCLI("control", link.CLIAsked{Line: []string{"status"}, Account: "operator", UID: 1000}, cliNodes, control)
|
||||
if v.terminal || v.refused != "" || !strings.Contains(v.why, "login session") {
|
||||
t.Fatalf("a line from no login session reads %+v", v)
|
||||
}
|
||||
v = judgeCLI("control", link.CLIAsked{Line: []string{"status"}, Account: "operator", UID: 1000, Session: "session-3.scope"},
|
||||
cliNodes, control)
|
||||
if !v.terminal {
|
||||
t.Fatalf("a line from a login session on the control-node reads %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,11 +758,14 @@ func stalledWords(l stalledLine, o conditions.Observation) (headline, explanatio
|
||||
if o.Resolver == conditions.ResolverOperator {
|
||||
needs = "push a new commit to its branch; the forge announces it and the mesh checks it."
|
||||
}
|
||||
return fmt.Sprintf("A pull request of %s has had no merge check %s", name, long),
|
||||
fmt.Sprintf("A pull request of %s has been open %s on a branch that requires the merge check, and the "+
|
||||
"mesh was never asked to check it: the forge never announced it. It cannot merge until it is checked.",
|
||||
name, long),
|
||||
fmt.Sprintf("The pull request of %s has a merge check now, or is closed", name), needs, nil
|
||||
pull := "A pull request of " + name
|
||||
if l.Number > 0 {
|
||||
pull = fmt.Sprintf("Pull request %s #%d", name, l.Number)
|
||||
}
|
||||
return fmt.Sprintf("%s has had no merge check %s", pull, long),
|
||||
fmt.Sprintf("%s has been open %s on a branch that requires the merge check, and the mesh was never asked "+
|
||||
"to check it: the forge never announced it. It cannot merge until it is checked.", pull, long),
|
||||
fmt.Sprintf("%s has a merge check now, or is closed", pull), needs, nil
|
||||
}
|
||||
held := l.State
|
||||
if held == "" {
|
||||
|
||||
@@ -314,7 +314,7 @@ func TestANeedNoNotificationAnswersNamesTheMeshMCPServer(t *testing.T) {
|
||||
// as a stalled line in state `unannounced` — no delivery exists, so there is nothing to stop, release or close —
|
||||
// and the operator's one act is a new commit on its branch, which the forge announces.
|
||||
func TestAnUnannouncedPullRequestSaysToPushANewCommit(t *testing.T) {
|
||||
l := stalledLine{ID: "novox/hq@bfe82315f42c", State: "unannounced", For: "11m0s", Bound: "10m0s",
|
||||
l := stalledLine{ID: "novox/hq@bfe82315f42c", Number: 243, State: "unannounced", For: "11m0s", Bound: "10m0s",
|
||||
H2: "none: the forge never announced it, so there is no delivery to close — the operator's",
|
||||
Says: "novox/hq#243 is open on main, which requires the merge check, and its head has had no merge check"}
|
||||
obs := stalledObservations([]stalledLine{l})
|
||||
@@ -325,7 +325,8 @@ func TestAnUnannouncedPullRequestSaysToPushANewCommit(t *testing.T) {
|
||||
if strings.Contains(o.Needs, "stop") || strings.Contains(o.Needs, "release") || !strings.Contains(o.Needs, "commit") {
|
||||
t.Fatalf("it says to %q", o.Needs)
|
||||
}
|
||||
if !strings.Contains(o.Headline, "no merge check") || !strings.Contains(o.Explanation, "never") {
|
||||
if !strings.Contains(o.Headline, "no merge check") || !strings.Contains(o.Headline, "#243") ||
|
||||
!strings.Contains(o.Explanation, "never") {
|
||||
t.Fatalf("it reads %q / %q", o.Headline, o.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1239,6 +1239,10 @@ func planCommand(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A module left out is not in the body, so the diff alone would say "nothing would change" for
|
||||
// a module just assigned whose settings cannot compose — success-shaped silence. Said first, with
|
||||
// why, as push and the plain plan say it (novox/hq ADR 0163, rule 6).
|
||||
reportLeftOut(args[0], declared)
|
||||
return writePlanDiff(ctx, open.inventory, args[0], body)
|
||||
}
|
||||
if *asJSON {
|
||||
|
||||
@@ -252,6 +252,10 @@ func askEveryResolver(ctx context.Context, resolvers map[string]string, places [
|
||||
v.wrong[0], andMore(len(v.wrong)-1))
|
||||
} else {
|
||||
// Nothing but silence: held for the next run, which raises it if the resolver is still silent.
|
||||
// Refused on every try too: a few hundred milliseconds of refusals is a resolver restarting as
|
||||
// well as one that stopped, and the two looks a finding needs are this run and the next
|
||||
// (novox/hq issue 348). The machine's own node-engine is the fast detector: its names check
|
||||
// raised the control node's resolver within a minute on 2026-10-09.
|
||||
o.Confirm = true
|
||||
o.Summary = fmt.Sprintf("the mesh's resolver on %s does not answer: %d of the %d question(s) about the "+
|
||||
"machines' names went unanswered, each asked %d times — the first, %s", node, len(v.unanswered), v.asked,
|
||||
@@ -1040,12 +1044,7 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
||||
return nil, err
|
||||
}
|
||||
hostVersions := deliveredVersions(shelf[hostModule])
|
||||
rolling := map[string]bool{}
|
||||
for _, p := range plans {
|
||||
for m := range p.Modules {
|
||||
rolling[m] = true
|
||||
}
|
||||
}
|
||||
rolling := rollingModules(plans)
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1103,6 +1102,26 @@ func probeCoreBuilds(ctx context.Context, d *doctor) ([]conditions.Observation,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rollingModules is every module an open plan is rolling out: those it keeps a record of, and those its
|
||||
// tiers name. **A release keeps no record per module** — its walk is per machine, its modules only in its
|
||||
// tier — so reading the records alone, D10 said "no plan is rolling them out" about the node-engine while
|
||||
// a release walked it, and the gate on that release's first machine waited on what its own send causes
|
||||
// (novox/hq issue 348). Pure.
|
||||
func rollingModules(plans []inventory.Plan) map[string]bool {
|
||||
rolling := map[string]bool{}
|
||||
for _, p := range plans {
|
||||
for m := range p.Modules {
|
||||
rolling[m] = true
|
||||
}
|
||||
for _, tier := range p.Tiers {
|
||||
for _, m := range tier {
|
||||
rolling[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return rolling
|
||||
}
|
||||
|
||||
// deliveredVersions are the versions a module's registered build is delivered as: the last element
|
||||
// of every resource path under a `versions/` directory, which registration filled from the artifact's
|
||||
// digest (catalogue `${version}`). The node-engine names itself by that directory.
|
||||
|
||||
@@ -188,11 +188,13 @@ func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inv
|
||||
for _, name := range set {
|
||||
modules[name] = &inventory.PlanModule{}
|
||||
}
|
||||
merged, _ := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||
return inventory.Plan{
|
||||
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
||||
Repository: m.Owner + "/" + m.Repo,
|
||||
Branch: m.Base,
|
||||
Commit: m.Commit,
|
||||
Merged: merged.UTC(),
|
||||
Created: time.Now().UTC(),
|
||||
State: inventory.PlanBuilding,
|
||||
Tiers: tiers,
|
||||
@@ -220,12 +222,20 @@ func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inv
|
||||
//
|
||||
// A plan with no branch recorded is from before branches were kept, and is superseded by the next
|
||||
// plan of its repository: what it had not built is folded in, so nothing is lost by it.
|
||||
//
|
||||
// **Newer is the branch's order, not the plans'** (novox/hq issue 349). A merge the bus did not hand
|
||||
// over is acted on late, by the catch-up, so its plan is made after the plan of a merge that came after
|
||||
// it — and that later-made plan of the earlier commit superseded the later merge's, and built what it
|
||||
// folded in from the commit before the later merge: twice on 2026-10-09, once a security fix. So where
|
||||
// both plans know when their merge was made, that decides; only where one does not do the plans' own
|
||||
// times. A merge older than the newest planned merge of its branch never reaches here at its own commit:
|
||||
// it is planned at that merge's commit, which contains it (laterOnTheBranch).
|
||||
func supersededBy(newer inventory.Plan, open []inventory.Plan, rollsOut func(string) bool) ([]string, []inventory.Plan) {
|
||||
folded := map[string]bool{}
|
||||
var closed []inventory.Plan
|
||||
for _, old := range open {
|
||||
if old.ID == newer.ID || !old.Open() || !strings.EqualFold(old.Repository, newer.Repository) ||
|
||||
(old.Branch != "" && old.Branch != newer.Branch) || !old.Created.Before(newer.Created) {
|
||||
(old.Branch != "" && old.Branch != newer.Branch) || !earlierOnTheBranch(old, newer) {
|
||||
continue
|
||||
}
|
||||
var took []string
|
||||
@@ -254,6 +264,30 @@ func supersededBy(newer inventory.Plan, open []inventory.Plan, rollsOut func(str
|
||||
return out, closed
|
||||
}
|
||||
|
||||
// earlierOnTheBranch says plan a answers a merge made before b's: by when the forge made each merge where
|
||||
// both are known, else by when each plan was made. A merge's own plan made again at the same commit
|
||||
// (the same merge time) is ordered by when it was made. Pure.
|
||||
func earlierOnTheBranch(a, b inventory.Plan) bool {
|
||||
if !a.Merged.IsZero() && !b.Merged.IsZero() && !a.Merged.Equal(b.Merged) {
|
||||
return a.Merged.Before(b.Merged)
|
||||
}
|
||||
return a.Created.Before(b.Created)
|
||||
}
|
||||
|
||||
// laterOnTheBranch says the plan newest answers a merge into m's branch made after m: then newest's commit
|
||||
// contains m's change, and m is planned there, so the newest commit of the branch is what is built (novox/hq
|
||||
// issue 349). newest is the newest merge's plan of the branch in any state: a later plan already done
|
||||
// built what it moved at its commit, and m planned at its own would build m's dependents back at the older
|
||||
// one. Pure.
|
||||
func laterOnTheBranch(m link.SourceMoved, newest inventory.Plan) bool {
|
||||
merged, err := time.Parse(time.RFC3339Nano, m.MergedAt)
|
||||
if err != nil || newest.Release != nil || newest.Commit == m.Commit {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(newest.Repository, m.Owner+"/"+m.Repo) && newest.Branch == m.Base &&
|
||||
newest.Merged.After(merged)
|
||||
}
|
||||
|
||||
// gates is what the next tier needs running from this one: a module of the tier that a later
|
||||
// tier is built by — the runtime dependency — and whose policy rolls it out, must be applied by
|
||||
// the machines running it before the next tier is asked. A base an image stands on need only be
|
||||
|
||||
@@ -931,27 +931,26 @@ func isWhyFlag(word string) bool {
|
||||
|
||||
// commandEnvironment is the environment of a command line this controller runs for someone: its own — the
|
||||
// stores' credentials, the bus, the broker, everything a command run from a shell beside it would have, because it
|
||||
// is that — with who asked, and the verb it came through. An empty verb is the controller's terminal (novox/hq ADR
|
||||
// 0272 §4): no `MESH_VERB` at all, whatever this process was started with.
|
||||
// is that — with who asked, and how it came.
|
||||
//
|
||||
// The terminal's line is also not the serving controller's (servedVar), and carries cliTerminalVar, so what reads
|
||||
// whether it was started at the terminal (startedAtTheTerminal) reads yes; every other line is stripped of that mark.
|
||||
func commandEnvironment(caller, verb string) []string {
|
||||
// **terminal** is said, never inferred (novox/hq ADR 0272): only a line mesh-cli asked as the controller's terminal
|
||||
// passes true. Its line has no `MESH_VERB`, not the served mark ADR 0266 puts on everything the serving controller
|
||||
// starts, and the terminal's mark (cliTerminalVar), so startedAtTheTerminal reads yes. Every other line names its
|
||||
// verb, and is stripped of the terminal's mark whatever this process's environment holds.
|
||||
func commandEnvironment(caller, verb string, terminal bool) []string {
|
||||
env := make([]string, 0, len(os.Environ())+3)
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, verbVar+"=") || strings.HasPrefix(kv, link.CallerVar+"=") ||
|
||||
strings.HasPrefix(kv, cliTerminalVar+"=") || (verb == "" && strings.HasPrefix(kv, servedVar+"=")) {
|
||||
strings.HasPrefix(kv, cliTerminalVar+"=") || (terminal && strings.HasPrefix(kv, servedVar+"=")) {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
env = append(env, link.CallerVar+"="+caller)
|
||||
if verb != "" {
|
||||
env = append(env, verbVar+"="+verb)
|
||||
} else {
|
||||
env = append(env, cliTerminalVar+"=1")
|
||||
if terminal {
|
||||
return append(env, cliTerminalVar+"=1")
|
||||
}
|
||||
return env
|
||||
return append(env, verbVar+"="+verb)
|
||||
}
|
||||
|
||||
// runVerb runs this binary with the given command line and gathers what it said.
|
||||
@@ -977,7 +976,7 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
||||
if verb == "" {
|
||||
verb = argv[0]
|
||||
}
|
||||
cmd.Env = commandEnvironment(caller+", through the "+catalogue.ControllerSeatName+" seat", verb)
|
||||
cmd.Env = commandEnvironment(caller+", through the "+catalogue.ControllerSeatName+" seat", verb, false)
|
||||
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
|
||||
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
|
||||
// prints its warnings beside the document, and a JSON parsed from the two together parsed
|
||||
|
||||
@@ -318,6 +318,21 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
||||
return notNow(err)
|
||||
}
|
||||
|
||||
// **A merge older than the newest planned merge of its branch is planned at that merge's commit**
|
||||
// (novox/hq issue 349): the catch-up acts on a merge the bus did not hand over after the merges that
|
||||
// came after it, and planned at its own commit it built what a later merge had fixed — the forge's
|
||||
// security fix, on 2026-10-09 — from the commit before it, dependents and all. The later commit contains
|
||||
// this merge's change. Planned there, with that merge's time, a module the later merge already looked at
|
||||
// reads as history and is not built again; the rest are built from the newest commit.
|
||||
newest, known, err := inv.NewestMergeOf(ctx, m.Owner+"/"+m.Repo, m.Base)
|
||||
if err != nil {
|
||||
return notNow(err)
|
||||
}
|
||||
if known && laterOnTheBranch(m, newest) {
|
||||
fmt.Printf(" %s/%s %.8s was merged before %.8s (%s, %s): what it moved is built from %.8s, which "+
|
||||
"contains it\n", m.Owner, m.Repo, m.Commit, newest.Commit, newest.ID, newest.State, newest.Commit)
|
||||
m.Commit, m.MergedAt = newest.Commit, newest.Merged.Format(time.RFC3339Nano)
|
||||
}
|
||||
from, packaging, already := mergeCandidates(m, entries, read)
|
||||
if len(from) == 0 && len(packaging) == 0 {
|
||||
// "Already built from it" and "nothing reads it" are different facts, and reading the first
|
||||
|
||||
@@ -51,7 +51,8 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||
// The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a
|
||||
// member cannot reach what the mesh's names point at.
|
||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n",
|
||||
|
||||
// No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199).
|
||||
"\nno-hosts\n",
|
||||
"\nconf-file=" + m.Facts["zones"].Path + "\n",
|
||||
@@ -62,6 +63,14 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
||||
t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config)
|
||||
}
|
||||
}
|
||||
// Bound to its addresses, one way or the other. mesh-catalog PR 161 (novox/hq issue 348) moves it from
|
||||
// bind-dynamic, which closed the private address's listener when a bridge teardown failed its re-read
|
||||
// of the machine's addresses, to bind-interfaces. This test reads the catalogue beside it, which may be
|
||||
// on either side of that merge, so it takes both; once the catalogue's main has it, bind-dynamic is
|
||||
// refused here.
|
||||
if !strings.Contains(config, "\nbind-interfaces\n") && !strings.Contains(config, "\nbind-dynamic\n") {
|
||||
t.Errorf("the resolver's configuration binds neither by bind-interfaces nor by bind-dynamic:\n%s", config)
|
||||
}
|
||||
// By address and never by interface: dnsmasq admits a query by the interface it arrives on
|
||||
// when told one, and a container's query to the private address arrives on the runtime's
|
||||
// bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110).
|
||||
|
||||
@@ -145,6 +145,13 @@ type Condition struct {
|
||||
// Raised is when it was first observed this time; LastObserved the newest observation.
|
||||
Raised time.Time `json:"raised"`
|
||||
LastObserved time.Time `json:"last-observed"`
|
||||
// First is when this fault was first raised, a reopening within ReopenWithin counted as the same
|
||||
// fault; Gaps are the stretches between, each from a clearing to the reopening after it. Absent on a
|
||||
// first raising, and on one written before they were kept. What asks whether the fault was there at a
|
||||
// moment — the gate, at a send — reads OpenAt, never Raised (novox/hq issue 348): a fault cleared and
|
||||
// reopened after a send was there at the send unless the send fell in one of its gaps.
|
||||
First time.Time `json:"first,omitzero"`
|
||||
Gaps []Gap `json:"gaps,omitempty"`
|
||||
// Observations is how many times it was observed since raised.
|
||||
Observations int `json:"observations"`
|
||||
// Count is how many times it has been raised, a reopening within ReopenWithin counted.
|
||||
@@ -274,3 +281,51 @@ func Order(list []Condition) {
|
||||
return list[i].Key < list[j].Key
|
||||
})
|
||||
}
|
||||
|
||||
// Gap is a stretch in which a fault was cleared, between its clearing and its reopening.
|
||||
type Gap struct {
|
||||
Cleared time.Time `json:"cleared"`
|
||||
Reopened time.Time `json:"reopened"`
|
||||
}
|
||||
|
||||
// KeptGaps is how many gaps a condition keeps. Past it the oldest go, and the fault is said to have begun
|
||||
// at the reopening after the newest of them: earlier history forgotten, never a fault said older than known.
|
||||
const KeptGaps = 8
|
||||
|
||||
// Began is when this fault began as the mesh knows it: its first raising, a reopening within
|
||||
// ReopenWithin being the same fault again (novox/hq issue 348).
|
||||
func (c Condition) Began() time.Time {
|
||||
if !c.First.IsZero() && c.First.Before(c.Raised) {
|
||||
return c.First
|
||||
}
|
||||
return c.Raised
|
||||
}
|
||||
|
||||
// OpenAt says the fault was there at t: it began at or before t, and t fell in none of its gaps. A fault
|
||||
// that cleared before a send and came back after it was not there at the send — that is the send's to
|
||||
// answer for — while one that flapped after the send was (novox/hq issue 348).
|
||||
func (c Condition) OpenAt(t time.Time) bool {
|
||||
if t.Before(c.Began()) {
|
||||
return false
|
||||
}
|
||||
for _, g := range c.Gaps {
|
||||
if !t.Before(g.Cleared) && t.Before(g.Reopened) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reopen is c raised again at now after a clearing at cleared, of a fault that began at first with gaps:
|
||||
// the same fault, with one more gap.
|
||||
func (c *Condition) reopen(first time.Time, gaps []Gap, cleared, now time.Time) {
|
||||
if first.IsZero() || !first.Before(now) {
|
||||
return
|
||||
}
|
||||
c.First = first
|
||||
c.Gaps = append(append([]Gap(nil), gaps...), Gap{Cleared: cleared, Reopened: now})
|
||||
if over := len(c.Gaps) - KeptGaps; over > 0 {
|
||||
c.First = c.Gaps[over-1].Reopened
|
||||
c.Gaps = c.Gaps[over:]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,12 @@ type Keeper struct {
|
||||
}
|
||||
|
||||
type clearing struct {
|
||||
at time.Time
|
||||
count int
|
||||
at time.Time
|
||||
count int
|
||||
// began is when the fault that cleared began, and gaps its earlier gaps, so its reopening keeps
|
||||
// them (Condition.OpenAt).
|
||||
began time.Time
|
||||
gaps []Gap
|
||||
silenced *Silence
|
||||
// tried is what healers tried before it cleared: a reopening is the same fault, and what was
|
||||
// tried on it is still what was tried.
|
||||
@@ -120,8 +124,8 @@ func NewKeeper(ctx context.Context, o Options) *Keeper {
|
||||
if recent, err := k.history.Since(ctx, k.now().Add(-ReopenWithin)); err == nil {
|
||||
for _, e := range recent {
|
||||
if e.Change == ChangeCleared {
|
||||
k.cleared[e.Key] = clearing{at: e.At, count: e.Condition.Count, silenced: e.Condition.Silenced,
|
||||
tried: e.Condition.Tried}
|
||||
k.cleared[e.Key] = clearing{at: e.At, count: e.Condition.Count, began: e.Condition.Began(), gaps: e.Condition.Gaps,
|
||||
silenced: e.Condition.Silenced, tried: e.Condition.Tried}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -197,6 +201,7 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
|
||||
k.mu.Lock()
|
||||
if before, ok := k.cleared[key]; ok && now.Sub(before.at) <= ReopenWithin {
|
||||
c.Count, change = before.count+1, ChangeReopened
|
||||
c.reopen(before.began, before.gaps, before.at, now)
|
||||
// A silence a person gave the condition before it cleared still holds: they said
|
||||
// they knew, and the same fault again ten minutes later is what they knew about.
|
||||
if before.silenced != nil && now.Before(before.silenced.Until) {
|
||||
@@ -313,7 +318,7 @@ func (k *Keeper) ClearSaying(ctx context.Context, key, why, resolved string) (bo
|
||||
}
|
||||
now := k.now().UTC()
|
||||
k.mu.Lock()
|
||||
k.cleared[key] = clearing{at: now, count: c.Count, silenced: c.Silenced, tried: c.Tried}
|
||||
k.cleared[key] = clearing{at: now, count: c.Count, began: c.Began(), gaps: c.Gaps, silenced: c.Silenced, tried: c.Tried}
|
||||
k.mu.Unlock()
|
||||
k.tell(Event{Condition: c, At: now, Change: ChangeCleared, Why: why, Cleared: &now})
|
||||
return true, nil
|
||||
|
||||
@@ -378,3 +378,119 @@ func TestATransitionIsOfferedAgainWhileTheBusIsAway(t *testing.T) {
|
||||
t.Fatalf("said %+v, unsaid %d", said, k.Unsaid())
|
||||
}
|
||||
}
|
||||
|
||||
// **A reopening keeps when the fault began** (novox/hq issue 348): Raised is the reopening, Began the
|
||||
// first raising — also from a keeper that read what cleared from the history — and past the window a
|
||||
// raising is a new fault that begins then.
|
||||
func TestAReopeningKeepsWhenTheFaultBegan(t *testing.T) {
|
||||
k, store, told, c := keeper(t)
|
||||
ctx := t.Context()
|
||||
first, err := k.Observe(ctx, silent("ace"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.Began().Equal(first.Raised) || !first.First.IsZero() {
|
||||
t.Fatalf("a first raising began at %s, raised %s, first %s", first.Began(), first.Raised, first.First)
|
||||
}
|
||||
c.pass(30 * time.Second)
|
||||
if _, err := k.Clear(ctx, "machine.ace.silent", "heard again"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.pass(time.Minute)
|
||||
again, err := k.Observe(ctx, silent("ace"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.Raised.After(first.Raised) || !again.Began().Equal(first.Raised) || len(again.Gaps) != 1 ||
|
||||
!again.Gaps[0].Reopened.Equal(again.Raised) || !again.Gaps[0].Cleared.Before(again.Raised) {
|
||||
t.Fatalf("reopened: raised %s, began %s, gaps %+v; first raised %s", again.Raised, again.Began(), again.Gaps, first.Raised)
|
||||
}
|
||||
if !again.OpenAt(first.Raised) || again.OpenAt(again.Gaps[0].Cleared) || !again.OpenAt(again.Raised) ||
|
||||
again.OpenAt(first.Raised.Add(-time.Second)) {
|
||||
t.Fatalf("open at the wrong moments: %+v", again)
|
||||
}
|
||||
// Through a restarted controller, reading the clearing from the history.
|
||||
if _, err := k.Clear(ctx, "machine.ace.silent", "heard again"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settled(t, told, 4)
|
||||
k.Close(context.Background())
|
||||
next := NewKeeper(ctx, Options{Store: store, History: store, Now: c.now})
|
||||
defer next.Close(context.Background())
|
||||
c.pass(time.Minute)
|
||||
third, err := next.Observe(ctx, silent("ace"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !third.Began().Equal(first.Raised) || len(third.Gaps) != 2 {
|
||||
t.Fatalf("after a restart the reopening began at %s, not %s, with gaps %+v", third.Began(), first.Raised, third.Gaps)
|
||||
}
|
||||
if _, err := next.Clear(ctx, "machine.ace.silent", "heard again"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.pass(ReopenWithin + time.Minute)
|
||||
fourth, err := next.Observe(ctx, silent("ace"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !fourth.Began().Equal(fourth.Raised) || len(fourth.Gaps) != 0 {
|
||||
t.Fatalf("past the window the fault began at %s, raised %s, gaps %+v", fourth.Began(), fourth.Raised, fourth.Gaps)
|
||||
}
|
||||
}
|
||||
|
||||
// Past KeptGaps the oldest gaps go, and the fault is said to have begun after them, never before.
|
||||
func TestAFaultKeepsItsNewestGapsAndForgetsWhatWasBefore(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 9, 10, 0, 0, 0, time.UTC)
|
||||
c := Condition{Raised: t0}
|
||||
first, gaps := t0, []Gap(nil)
|
||||
for i := 1; i <= KeptGaps+2; i++ {
|
||||
cleared, now := t0.Add(time.Duration(2*i)*time.Minute), t0.Add(time.Duration(2*i+1)*time.Minute)
|
||||
c = Condition{Raised: now}
|
||||
c.reopen(first, gaps, cleared, now)
|
||||
first, gaps = c.Began(), c.Gaps
|
||||
}
|
||||
if len(c.Gaps) != KeptGaps || !c.Began().Equal(t0.Add(5*time.Minute)) || c.OpenAt(t0.Add(time.Minute)) {
|
||||
t.Fatalf("after %d gaps: began %s, %d gaps", KeptGaps+2, c.Began(), len(c.Gaps))
|
||||
}
|
||||
// A first time not before the reopening is no earlier fault.
|
||||
d := Condition{Raised: t0}
|
||||
d.reopen(t0, nil, t0.Add(-time.Minute), t0)
|
||||
if !d.First.IsZero() || len(d.Gaps) != 0 {
|
||||
t.Fatalf("a fault reopened at its own first raising: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Two reopenings in one keeper, each after ClearSaying: both gaps kept, the fault begun at its first raising,
|
||||
// and open again at the second clearing's reopening.
|
||||
func TestASecondReopeningKeepsBothGaps(t *testing.T) {
|
||||
k, _, _, c := keeper(t)
|
||||
ctx := t.Context()
|
||||
first, err := k.Observe(ctx, silent("ace"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cleared []time.Time
|
||||
for i := 0; i < 2; i++ {
|
||||
c.pass(30 * time.Second)
|
||||
cleared = append(cleared, c.now().UTC())
|
||||
if ok, err := k.ClearSaying(ctx, "machine.ace.silent", "heard again", "ace is heard again"); err != nil || !ok {
|
||||
t.Fatalf("cleared %v: %v", ok, err)
|
||||
}
|
||||
c.pass(time.Minute)
|
||||
if _, err := k.Observe(ctx, silent("ace")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := k.Open(ctx)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
g := got[0]
|
||||
if !g.Began().Equal(first.Raised) || len(g.Gaps) != 2 || !g.Gaps[0].Cleared.Equal(cleared[0]) ||
|
||||
!g.Gaps[1].Cleared.Equal(cleared[1]) || !g.Gaps[1].Reopened.Equal(g.Raised) || g.Count != 3 {
|
||||
t.Fatalf("after two reopenings: began %s, gaps %+v, count %d", g.Began(), g.Gaps, g.Count)
|
||||
}
|
||||
if g.OpenAt(cleared[0].Add(time.Second)) || !g.OpenAt(cleared[0].Add(-time.Second)) || g.OpenAt(cleared[1].Add(time.Second)) {
|
||||
t.Fatalf("open at the wrong moments: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- A plan keeps when the forge made the merge it answers (novox/hq issue 349).
|
||||
--
|
||||
-- A newer plan of a repository's branch supersedes the older open ones, and "newer" was read from when each
|
||||
-- plan was made. A merge the bus did not hand over is acted on late, by the catch-up, so its plan is made
|
||||
-- after the plan of a merge that came after it — and superseded it, folding its unbuilt modules into a plan
|
||||
-- at the older commit. On 2026-10-09 a security fix to the forge's module would have been built from the
|
||||
-- commit before it. Merges into one branch are made one after another, each on the one before, so the
|
||||
-- forge's merge time is the branch's order. Null for a plan kept before this column, and for a plan no merge
|
||||
-- made (a release); then the plans' own order stands, as before.
|
||||
alter table release_plan add column merged_at timestamptz;
|
||||
@@ -19,8 +19,12 @@ type Plan struct {
|
||||
Repository string `json:"repository"`
|
||||
// Branch is the branch the merge went into (novox/hq issue 254): a newer plan supersedes the open
|
||||
// ones of the same repository and branch. Empty for a plan from before it was kept.
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Commit string `json:"commit"`
|
||||
// Merged is when the forge made the merge this plan answers (novox/hq issue 349): the order of a
|
||||
// branch's merges, which is not the order their plans were made in when one was acted on late. Zero
|
||||
// for a release, and for a plan kept before it was.
|
||||
Merged time.Time `json:"merged,omitzero"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
State string `json:"state"`
|
||||
@@ -249,8 +253,8 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
||||
var revision int64
|
||||
err = tx.QueryRow(ctx,
|
||||
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note,
|
||||
branch, tier_entered, revision, epoch, release, delivery)
|
||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14, $15)
|
||||
branch, tier_entered, revision, epoch, release, delivery, merged_at)
|
||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, 1, $13, $14, $15, $16)
|
||||
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
||||
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note, branch = excluded.branch,
|
||||
tier_entered = excluded.tier_entered, revision = release_plan.revision + 1, epoch = excluded.epoch,
|
||||
@@ -258,7 +262,7 @@ func (i *Inventory) SavePlan(ctx context.Context, p *Plan) error {
|
||||
where release_plan.revision = $12
|
||||
returning revision`,
|
||||
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note, p.Branch, entered,
|
||||
p.Revision, epoch, release, delivery).Scan(&revision)
|
||||
p.Revision, epoch, release, delivery, mergedAt(p.Merged)).Scan(&revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// The row is there and at another revision — moved since this was read, or there already
|
||||
// when this one is new: either way not this writer's to overwrite. (A plan saved before plans
|
||||
@@ -291,6 +295,14 @@ func short(commit string) string {
|
||||
return commit
|
||||
}
|
||||
|
||||
// mergedAt is a plan's merge time as the store keeps it: null when not known.
|
||||
func mergedAt(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// OpenPlans is every plan still being worked, oldest first.
|
||||
func (i *Inventory) OpenPlans(ctx context.Context) ([]Plan, error) {
|
||||
return i.plans(ctx, `where state in ('building', 'rolling') order by created`)
|
||||
@@ -301,6 +313,18 @@ func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error)
|
||||
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
|
||||
}
|
||||
|
||||
// NewestMergeOf is the plan, in any state, of the newest merge into a repository's branch that the mesh
|
||||
// planned: the branch's newest commit the mesh knows of (novox/hq issue 349). False when no plan of it
|
||||
// recorded when its merge was made.
|
||||
func (i *Inventory) NewestMergeOf(ctx context.Context, repository, branch string) (Plan, bool, error) {
|
||||
plans, err := i.plans(ctx, `where lower(repository) = lower($1) and branch = $2 and merged_at is not null
|
||||
and release is null order by merged_at desc, created desc limit 1`, repository, branch)
|
||||
if err != nil || len(plans) == 0 {
|
||||
return Plan{}, false, err
|
||||
}
|
||||
return plans[0], true, nil
|
||||
}
|
||||
|
||||
// PlanByID is one plan.
|
||||
func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
||||
plans, err := i.plans(ctx, `where id = '`+id+`'`)
|
||||
@@ -313,11 +337,11 @@ func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
||||
return plans[0], nil
|
||||
}
|
||||
|
||||
func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
||||
func (i *Inventory) plans(ctx context.Context, tail string, args ...any) ([]Plan, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note, branch,
|
||||
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release, delivery
|
||||
from release_plan `+tail)
|
||||
coalesce(tier_entered, created), revision, coalesce(epoch, 0), release, delivery, merged_at
|
||||
from release_plan `+tail, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -327,11 +351,15 @@ func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
||||
var p Plan
|
||||
var tiers, modules, release, delivery []byte
|
||||
var epoch int64
|
||||
var merged *time.Time
|
||||
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
||||
&p.Tier, &tiers, &modules, &p.Note, &p.Branch, &p.TierEntered, &p.Revision, &epoch, &release,
|
||||
&delivery); err != nil {
|
||||
&delivery, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if merged != nil {
|
||||
p.Merged = merged.UTC()
|
||||
}
|
||||
if len(release) > 0 {
|
||||
if err := json.Unmarshal(release, &p.Release); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -73,3 +73,45 @@ func TestASupersededPlanIsNotOpen(t *testing.T) {
|
||||
t.Fatalf("a superseded plan is not among the recent ones as superseded: %+v", recent)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 349 (review of the follow-up): the newest merge of a branch is the one merged last, whatever
|
||||
// order the plans were made in, in any state; a tie is broken by the plan made last; a release, another
|
||||
// branch and another repository are never it; and its time is kept to the nanosecond.
|
||||
func TestTheNewestMergeOfABranchIsTheOneMergedLast(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
t0 := time.Date(2026, 10, 9, 10, 0, 0, 0, time.UTC)
|
||||
save := func(id, repo, branch string, merged, created time.Time, state string, release bool) {
|
||||
t.Helper()
|
||||
p := Plan{ID: id, Repository: repo, Branch: branch, Commit: id + "-commit", Merged: merged, Created: created,
|
||||
State: state, Tiers: [][]string{}, Modules: map[string]*PlanModule{}}
|
||||
if release {
|
||||
p.Release = &PlanRelease{}
|
||||
}
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, found, err := inv.NewestMergeOf(ctx, "novox/mesh-catalog", "main"); err != nil || found {
|
||||
t.Fatalf("a branch with no plan: %v %v", found, err)
|
||||
}
|
||||
// Made in the other order than merged: the later merge's plan made first, and done.
|
||||
save("plan-later", "novox/mesh-catalog", "main", t0.Add(2*time.Minute+250*time.Millisecond), t0, PlanDone, false)
|
||||
save("plan-earlier", "novox/mesh-catalog", "main", t0.Add(time.Minute), t0.Add(5*time.Minute), PlanRolling, false)
|
||||
save("plan-other-branch", "novox/mesh-catalog", "release", t0.Add(time.Hour), t0, PlanRolling, false)
|
||||
save("plan-other-repo", "novox/mesh-controller", "main", t0.Add(time.Hour), t0, PlanRolling, false)
|
||||
save("release-1", "novox/mesh-catalog", "main", t0.Add(time.Hour), t0, PlanRolling, true)
|
||||
save("plan-unknown", "novox/mesh-catalog", "main", time.Time{}, t0.Add(time.Hour), PlanRolling, false)
|
||||
p, found, err := inv.NewestMergeOf(ctx, "Novox/Mesh-Catalog", "main")
|
||||
if err != nil || !found || p.ID != "plan-later" {
|
||||
t.Fatalf("the newest merge: %s %v %v", p.ID, found, err)
|
||||
}
|
||||
if !p.Merged.Equal(t0.Add(2*time.Minute + 250*time.Millisecond)) {
|
||||
t.Fatalf("its merge time was not kept to the nanosecond: %s", p.Merged)
|
||||
}
|
||||
// The same merge time: the plan made last.
|
||||
save("plan-again", "novox/mesh-catalog", "main", t0.Add(2*time.Minute+250*time.Millisecond), t0.Add(time.Minute), PlanBuilding, false)
|
||||
if p, _, _ := inv.NewestMergeOf(ctx, "novox/mesh-catalog", "main"); p.ID != "plan-again" {
|
||||
t.Fatalf("one merge time, two plans: %s", p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ type CLIAsked struct {
|
||||
Account string `json:"account"`
|
||||
UID uint32 `json:"uid"`
|
||||
Follow string `json:"follow,omitempty"`
|
||||
// Session is the login session the asking process runs in, as the node-engine read it from the kernel's cgroup
|
||||
// (`session-<id>.scope` under the account's own slice), or empty: a service, a user unit. Only a login session is
|
||||
// the terminal (novox/hq ADR 0272).
|
||||
Session string `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
// CLIAnswer is what the controller answers, as the `result` of a call's answer: what the command printed, how it
|
||||
|
||||
@@ -17,7 +17,11 @@ import (
|
||||
// The node-engine's request and the answer it hands mesh-cli hold these field names; the engine's side holds the
|
||||
// same list (mesh-host internal/meshcli, TestTheRequestToTheControllerKeepsItsFieldNames).
|
||||
func TestTheMeshCLIRequestAndAnswerKeepTheirFieldNames(t *testing.T) {
|
||||
body, _ := json.Marshal(CLIAsked{Line: []string{"status"}, Account: "a", UID: 1})
|
||||
body, _ := json.Marshal(CLIAsked{Line: []string{"status"}, Account: "a", UID: 1, Session: "session-3.scope"})
|
||||
if got := keysIn(t, body); got != "account line session uid" {
|
||||
t.Fatalf("the request's fields are %q", got)
|
||||
}
|
||||
body, _ = json.Marshal(CLIAsked{Line: []string{"status"}, Account: "a", UID: 1})
|
||||
if got := keysIn(t, body); got != "account line uid" {
|
||||
t.Fatalf("the request's fields are %q", got)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user