A fingerprint written into a recipe names one particular copy of the base — the copy on whichever machine the person typing it was using. On any other mesh that copy has never existed, so the build stops on its first line with a message about an image nobody can look up. Three modules in the catalogue were in exactly that state, and the line each of them replaced was equally dead. A module now names the module and artifact instead, and the mesh answers with what it holds. The builder is still a thing that clones, builds and answers: the answer travels with the question, because only the mesh knows what it has. A base the mesh has not built is refused before anything is built, naming which module has to exist first.
185 lines
8.1 KiB
Go
185 lines
8.1 KiB
Go
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"`
|
|
// Path is the module's directory inside that repository (novox/hq ADR 0069). Empty means the
|
|
// repository root, which is the ordinary case; a repository holding several modules names
|
|
// each by its own directory.
|
|
Path string `json:"path,omitempty"`
|
|
// Held is every artifact this mesh has built, keyed "<module>/<artifact>".
|
|
//
|
|
// **Sent with the asking rather than fetched by the builder** (novox/hq issue 044). A module
|
|
// says which module's artifact its build stands on; only the mesh knows which copy of that
|
|
// artifact *this* mesh holds, and the builder is deliberately a thing that clones, runs a
|
|
// build and answers — giving it a way to ask the mesh questions would make it something else.
|
|
// So the answer travels with the question.
|
|
//
|
|
// It is everything rather than only what this module needs, because what this module needs is
|
|
// written in a manifest the mesh has not read: it is inside the repository, and reading it is
|
|
// the build's first act.
|
|
Held map[string]string `json:"held,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"`
|
|
// Path is echoed back, so what the mesh records as this module's source is what was actually
|
|
// built rather than what the asker meant (novox/hq ADR 0069).
|
|
Path string `json:"path,omitempty"`
|
|
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"`
|
|
|
|
// Against is every pinned image this was built on top of, read out of the build's own inputs
|
|
// (novox/hq ADR 0009). The catalogue turns these into edges; nothing else need care.
|
|
Against []string `json:"against,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
|
|
}
|
|
}
|
|
}
|