Files
mesh-controller/internal/link/builds.go
T
jschoubben 076e0ae259 A build is taken in where its outcome is heard, and the build tool answers at once (issue 176)
The console's `build` tool answered "no build machine answered within 0s", handed a forge path to
git as written, and a build heard afterwards was recorded and never registered: recording and
registration lived only in the waiting caller, and the tool did not wait.

Now one function takes a build's outcome in — records it, parses the manifest, refuses a definition
naming an installation, registers the module with its source as the seat and path the request
carried — and both the waiting command and the daemon that follows the role's `built` event call
it. `build --wait 0` asks and returns with the id; `builds --log <id>` follows it. The seat verb
says `--self` for a repository given without a scheme.
2026-10-01 01:27:04 +02:00

156 lines
7.0 KiB
Go

package link
import (
"context"
"encoding/json"
"fmt"
"time"
)
// Asking a role to build something, and being told what came of it.
//
// A build is work submitted to a role, not a message to a machine (novox/hq ADR 0121). The
// build-machine seat accepts a build and emits an outcome, so the same publish that answers whoever
// asked also reaches the controller that records it and the catalogue that places it in the module
// graph — and no build machine needs permission to publish into anybody's inbox.
//
// **This is the one flow whose shape differs from every other**, which is why it has its own seam
// rather than living in `Bus`. Everything else the controller sends is either an event nobody must
// act on or a declaration a node reconciles toward; a build is a request that takes minutes and has
// exactly one answer. Too long for request/reply, too particular to be an event.
// TheBuildMachine is the role a build is submitted to.
const TheBuildMachine = "mesh-build-machine"
// BuildWork is where a build request lands, and BuildOutcome is where its result does. Derived from
// the seat, so both sides name the role and neither names the other.
func BuildWork() string { return "mesh.seat." + TheBuildMachine + ".accept.build" }
func BuildOutcome() string { return "mesh.seat." + TheBuildMachine + ".event.built" }
// BuildStarted is where a build machine says it has taken a build, and BuildLog is where it says
// what it is doing, one line per message, under the build's own id (novox/hq ADR 0157).
//
// **The whole build is on the bus as it happens.** The outcome alone told a person that a build
// failed and its first line why; everything between — which command, how long, where it hung —
// lived in one container's stderr on one machine. Every line is now an event of the role, retained
// with the rest of the mesh's events, so a reader follows a build live by subscribing its subject,
// or reads it back afterwards from the stream, and a viewer is a subscriber and nothing more.
func BuildStarted() string { return "mesh.seat." + TheBuildMachine + ".event.started" }
func BuildLog(id string) string { return "mesh.seat." + TheBuildMachine + ".event.log." + id }
// BuildStart is what a build machine says the moment it takes a build.
type BuildStart struct {
ID string `json:"id"`
Repository string `json:"repository"`
Path string `json:"path,omitempty"`
Ref string `json:"ref,omitempty"`
On string `json:"on"`
At string `json:"at"`
}
// BuildLine is one thing a build said while building.
type BuildLine struct {
ID string `json:"id"`
// Seq counts the lines of one build from 1, so a reader that joined late or read two copies
// can order them and see a gap.
Seq int `json:"seq"`
At string `json:"at"`
// Step is which part of the build spoke — clone, context, image, run, failed — and Message is
// what it said, as the builder's own log prints it.
Step string `json:"step"`
Message string `json:"message"`
}
// KeyRoleBuilt is the build outcome under the role's name, on the bus the mesh runs on today.
//
// The same event as KeyModuleBuilt and published beside it, because a catalogue installed before this
// change listens for the module's name and one installed after listens for the role's. Both, until
// this bus retires: a rename needs publisher and subscriber to change together, and a deployment
// cannot promise which arrives first.
const KeyRoleBuilt = "built"
// Builders is how work reaches a build machine and how the outcome comes back.
type Builders interface {
// Submit asks for one build and waits for its outcome.
//
// The wait is long by nature. A build clones, pulls a base image and runs a container build, so
// a timeout here says "nothing is doing builds" rather than "this build is slow" — and the two
// need different remedies, which is why the message distinguishes them.
Submit(ctx context.Context, request BuildRequest, wait time.Duration) (BuildResult, error)
// Ask submits one build and does not wait: the outcome is the role's event, heard and taken in
// by the controller whether or not anybody waited (novox/hq issue 176). For a caller that
// cannot hold a connection for the minutes a build takes — a tool call — and follows the build
// by its id instead.
Ask(ctx context.Context, request BuildRequest) error
// Close lets go of whatever was dialled.
Close()
}
// BuildMachine is a machine taking work from the role it holds.
type BuildMachine interface {
// Take hands each request to do until the context ends, and says why it stopped.
Take(ctx context.Context, do func(context.Context, Build)) error
Close()
}
// Build is one request a machine has been handed.
type Build interface {
// Began says the build has been taken and is under way, before anything runs.
Began(ctx context.Context) error
// Say publishes one line of what the build is doing. Never fails the build: a line the bus
// did not take is a line lost, and the outcome still comes.
Say(step, message string)
// Request is what to build.
Request() BuildRequest
// Announce publishes the outcome as the role's own event.
//
// One publish, three audiences: whoever asked matches it by the id their request carried, the
// controller records it, and the catalogue places it. On the bus the mesh runs on today that
// fan-out came from a shared exchange; here the mesh derived the subject.
Announce(ctx context.Context, result BuildResult) error
// Done settles the request. Called only after the outcome is away, so a machine that dies
// before announcing leaves the work for another rather than losing it.
Done() error
// Hold hands the work back for another attempt after the delay.
Hold(after time.Duration) error
}
// waitingFor is the message a caller gets when nothing answered. Its own function because both
// transports say it, and saying it differently in two places is how one of them ends up vague.
func waitingFor(wait time.Duration) error {
return fmt.Errorf(
"no build machine answered within %s. Either nothing holds %s — in which case the work is "+
"queued and will be done when something does — or a build is taking longer than this",
wait, TheBuildMachine)
}
// theOutcomeOf reads a result and says whether it is the answer to this request.
func theOutcomeOf(body []byte, id string) (BuildResult, bool, error) {
var result BuildResult
if err := json.Unmarshal(body, &result); err != nil {
return BuildResult{}, false, fmt.Errorf("a build machine answered with something unreadable: %w", err)
}
// Somebody else's build. Skipped rather than returned, because returning it would attribute one
// build's outcome to another's.
return result, result.ID == id, nil
}
// ModuleOf reads the module's name out of a manifest a build produced, which is the only place it is
// authoritative — a request named a repository and a path, not a module.
func ModuleOf(manifest json.RawMessage) string {
var named struct {
Module string `json:"module"`
}
if err := json.Unmarshal(manifest, &named); err != nil {
return ""
}
return named.Module
}