Builds have a history, and failures are rows like any other

A build result was answered to whoever asked and kept nowhere. So "when
did this last build", "why did it fail" and "which machine built what is
running" had no answer, and a build nobody was waiting for was reported
into the void — which is the same as not reporting it.

Failures are recorded too, and that is the point rather than a detail: a
failed build that leaves no trace is indistinguishable from one nobody
asked for, and the difference is the whole of whether somebody should be
looking at something. A build that never learned what it was building
keeps the repository, because that is what a person goes and looks at.

Recording is idempotent on the correlation id, because a result can
arrive twice — as the answer to whoever asked, and on the exchange when
nobody was. Two rows would show one build as two, and which is real is
not answerable afterwards.

The serving control plane now binds `built` as well, so results from
builds it did not ask for are kept. It refuses them loudly when it has
nowhere to put them rather than dropping them, so the broker's own
counters show something arriving that nothing handles.

`builds [<module>]` reads it: what happened lately across the mesh, or
what has happened to one module — the first asked after something goes
wrong, the second when deciding whether to trust something.

What was published is kept with the build, so a digest traces back to
what made it without holding the manifest twice in a place that can
disagree with the first.
This commit is contained in:
2026-08-30 10:18:23 +02:00
parent 78c5b653cf
commit 0bbb5c6838
5 changed files with 464 additions and 8 deletions
+124 -6
View File
@@ -65,6 +65,8 @@ func run() error {
switch args[0] {
case "build":
return buildCommand(ctx, args[1:])
case "builds":
return buildsCommand(ctx, args[1:])
case "pin":
return pinCommand(ctx, args[1:], true)
case "unpin":
@@ -134,6 +136,7 @@ func usage() {
settings set <module> <file> --node <n> ...or for one machine
settings clear <module> [--node <n>] take a layer away
build <repository> [--ref R] have a build machine build it, and record what came out
builds [<module>] what has been built lately, and what came of it
pin <node> <provision> <from> which node this one gets a provision from
unpin <node> <provision> put that question back
plan <node> [--files|--json] what that node would run, and why
@@ -490,6 +493,9 @@ func serve(ctx context.Context) error {
return err
}
defer server.Close()
// And build results nobody was waiting for. A build triggered any other way than `build`
// would otherwise be reported into the void, which is the same as not reporting it.
server.Records(builds{inv})
return server.Serve(ctx)
}
@@ -1711,6 +1717,19 @@ func buildCommand(ctx context.Context, args []string) error {
if err != nil {
return err
}
// Kept before it is judged. A failed build that leaves no trace is indistinguishable from one
// nobody asked for, and the difference is the whole of whether somebody should be looking at
// something.
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
if err := inv.RecordBuild(ctx, buildFrom(result)); err != nil {
return err
}
if result.Failed != "" {
// The builder's own words. Wrapping them in something about the control plane would put
// two explanations between a person and a build log.
@@ -1738,12 +1757,6 @@ func buildCommand(ctx context.Context, args []string) error {
return nil
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
// Recorded with where it came from, so "is this current?" is answerable without building it
// again (novox/hq ADR 0009).
if err := inv.RegisterModule(ctx, manifest, inventory.Source{
@@ -1757,3 +1770,108 @@ func buildCommand(ctx context.Context, args []string) error {
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
return nil
}
// buildFrom turns what a builder said into what the mesh keeps.
func buildFrom(result link.BuildResult) inventory.Build {
kept := inventory.Build{
ID: result.ID, Repository: result.Repository, Ref: result.Ref,
Commit: result.Commit, On: result.On, Failed: result.Failed,
}
for _, made := range result.Made {
kept.Made = append(kept.Made, inventory.Artifact{
Name: made.Name, Kind: made.Kind, Reference: made.Reference,
})
}
// The module name comes from the manifest, which only exists when the build got that far.
if len(result.Manifest) > 0 {
if m, err := catalogue.ParseManifest(result.Manifest); err == nil {
kept.Module = m.Module
}
}
return kept
}
// buildsCommand says what has been built lately.
func buildsCommand(ctx context.Context, args []string) error {
set := flag.NewFlagSet("builds", flag.ContinueOnError)
limit := set.Int("n", 20, "how many to show")
positionals, err := parseAround(set, args)
if err != nil {
return err
}
module := ""
if len(positionals) == 1 {
module = positionals[0]
} else if len(positionals) > 1 {
return errors.New("builds [<module>] [-n N]")
}
inv, err := openInventory(ctx)
if err != nil {
return err
}
defer inv.Close()
builds, err := inv.Builds(ctx, module, *limit)
if err != nil {
return err
}
if len(builds) == 0 {
// Said rather than printed as nothing: an empty list and a failed read must never look
// the same, and getting here means the store answered.
if module != "" {
fmt.Printf("nothing has been built for %s\n", module)
return nil
}
fmt.Println("nothing has been built yet")
return nil
}
for _, b := range builds {
what := b.Module
if what == "" {
// It failed before knowing what it was building, which is most of the interesting
// failures. The repository is what a person has to go and look at.
what = "?"
}
outcome := "built " + short(b.Commit)
if !b.Worked() {
outcome = "failed"
}
fmt.Printf("%-18s %-14s %-10s %s\n",
what, outcome, b.On, b.At.Local().Format("2006-01-02 15:04"))
fmt.Printf(" %s", b.Repository)
if b.Ref != "" {
fmt.Printf(" at %s", b.Ref)
}
fmt.Println()
for _, made := range b.Made {
fmt.Printf(" %-10s %s\n", made.Kind, made.Reference)
}
if !b.Worked() {
// The builder's own first line. The whole failure is often a build log, and printing
// it here would bury every other row.
fmt.Printf(" %s\n", firstLine(b.Failed))
}
}
return nil
}
// firstLine is as much of a failure as belongs in a list.
func firstLine(s string) string {
if cut := strings.IndexByte(s, '\n'); cut >= 0 {
return strings.TrimSpace(s[:cut])
}
return strings.TrimSpace(s)
}
// builds keeps what a builder said, for the serving control plane.
//
// A type of its own rather than a method on the enrolment, because they are unrelated things
// arriving on one queue and an implementation of one should not have to say anything about the
// other.
type builds struct{ inv *inventory.Inventory }
func (b builds) Built(ctx context.Context, result link.BuildResult) error {
return b.inv.RecordBuild(ctx, buildFrom(result))
}