From 84a75ed2a412bc062ab029e052685e56defb5db1 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 18:57:37 +0000 Subject: [PATCH] Bring F3 up to its release branch, with both sides' store tests (part 13 of 14) Co-Authored-By: Claude Opus 5.5 --- internal/inventory/placement_test.go | 222 ++++++++++++++++++++++ internal/inventory/plans.go | 72 ++++++- internal/inventory/record_mirrors_test.go | 72 +++++++ internal/inventory/template_test.go | 25 +++ internal/inventory/unseen.go | 28 ++- internal/link/build.go | 24 +++ internal/link/builds.go | 19 ++ internal/link/builds_nats.go | 5 +- internal/link/queue.go | 3 + 9 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 internal/inventory/placement_test.go create mode 100644 internal/inventory/template_test.go diff --git a/internal/inventory/placement_test.go b/internal/inventory/placement_test.go new file mode 100644 index 00000000..0483fe7b --- /dev/null +++ b/internal/inventory/placement_test.go @@ -0,0 +1,222 @@ +package inventory + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// placedLibrary is a module with each kind of placement: a directory to place, two accesses, a container that +// may keep a found network. +func placedLibrary() catalogue.Manifest { + return catalogue.Manifest{Module: "library", Version: "1", + Accesses: []catalogue.Access{{ID: "series"}, {ID: "downloads"}}, + Resources: []map[string]any{ + {"id": "config", "type": "directory", "mode": "0750"}, + {"id": "server", "type": "container", + "image": "registry.invalid:5000/library@sha256:" + strings.Repeat("a", 64), + "volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:downloads}:/downloads"}}, + {"id": "runtime-config", "type": "file", "path": "${dir:config}/runtime.json", "mode": "0640", + "content": `{"log":"info"}`, "merge": "json"}, + }} +} + +// The layers of the issue (651): a mesh-wide layer with an access and a config value, a node's layer with a +// place, an access and a kept network beside a config value, a node's layer of placement only, and one with +// none. +var placementLayers = []struct { + node string + values string +}{ + {"", `{"accesses": {"downloads": "/storage/downloads"}, "log": "warn"}`}, + {"anchor", `{"places": {"config": {"path": "/var/lib/library/config", "owner": "1001:2000"}}, + "accesses": {"series": "/storage/media/series"}, "networks": {"server": ["predecessor_default"]}, "workers": 4}`}, + {"laptop", `{"accesses": {"series": "/home/media/series"}}`}, + {"spare", `{"log": "debug"}`}, +} + +// **Placement moves out of the settings layers, and every layer reads as it did** (novox/hq ADR 0348 clause +// 11, issue 651). Migration 0097 moves each layer's places, accesses and kept networks beside it: no settings +// row holds a placement key after, every layer the settings verb and composition read is what it was, each +// moved layer is kept whole in the history with why, and a layer without placement is not touched. +func TestPlacementMovesOutOfTheSettingsLayers(t *testing.T) { + inv, migrate := ForTestBefore(t, 97) + ctx := t.Context() + if err := inv.RegisterModule(ctx, placedLibrary(), Source{}); err != nil { + t.Fatal(err) + } + for _, n := range []string{"anchor", "laptop", "spare"} { + if _, err := inv.AddNode(ctx, n); err != nil { + t.Fatal(err) + } + } + if err := inv.SetAdopted(ctx, "anchor", true); err != nil { + t.Fatal(err) + } + // As the mesh holds them before the migration: placement among the settings. + for _, l := range placementLayers { + if l.node == "" { + inv.ExecForTest(t, `insert into settings (node, module, values, set_by) values (null, 'library', $1, 'the operator')`, l.values) + continue + } + inv.ExecForTest(t, `insert into settings (node, module, values) select id, 'library', $2 from node where name = $1`, + l.node, l.values) + } + setAt := inv.QueryForTest(t, `select coalesce(n.name, ''), s.set_at from settings s left join node n on n.id = s.node order by 1`) + + migrate() + + if held := inv.QueryForTest(t, `select module from settings where values ?| array['places', 'accesses', 'networks']`); len(held) != 0 { + t.Fatalf("a settings layer still holds a placement key: %v", held) + } + if after := inv.QueryForTest(t, `select coalesce(n.name, ''), s.set_at from settings s left join node n on n.id = s.node order by 1`); !reflect.DeepEqual(after, setAt) { + t.Fatalf("the layers or when they were set changed: %v, was %v", after, setAt) + } + for _, l := range placementLayers { + var want map[string]any + if err := json.Unmarshal([]byte(l.values), &want); err != nil { + t.Fatal(err) + } + got, has, err := inv.Layer(ctx, l.node, "library") + if err != nil || !has || !reflect.DeepEqual(roundTrip(t, got), want) { + t.Errorf("the layer on %q reads %v (%v, %v), was %v", l.node, got, has, err, want) + } + } + // Composition's view: the config values in Values, the placement in Placement, together the layer. + layers, err := inv.SettingsFor(ctx, "anchor", "library") + if err != nil || len(layers) != 2 { + t.Fatalf("anchor's layers: %v, %v", layers, err) + } + if layers[1].Values["workers"] == nil || layers[1].Values["places"] != nil || layers[1].Placement["places"] == nil || + layers[1].Placement["networks"] == nil || layers[0].Placement["accesses"] == nil || layers[0].Values["log"] != "warn" { + t.Fatalf("anchor's layers split wrong: %+v", layers) + } + for i, l := range layers { + var want map[string]any + _ = json.Unmarshal([]byte(placementLayers[i].values), &want) + if !reflect.DeepEqual(roundTrip(t, l.Whole()), want) { + t.Errorf("anchor's layer %s is %v whole, was %v", l.From, l.Whole(), want) + } + } + // Judged as composition judges them, as before: anchor is adopted, so its kept network composes. + if err := catalogue.JudgeSettings(placedLibrary(), layers, true); err != nil { + t.Fatalf("the moved layers no longer compose: %v", err) + } + + moved := inv.QueryForTest(t, `select coalesce(n.name, ''), h.replaced_by, h.why, h.set_by from settings_history h + left join node n on n.id = h.node order by 1`) + want := [][]string{ + {`""`, `"moved"`, `"moved to placement, ADR 0348 clause 11"`, `"the operator"`}, + {`"anchor"`, `"moved"`, `"moved to placement, ADR 0348 clause 11"`, `null`}, + {`"laptop"`, `"moved"`, `"moved to placement, ADR 0348 clause 11"`, `null`}, + } + if !reflect.DeepEqual(moved, want) { + t.Fatalf("the history of what moved: %v, want %v", moved, want) + } + past, err := inv.SettingsHistory(ctx, "anchor", "library") + if err != nil || len(past) != 1 || past[0].Why != "moved to placement, ADR 0348 clause 11" || past[0].Values["places"] == nil { + t.Fatalf("settings history on anchor: %+v, %v", past, err) + } +} + +// **The settings verb's path still takes a placement key, and routes it** (issue 651): a set keeps the +// placement beside the layer and the rest in it, reads back whole, is refused as before when the placement +// cannot compose (nothing written), and a set or a clear replaces the placement with the layer. +func TestASettingRoutesPlacementBesideTheLayer(t *testing.T) { + inv := ForTest(t) + ctx := t.Context() + if err := inv.RegisterModule(ctx, placedLibrary(), Source{}); err != nil { + t.Fatal(err) + } + if _, err := inv.AddNode(ctx, "anchor"); err != nil { + t.Fatal(err) + } + // Placement alone is a layer as well: the whole mesh's says where both accesses are. + meshWide := map[string]any{"accesses": map[string]any{ + "downloads": "/storage/downloads", "series": "/storage/series"}} + if err := inv.SetSettings(ctx, "", "library", meshWide); err != nil { + t.Fatal(err) + } + if got, has, _ := inv.Layer(ctx, "", "library"); !has || !reflect.DeepEqual(roundTrip(t, got), roundTrip(t, meshWide)) { + t.Fatalf("the mesh-wide layer of placement only reads %v (%v)", got, has) + } + set := map[string]any{"log": "warn", + "places": map[string]any{"config": map[string]any{"path": "/var/lib/library/config"}}, + "accesses": map[string]any{"series": "/storage/media/series"}} + if err := inv.SetSettings(ctx, "anchor", "library", set); err != nil { + t.Fatal(err) + } + stored := inv.QueryForTest(t, `select s.values, p.values from settings s join placement p using (module, node) + where s.node is not null`) + if len(stored) != 1 || stored[0][0] != `{"log":"warn"}` || + !strings.Contains(stored[0][1], `"places"`) || !strings.Contains(stored[0][1], `"accesses"`) { + t.Fatalf("the layer and its placement are kept as %v", stored) + } + if got, _, _ := inv.Layer(ctx, "anchor", "library"); !reflect.DeepEqual(roundTrip(t, got), roundTrip(t, set)) { + t.Fatalf("the layer reads %v, was set %v", got, set) + } + + // Refused as before, by the same judgement, and nothing changes. + for name, bad := range map[string]map[string]any{ + "an undeclared directory": {"places": map[string]any{"cache": "/var/lib/library/cache"}}, + "the machine's own": {"accesses": map[string]any{"series": "/etc"}}, + "a kept network on a converged machine": { + "networks": map[string]any{"server": []any{"predecessor_default"}}}, + } { + if err := inv.SetSettings(ctx, "anchor", "library", bad); err == nil { + t.Errorf("%s was kept", name) + } + } + if err := inv.SetSettings(ctx, "", "library", map[string]any{"accesses": meshWide["accesses"], + "networks": map[string]any{"server": []any{"x"}}}); err == nil || !strings.Contains(err.Error(), "per node") { + t.Errorf("a mesh-wide kept network: %v", err) + } + if got, _, _ := inv.Layer(ctx, "anchor", "library"); !reflect.DeepEqual(roundTrip(t, got), roundTrip(t, set)) { + t.Fatalf("a refused set changed the layer to %v", got) + } + + // A set without placement replaces it too, and the history keeps the layer whole. + if err := inv.SetSettings(ctx, "anchor", "library", map[string]any{"log": "debug"}); err != nil { + t.Fatal(err) + } + if rows := inv.QueryForTest(t, `select module from placement where node is not null`); len(rows) != 0 { + t.Fatalf("a set without placement left it: %v", rows) + } + past, err := inv.SettingsHistory(ctx, "anchor", "library") + if err != nil || len(past) != 1 || past[0].Values["places"] == nil || past[0].Values["log"] != "warn" { + t.Fatalf("the replaced layer is not kept whole: %+v, %v", past, err) + } + + // The merge gate's way in keeps a layer as the facts carry it, whole: split the same way. + if err := inv.KeepSettings(ctx, "anchor", "library", map[string]any{"log": "info", + "places": map[string]any{"config": "/var/lib/library/config"}}); err != nil { + t.Fatal(err) + } + if rows := inv.QueryForTest(t, `select values from settings where values ? 'places'`); len(rows) != 0 { + t.Fatalf("a kept layer put placement among the settings: %v", rows) + } + for _, node := range []string{"", "anchor"} { + if err := inv.ClearSettings(ctx, node, "library"); err != nil { + t.Fatal(err) + } + } + if rows := inv.QueryForTest(t, `select module from placement`); len(rows) != 0 { + t.Fatalf("a clear left the placement: %v", rows) + } +} + +func roundTrip(t *testing.T, v map[string]any) map[string]any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + out := map[string]any{} + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + return out +} diff --git a/internal/inventory/plans.go b/internal/inventory/plans.go index fd86c2b9..c9f712cf 100644 --- a/internal/inventory/plans.go +++ b/internal/inventory/plans.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "time" @@ -391,6 +392,65 @@ type PlanGate struct { // reports its module's container left running because it said busy, the judging waits and has not // started; once it reports the container replaced, Since is moved to then. Nil when nothing waited. Busy *GateBusy `json:"busy,omitempty"` + // Pending says the send this gate judges is kept and not yet made (novox/hq ADR 0353 §8): a controller that + // takes over reads the machine's last send before making it, and never sends it twice. + Pending bool `json:"pending,omitempty"` + // Silent says the machine reported nothing on its send within ApplySilentAfter (novox/hq ADR 0353 §4): left, + // neither failed nor put back. + Silent bool `json:"silent,omitempty"` + // TookOver names the gate whose readings this one took over (novox/hq ADR 0353 §7): another walk's judging of + // the same build on the same machine, which this gate goes on from instead of starting from zero. + TookOver string `json:"took_over,omitempty"` +} + +// TakeReadings makes this gate go on from another's readings of the same build on the same machine (novox/hq ADR +// 0353 §7): when the judging began, when each machine applied, the passes counted and when, and what each found. +// A module's own count is taken only for the modules this gate judges. +func (g *PlanGate) TakeReadings(from *PlanGate, modules []string, id string) { + if from == nil { + return + } + if from.Since != nil { + since := *from.Since + g.Since = &since + } + for n, s := range from.Sent { + if slices.Contains(g.Machines, n) { + if g.Sent == nil { + g.Sent = map[string]SentDeclaration{} + } + g.Sent[n] = s + } + } + for n, at := range from.Applied { + if slices.Contains(g.Machines, n) { + if g.Applied == nil { + g.Applied = map[string]time.Time{} + } + g.Applied[n] = at + } + } + g.Passes, g.Faulted, g.HealthySeen, g.Last = from.Passes, from.Faulted, from.HealthySeen, from.Last + if from.LastPass != nil { + at := *from.LastPass + g.LastPass = &at + } + for _, m := range modules { + if n, counted := from.Healthy[m]; counted { + if g.Healthy == nil { + g.Healthy = map[string]int{} + } + g.Healthy[m] = n + } + if at, counted := from.HealthyAt[m]; counted { + if g.HealthyAt == nil { + g.HealthyAt = map[string]time.Time{} + } + g.HealthyAt[m] = at + } + } + g.Readings = append([]GateReading(nil), from.Readings...) + g.TookOver = id } // GateBusy is a first-node gate's wait for a busy container (novox/hq ADR 0335 §4). @@ -477,9 +537,17 @@ type CarriedMove struct { type PlanRelease struct { Order []string `json:"order"` Next int `json:"next"` - // Gate is the machine being judged; nil between machines. + // Gate is the first machine being judged; nil between machines. Gate *PlanGate `json:"gate,omitempty"` - Done []string `json:"done,omitempty"` + // Rest says the first machine passed (novox/hq ADR 0353 §1): every other machine is sent at once, each judged by + // its own gate in Gates, by machine — a build no gate passed going to one machine of a step only (§2). + Rest bool `json:"rest,omitempty"` + Gates map[string]*PlanGate `json:"gates,omitempty"` + // Failed are the machines of the rest whose gate failed, and Silent those that said nothing on their send + // within ApplySilentAfter (ADR 0353 §3 and §4). + Failed []string `json:"failed,omitempty"` + Silent []string `json:"silent,omitempty"` + Done []string `json:"done,omitempty"` // Skipped are the machines not heard from when their turn came, left as they were. Skipped []string `json:"skipped,omitempty"` // By is the person who released it, empty when the mesh did. diff --git a/internal/inventory/record_mirrors_test.go b/internal/inventory/record_mirrors_test.go index db2e9ba2..cb329ae1 100644 --- a/internal/inventory/record_mirrors_test.go +++ b/internal/inventory/record_mirrors_test.go @@ -1,6 +1,7 @@ package inventory import ( + "encoding/json" "reflect" "slices" "strings" @@ -39,3 +40,74 @@ func TestTheDeclaredConfigMirrorCarriesOnlyItsListedFields(t *testing.T) { t.Errorf("the mirrored module carries %v", got) } } + +// **A declaration using every field reaches the mirror whole** (ADR 0356 §4): the mirror holds what the manifest +// declares about a config value, so a declaration with every field set reads back from the mirror's value with +// each field as written. +func TestADeclarationUsingEveryFieldRoundTripsThroughTheMirror(t *testing.T) { + low, high := 1.0, 65535.0 + every := catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: 8080, Why: "the port it always had", + Type: catalogue.ConfigNumber, Description: "the port it listens on, a TCP port number", + Enum: []any{8080, 8443}, Minimum: &low, Maximum: &high, Pattern: "^[0-9]+$", Trusted: true, + Predefined: "http.port", Required: true, Runtime: true} + declared, err := json.Marshal(map[string]catalogue.SettingDeclaration{"port": every}) + if err != nil { + t.Fatal(err) + } + var written map[string]map[string]any + if err := json.Unmarshal(declared, &written); err != nil { + t.Fatal(err) + } + for _, field := range jsonFields(reflect.TypeOf(catalogue.SettingDeclaration{})) { + if _, has := written["port"][field]; !has { + t.Fatalf("the declaration under test leaves %q unset: set every field", field) + } + } + mirrored, err := declaredMirrored(declared) + if err != nil { + t.Fatal(err) + } + value, err := json.Marshal(DeclaredConfigMirrored{Module: "proxy", Settings: mirrored}) + if err != nil { + t.Fatal(err) + } + var read struct { + Settings map[string]map[string]any `json:"settings"` + } + if err := json.Unmarshal(value, &read); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(read.Settings, written) { + t.Errorf("the mirror holds %v; the manifest declared %v", read.Settings, written) + } +} + +// **A module moved to the config form is mirrored from it** (ADR 0348 §1): the store keeps its manifest in the +// form it was written in, under `config`, and the declared-config mirror reads its declarations from there with +// the config form's fields. +func TestAModuleInTheConfigFormIsMirroredWithItsWholeDeclaration(t *testing.T) { + inv := fresh(t) + var m catalogue.Manifest + if err := json.Unmarshal([]byte(`{"module": "proxy", "version": "1", "config": {"port": {"type": "number", + "description": "the port it listens on, a TCP port number", "default": 8080, "minimum": 1, + "maximum": 65535, "enum": [8080, 8443], "predefined": "http.port"}}}`), &m); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil { + t.Fatal(err) + } + values, err := inv.DeclaredConfigMirror(t.Context()) + if err != nil { + t.Fatal(err) + } + var got DeclaredConfigMirrored + if err := json.Unmarshal(values["proxy"], &got); err != nil { + t.Fatal(err) + } + port, has := got.Settings["port"] + if !has || port.Type != catalogue.ConfigNumber || port.Description == "" || string(port.Default) != "8080" || + port.Minimum == nil || *port.Minimum != 1 || port.Maximum == nil || *port.Maximum != 65535 || + string(port.Enum) != "[8080,8443]" || port.Predefined != "http.port" { + t.Errorf("a module in the config form is mirrored as %s", values["proxy"]) + } +} diff --git a/internal/inventory/template_test.go b/internal/inventory/template_test.go new file mode 100644 index 00000000..b9f58cc8 --- /dev/null +++ b/internal/inventory/template_test.go @@ -0,0 +1,25 @@ +package inventory + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The manifest the mesh holds carries each file's text (novox/hq ADR 0349 §2): one still naming a template — a +// build by a builder that predates templates, or a manifest handed over unbuilt — would compose a file with no +// text, and is refused by name. +func TestAManifestStillNamingATemplateIsNotRegistered(t *testing.T) { + inv := ForTest(t) + m := catalogue.Manifest{Module: "notifier", Version: "1", Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/notifier.conf", "template": "files/notifier.conf"}}} + err := inv.RegisterModule(t.Context(), m, Source{}) + if err == nil || !strings.Contains(err.Error(), "files/notifier.conf") { + t.Fatalf("a manifest naming a template was registered: %v", err) + } + m.Resources[0] = map[string]any{"id": "conf", "type": "file", "path": "/etc/notifier.conf", "content": "x"} + if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil { + t.Fatalf("the same manifest with its text was refused: %v", err) + } +} diff --git a/internal/inventory/unseen.go b/internal/inventory/unseen.go index 8acbf13b..023fa315 100644 --- a/internal/inventory/unseen.go +++ b/internal/inventory/unseen.go @@ -20,9 +20,11 @@ func (i *Inventory) Layer(ctx context.Context, nodeName, module string) (map[str if err != nil { return nil, false, err } + // Said whole, as it was set: its config values and the placement kept beside them (novox/hq ADR 0348 + // clause 11), so a person reading it before setting it sees every key they may keep or remove. var raw []byte - err = i.store.Pool().QueryRow(ctx, - `select values from settings where module = $1 and node is not distinct from $2::uuid`, + err = i.store.Pool().QueryRow(ctx, `select `+layerWhole+` from settings s + where s.module = $1 and s.node is not distinct from $2::uuid`, module, nodeID).Scan(&raw) if errors.Is(err, pgx.ErrNoRows) { return nil, false, nil @@ -46,6 +48,9 @@ type PastLayer struct { ReplacedBy string // SetBy is who had set the layer, when it was kept (novox/hq ADR 0277); empty for one set before that was kept. SetBy string + // Why is why it went, where that was said: "moved to placement, ADR 0348 clause 11" for a layer whose + // placement keys migration 0097 moved out of it (ReplacedBy "moved"). + Why string } // LayerOrigin is who set a layer and when (novox/hq ADR 0277): empty words for a layer set before who set it was @@ -79,7 +84,7 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string return nil, err } rows, err := i.store.Pool().Query(ctx, - `select values, set_at, replaced_at, replaced_by, set_by from settings_history + `select values, set_at, replaced_at, replaced_by, set_by, why from settings_history where module = $1 and node is not distinct from $2::uuid order by replaced_at desc`, module, nodeID) if err != nil { @@ -90,13 +95,16 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string for rows.Next() { var raw []byte var p PastLayer - var by *string - if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy, &by); err != nil { + var by, why *string + if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy, &by, &why); err != nil { return nil, err } if by != nil { p.SetBy = *by } + if why != nil { + p.Why = *why + } if err := json.Unmarshal(raw, &p.Values); err != nil { return nil, err } @@ -107,9 +115,15 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string // keepReplacedSQL copies a layer into the history before it is replaced or cleared; run in the same // transaction as the write where there is one, so a write that fails leaves no history of it. +// +// The layer is kept whole, as it was set: its config values and its placement (novox/hq ADR 0348 clause 11). const keepReplacedSQL = `insert into settings_history (node, module, values, set_at, replaced_by, set_by) - select node, module, values, set_at, $3, set_by from settings - where module = $1 and node is not distinct from $2::uuid` + select s.node, s.module, ` + layerWhole + `, s.set_at, $3, s.set_by from settings s + where s.module = $1 and s.node is not distinct from $2::uuid` + +// layerWhole is a settings row `s` with the placement kept beside it folded back in: the layer as it was set. +const layerWhole = `s.values || coalesce((select p.values from placement p + where p.module = s.module and p.node is not distinct from s.node), '{}'::jsonb)` // layerNode is the node id of a layer, nil for the whole mesh's. func (i *Inventory) layerNode(ctx context.Context, nodeName string) (*string, error) { diff --git a/internal/link/build.go b/internal/link/build.go index 6eb16588..73384e9f 100644 --- a/internal/link/build.go +++ b/internal/link/build.go @@ -164,6 +164,30 @@ type CheckRequest struct { // each, from the merge it judged, into the scratch namespace once it has judged and not failed, for the walk to // take. A build seat from before it builds nothing, and the walk builds as before. Builds []CheckBuild `json:"builds,omitempty"` + // Try marks a branch tried before its pull request (novox/hq issue 686): the repository's own check for a + // pushed branch or commit, asked by a builder through the controller's `try` verb. Its outcome is read from + // the build's log (StepTried) and said to nobody: no status on the forge, nothing recorded or registered, + // nothing built for a merge. Tests narrows what the repository's script runs to the tests a pattern names, + // and StoreOnly to its store tests; a script that reads neither runs whole. + Try bool `json:"try,omitempty"` + Tests string `json:"tests,omitempty"` + StoreOnly bool `json:"store-only,omitempty"` +} + +// StepTried is the step a build seat says a try's outcome under, in the build's log: one line, a Tried as JSON +// (novox/hq issue 686). +const StepTried = "tried" + +// Tried is what came of a branch tried before its pull request (novox/hq issue 686): the verdict of the +// repository's own check for the ref merged into the trunk, how long it took, and the lines that name what +// failed, picked from the whole of its output (issue 460). +type Tried struct { + Verdict string `json:"verdict"` + Summary string `json:"summary"` + Took string `json:"took"` + Head string `json:"head,omitempty"` + Trunk string `json:"trunk,omitempty"` + Failed []string `json:"failed,omitempty"` } // CheckBuild is one module a check builds for its merge: from the merge it made when Merged — a module built from diff --git a/internal/link/builds.go b/internal/link/builds.go index 7fe20541..f372d056 100644 --- a/internal/link/builds.go +++ b/internal/link/builds.go @@ -96,8 +96,27 @@ type BuildStart struct { // an earlier taker left unfinished — most often a build agent replaced mid-build — and started // again from the beginning (novox/hq issue 594). 0 from a machine that predates it. Delivery int `json:"delivery,omitempty"` + // Try marks the start of a branch tried before its pull request (novox/hq issue 686), which names no + // repository and no ref (startOf). + Try bool `json:"try,omitempty"` } +// startOf is what a build machine says of the ask it took. **A try's start names no repository and no commit** +// (novox/hq issue 686): the forge's holder writes mesh/merge-gate pending on an open pull request whose head is +// the commit a start names, and a try of that head is no check of the pull request — nothing on the forge moves +// for a try. Its id, machine and time are all the queue reads of a start. +func startOf(r BuildRequest, on string, delivery int, at time.Time) BuildStart { + s := BuildStart{ID: r.ID, Repository: r.Repository, Path: r.Path, Ref: r.Ref, On: on, + At: at.UTC().Format(time.RFC3339Nano), Delivery: delivery} + if IsTry(r) { + s.Repository, s.Path, s.Ref, s.Try = "", "", "", true + } + return s +} + +// IsTry says an ask is a branch tried before its pull request (novox/hq issue 686). +func IsTry(r BuildRequest) bool { return r.Check != nil && r.Check.Try } + // Restarted is how a build handed out again says so (novox/hq issue 594), in its log and in its check's // report, so a second run never reads as the first. Neutral on why: a holder replaced mid-build, a paused // holder handing an ask back as it arrived, and a bus that held its acknowledgements past the wait all diff --git a/internal/link/builds_nats.go b/internal/link/builds_nats.go index c7d0c67e..5ec3b9c1 100644 --- a/internal/link/builds_nats.go +++ b/internal/link/builds_nats.go @@ -401,10 +401,7 @@ func (b *natsBuild) Done() error { return b.msg.Ack() } // Began publishes that this machine has taken the build, into the stream like the outcome, so a // reader that asks afterwards sees when it started as well as how it ended. func (b *natsBuild) Began(ctx context.Context) error { - body, err := json.Marshal(BuildStart{ - ID: b.request.ID, Repository: b.request.Repository, Path: b.request.Path, - Ref: b.request.Ref, On: b.on, At: time.Now().UTC().Format(time.RFC3339Nano), Delivery: b.delivery, - }) + body, err := json.Marshal(startOf(b.request, b.on, b.delivery, time.Now())) if err != nil { return err } diff --git a/internal/link/queue.go b/internal/link/queue.go index 50865ef8..3bbbb8a2 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -427,6 +427,9 @@ func ReadCheckBuilds(ctx context.Context, js *broker.JetStream, seat string, pul // safeKey is an id that can be a key, and a subject token, as it is: a build id, never a pattern. var safeKey = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +// SafeBuildID says an id can be a subject token as it is: a build's id, never a pattern. +func SafeBuildID(id string) bool { return safeKey.MatchString(id) } + // Cancelled is what the controller writes for an ask it cancelled. type Cancelled struct { At string `json:"at"`