package link import ( "context" "encoding/json" "fmt" "time" amqp "github.com/rabbitmq/amqp091-go" ) // Asking a machine to build a module, and hearing what came out. // // **A build is work, not state.** Everything else the control plane sends a node is a declaration // — *this is what you should be* — and the node reconciles toward it forever. A build happens // once, produces something, and is finished. Putting it in a declaration would mean a machine // rebuilding on every reconcile, or the declaration carrying "and I already did this", which is // state about an event rather than about the machine. // // So it travels on its own queue, and the reply comes back correlated. That is also why the // builder is a **separate consumer** rather than the host: the host applies declarations and // holds no opinion about what they contain, and a host that also built things would be a host // with a container runtime requirement and a git dependency (novox/hq ADR 0005). // BuildQueue is where build requests wait. One queue, so several build machines can share the // work and each request is done exactly once — which is what a queue is for and what a // per-machine routing key would not give. const BuildQueue = "builds" // KeyBuilt is what a builder publishes when it has finished, successfully or not. const KeyBuilt = "built" // ReplyQueue is where the answer to one request goes. // // **Named here rather than left to the broker**, so it can be scoped and reasoned about. A broker // generates its own name for an unnamed queue, and a builder permitted to write to whatever that // convention happens to produce works on one broker and silently cannot answer on another. func ReplyQueue(id string) string { return BuildQueue + ".reply." + id } // BuildRequest is one module to build. type BuildRequest struct { // ID correlates the answer with the asking. Not the module name: two builds of one module can // be in flight, and the second answer is not the first one's. ID string `json:"id"` // Repository is where the source is, as git would clone it. Repository string `json:"repository"` // Ref is the branch, tag or commit. Empty means whatever the repository's default is, which // is the only case where the mesh does not know what it built until it has built it. Ref string `json:"ref,omitempty"` } // BuildResult is what a builder says back. // // **Failure is a result, not an absence.** A build that fails and says nothing is // indistinguishable from a builder that is not running, and those want completely different // responses — the same rule the host follows about a service that does not exist. type BuildResult struct { ID string `json:"id"` Repository string `json:"repository"` Ref string `json:"ref,omitempty"` // On is the machine that did it, so a failure that is about one machine can be told from one // about the source. On string `json:"on"` // Commit is what was actually built. The mesh records it, which is what makes "is this // current?" answerable without building again. Commit string `json:"commit,omitempty"` // Manifest is the module as the mesh should hold it, artifacts resolved to digests. Raw, // because the control plane parses it with the same parser it uses for one handed over by // hand — a second path would be a second thing to disagree. Manifest json.RawMessage `json:"manifest,omitempty"` // Made is each artifact, for reporting. Made []MadeArtifact `json:"made,omitempty"` // Failed is why, when it did. Failed string `json:"failed,omitempty"` } // MadeArtifact is one thing a build produced, as a person would want it reported. type MadeArtifact struct { Name string `json:"name"` Kind string `json:"kind"` Reference string `json:"reference"` } // RequestBuild asks for a module to be built and waits for the answer. // // Waiting rather than returning immediately, because the thing a person wants after asking for a // build is to know whether it worked. A build that is dispatched and forgotten needs somewhere to // look afterwards, and there is nowhere yet. func RequestBuild(ctx context.Context, channel *amqp.Channel, request BuildRequest, timeout time.Duration) (BuildResult, error) { // Its own queue for the answer, declared before the ask. Consuming from the shared exchange // would mean competing with the control plane's own consumer for a message meant for this // caller — which is the fault this package's own doc comment records having had. replies, err := channel.QueueDeclare(ReplyQueue(request.ID), false, true, true, false, nil) if err != nil { return BuildResult{}, err } // Bound to the exchange, and the answer comes back through it. // // **A builder never publishes to the default exchange**, because permission there is per // exchange and not per queue — a builder allowed to use it could publish into any node's // queue, which is the privilege a build machine most obviously should not have. Found by // running it: the builder built, could not answer, and the connection closed saying only // "not allowed to publish to exchange ''". // // The cost is that every asker sees every result, which is why the correlation is checked // below rather than assumed. if err := channel.QueueBind(replies.Name, KeyBuilt, Exchange, false, nil); err != nil { return BuildResult{}, err } answers, err := channel.ConsumeWithContext(ctx, replies.Name, "", true, true, false, false, nil) if err != nil { return BuildResult{}, err } body, err := json.Marshal(request) if err != nil { return BuildResult{}, err } if err := channel.PublishWithContext(ctx, "", BuildQueue, false, false, amqp.Publishing{ ContentType: "application/json", DeliveryMode: amqp.Persistent, CorrelationId: request.ID, ReplyTo: replies.Name, Body: body, }); err != nil { return BuildResult{}, err } waiting, cancel := context.WithTimeout(ctx, timeout) defer cancel() for { select { case <-waiting.Done(): return BuildResult{}, fmt.Errorf( "no builder answered within %s. Something must be consuming %q, and nothing is "+ "— or it is building something that takes longer than this", timeout, BuildQueue) case delivery, ok := <-answers: if !ok { return BuildResult{}, fmt.Errorf("the connection closed while waiting for a build") } var result BuildResult if err := json.Unmarshal(delivery.Body, &result); err != nil { return BuildResult{}, fmt.Errorf("a builder answered with something unreadable: %w", err) } if result.ID != request.ID { // Somebody else's answer on this queue. Ignored rather than returned, because // returning it would attribute one build's outcome to another's. continue } return result, nil } } }