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 `, 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 }