Files
mesh-controller/internal/link/builds.go
T
jochen ff5ef0ab60 The controller asks the build role that has a holder, and hears both roles' outcomes (hq ADR 0190, the handover)
A controller that asked node-build-agent from its first run would queue every build where nothing
pulls, and the build that registers build-agent — the first holder — would be among them. So the
role is chosen at ask time from the catalogue: the current role when any assigned module claims it,
the retired one while only the builder does, the current one when neither. Outcomes are followed on
both seats, the controller may publish to both, and a build's log is read under whichever role did
it; a machine on the retired role is proven on the bus to take that role's asks. The switch order
is written where the role is named, and the retired half is marked for removal with the seat row.
2026-10-03 02:51:03 +02:00

202 lines
9.9 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: node-scoped, held on every machine that
// builds, and the work shared among them (novox/hq ADR 0190). The name stays for every caller; what
// it names moved from the mesh's one build machine to whichever build agent is idle.
//
// **Switching a live mesh over, in order** — and why no step strands a build. The old seat's
// stream and worker (SEAT_MESH_BUILD_MACHINE, SEAT_MESH_BUILD_MACHINE_worker) stay on the bus until
// removed by hand, and the builder keeps draining them while it is assigned, because a machine
// serves the seat its credential claims (BuildSeatClaimed) and the controller asks the seat that
// has a holder (buildSeatAmong in the command) and hears both seats' outcomes:
//
// 1. Merge the controller and the host's first user list together; the new controller rolls and,
// seeing only the builder assigned, still asks mesh-build-machine — which the builder holds.
// 2. Merge the catalogue's build-agent; the builder builds it and the controller registers it.
// 3. On each machine that builds: `module issue build-agent --node <n>`, then `assign`, then
// `push`. The first holder appears, and from then on asks go to node-build-agent.
// 4. Unassign builder everywhere and `module forget` it.
// 5. By hand: delete SEAT_MESH_BUILD_MACHINE and its worker, drop the retired seat row and
// TheBuildMachineBefore with it, and the second entries in seatsTheControllerAsks and
// ControllerFollows.
const TheBuildMachine = "node-build-agent"
// TheBuildMachineBefore is the role a build was submitted to until ADR 0190: the mesh's one build
// machine, mesh-scoped. Kept named while the handover runs — a machine whose credential claims it
// still serves it, and the controller still hears its outcomes — and dropped with the retired seat
// row once nothing claims it.
const TheBuildMachineBefore = "mesh-build-machine"
// BuildSeatClaimed is the build role a machine serves: the first seat its credential claims, or the
// current role when the credential names none (a credential from before claims travelled in it, or
// one written by hand). **The credential decides, not the binary** (ADR 0190 handover): one build
// machine binary runs as the old `builder` on the old seat and as a `build-agent` on the new one,
// each taking the work the mesh issued it a credential for, so neither drains the other's queue
// and the switch needs no flag day.
func BuildSeatClaimed(claimed []string) string {
for _, seat := range claimed {
if seat != "" {
return seat
}
}
return TheBuildMachine
}
// 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. The no-argument forms name the
// current role; the `Of` forms take the seat, for the handover during which two roles exist.
func BuildWork() string { return BuildWorkOf(TheBuildMachine) }
func BuildOutcome() string { return BuildOutcomeOf(TheBuildMachine) }
func BuildWorkOf(seat string) string { return "mesh.seat." + seat + ".accept.build" }
func BuildOutcomeOf(seat string) string {
return "mesh.seat." + seat + ".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 BuildStartedOf(TheBuildMachine) }
func BuildLog(id string) string { return BuildLogOf(TheBuildMachine, id) }
func BuildStartedOf(seat string) string { return "mesh.seat." + seat + ".event.started" }
func BuildLogOf(seat, id string) string { return "mesh.seat." + seat + ".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
}