Files
mesh-controller/internal/link/builds.go
jschoubben 5fcde512bc A build is announced under both names on the old bus, or merging breaks the live mesh
Found by asking what merging this would do to the mesh that is actually running — the
only place the question could have been asked, because the tests were green and both
buses were self-consistent.

Moving the build outcome to the role means a catalogue built from the current manifests
listens for the role's name. The catalogue *already running* listens for the module's,
because that is what it was told when it was installed. The two do not meet, so merging
as it stood would have stopped the live mesh's module graph being updated — silently,
since a binding that matches nothing is not an error.

A rename on a live bus needs the publisher and the subscriber to change together, and a
deployment cannot promise which arrives first. So the old bus announces under both names
and the order stops mattering. The module's own name retires with the bus, in step 5's
list; nothing has ever run on the bus being built, so there is no legacy name there and
this doubling has no counterpart.
2026-09-27 17:39:39 +02:00

109 lines
4.7 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" }
// 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)
// 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 {
// 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
}