One commit, one plan: a commit off the trunk — a pull request's head, a branch built by hand, a rebuild or replay of one — is for checking. The build seat reads from its clone which branches hold the commit, and the controller records and never registers a build whose commit is not on the branch the module follows (the repository's default for a new one), so nothing off the trunk can be sent. A pull request's check now carries its change plan, computed by the planner: what a merge would build in which order, what each machine would receive, and what is not an ordinary send — the bus step, a module waiting for a person, a provider's consumers.
315 lines
16 KiB
Go
315 lines
16 KiB
Go
package link
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// Emitting a module event from Go.
|
|
//
|
|
// **Every event rides one topic exchange** (novox/hq ADR 0042), which is not the direct exchange
|
|
// nodes and the control plane speak over. A module that announces something publishes here, and
|
|
// consumers bind their own durable queue to a pattern over it.
|
|
//
|
|
// This exists because the builder is a module written in Go while every other emitter is
|
|
// TypeScript on the sdk. The envelope is the sdk's, reproduced exactly: the body is the payload
|
|
// alone and everything about the event travels as headers. A second shape would be a second thing
|
|
// for consumers to handle, and they are written against the first.
|
|
const (
|
|
// EventsExchange is where every event rides. Named here rather than imported from the broker
|
|
// package for the same reason BuildQueueName is duplicated there — one direction of dependency.
|
|
EventsExchange = "mesh.events"
|
|
)
|
|
|
|
// EmitEvent publishes one module event, in the envelope the sdk's consumers expect.
|
|
//
|
|
// The envelope is the transport's to write (bus.go) and this is only what goes in it, which is
|
|
// what lets one conformance fixture hold both implementations to the same headers.
|
|
func EmitEvent(ctx context.Context, bus Bus, eventType, source, node string, body any) error {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot serialise a %s event: %w", eventType, err)
|
|
}
|
|
// The publish is not confirmed by the caller: it has already done the work the event
|
|
// describes, and a build that succeeded must not be reported as failed because saying so
|
|
// failed. Each transport decides what "published" means for it.
|
|
return bus.PublishEvent(ctx, eventType, source, node, payload)
|
|
}
|
|
|
|
// eventID is what a consumer deduplicates on: delivery is at-least-once, so a handler must be able
|
|
// to tell a redelivery from a second event, and only the emitter can say which it is.
|
|
func eventID() (string, error) {
|
|
raw := make([]byte, 16)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", fmt.Errorf("cannot make an event id: %w", err)
|
|
}
|
|
return hex.EncodeToString(raw), nil
|
|
}
|
|
|
|
// MeshControllerSeat is the role the control plane holds, and therefore where its own facts live: a
|
|
// role's events belong to the role, not to whichever container is holding it today (novox/hq ADR 0121,
|
|
// ADR 0129). It is what makes them addressable while the control plane itself is being replaced.
|
|
const MeshControllerSeat = "mesh-controller"
|
|
|
|
// The facts the mesh states about its own work (novox/hq ADR 0134).
|
|
const (
|
|
// KeyApplied: a machine now runs what it was sent.
|
|
KeyApplied = "applied"
|
|
// KeyRefused: a machine did not take what it was sent, and why.
|
|
KeyRefused = "refused"
|
|
// KeyBuiltBefore: a build the mesh already held, for a catalogue that asked what it missed. Not
|
|
// `built` — that is the build machine's, said as it happens, and a replay is neither.
|
|
KeyBuiltBefore = "built-before"
|
|
// KeySecretReplaced: a value given to the mesh by hand was replaced with one it made, after its
|
|
// module's first good start (novox/hq ADR 0228). Never the value.
|
|
KeySecretReplaced = "secret-replaced"
|
|
// KeyHealerActed: a healer acted on a condition — what it did and what came of it, or that its budget
|
|
// is spent and the condition is the operator's (novox/hq to-be 45 §7). Never a person's act: those
|
|
// are the hand-act log's.
|
|
KeyHealerActed = "healer-acted"
|
|
// KeyRolledBack: a build failed its gate on its first machine and was put back there, or could not
|
|
// be (novox/hq ADR 0236, to-be 45 §8); or a witness on a machine put a core component back.
|
|
KeyRolledBack = "rolled-back"
|
|
// KeyChecked: a pull request's merge check was judged (novox/hq to-be 45 §9) — the verdict, its
|
|
// summary and its report, for the forge's holder to set as the pull request's status.
|
|
KeyChecked = "checked"
|
|
)
|
|
|
|
// Applied is what a machine now runs, as the mesh states it.
|
|
type Applied struct {
|
|
Node string `json:"node"`
|
|
Declared string `json:"declared,omitempty"`
|
|
// Resources is how many the machine applied, not which: the list is the machine's own account
|
|
// of itself and belongs in the records, not in a fact every listener has to read past.
|
|
Resources int `json:"resources"`
|
|
}
|
|
|
|
// Refused is a machine that would not take what it was sent.
|
|
type Refused struct {
|
|
Node string `json:"node"`
|
|
Declared string `json:"declared,omitempty"`
|
|
Refused string `json:"refused,omitempty"`
|
|
Failed map[string]string `json:"failed,omitempty"`
|
|
}
|
|
|
|
// KeyModuleBuilt is what the builder announces when it has built something. The catalogue places
|
|
// it in the module graph; nothing else need care.
|
|
const KeyModuleBuilt = "module.builder.built"
|
|
|
|
// KeyModuleUpgraded is the catalogue saying a module's current version has moved.
|
|
//
|
|
// **The control plane hooks the meaning, not the build.** The builder says what it built; the
|
|
// catalogue decides whether that was an upgrade — a rebuild producing the commit already current
|
|
// is not one — and only this says anything the control plane can act on. Consuming the build
|
|
// directly would make the control plane re-derive a decision another module already made, and the
|
|
// two would eventually disagree (novox/hq ADR 0072).
|
|
const KeyModuleUpgraded = "module.mesh-catalog.upgraded"
|
|
|
|
// KeyCatchingUp is the catalogue saying it has just started and may have missed things.
|
|
//
|
|
// **A durable queue only keeps what arrived after it existed.** The catalogue's own queue is
|
|
// durable, so nothing is lost once it is running — but the modules built before it first ran were
|
|
// announced to a queue that did not exist yet, and on a fresh mesh those are, necessarily, the
|
|
// shared base, the store the catalogue runs on, and the catalogue itself. The graph's foundation
|
|
// is the part it never hears about (novox/hq 04-ISSUES/050).
|
|
//
|
|
// So it asks, and the control plane answers with what it recorded. Asking rather than being told
|
|
// because only the catalogue knows it has a gap; the control plane cannot tell a fresh catalogue
|
|
// from one that is merely quiet.
|
|
const KeyCatchingUp = "module.mesh-catalog.catching-up"
|
|
|
|
// CatchUpQueue is where that lands. Durable, for the same reason the upgrade queue is: a catalogue
|
|
// that started while the control plane was restarting is exactly the one with a gap to fill.
|
|
const CatchUpQueue = "control.catchup"
|
|
|
|
// UpgradeQueue is where those land. Durable and named, not a temporary queue: an upgrade announced
|
|
// while the control plane is restarting is exactly the one that must not be missed.
|
|
const UpgradeQueue = "control.upgrades"
|
|
|
|
// Replayer answers a catalogue that says it has just started.
|
|
//
|
|
// It is handed every build the mesh recorded, oldest first, and re-announces each. The catalogue
|
|
// registers them as history: a replayed build changed nothing in the world, so announcing it as an
|
|
// upgrade would have the mesh act on news that is years old.
|
|
type Replayer interface {
|
|
// Announceable is every build worth re-announcing, oldest first.
|
|
//
|
|
// It hands them back rather than publishing them: the wire belongs to this package, and a
|
|
// replay that built its own announcements could drift from what the builder emits — which is
|
|
// the one thing it must match exactly, because the catalogue has a single handler for both.
|
|
Announceable(ctx context.Context) ([]Announcement, error)
|
|
}
|
|
|
|
// Announcement is a build, in the shape the builder announces one.
|
|
//
|
|
// The field names are the wire's, not Go's, because a catalogue reads these and a rename here is
|
|
// an event nobody handles.
|
|
type Announcement struct {
|
|
Module string `json:"module"`
|
|
Commit string `json:"commit"`
|
|
Repository string `json:"repository"`
|
|
Path string `json:"path"`
|
|
Ref string `json:"ref"`
|
|
Manifest json.RawMessage `json:"manifest,omitempty"`
|
|
Against []string `json:"against,omitempty"`
|
|
Made []MadeArtifact `json:"made,omitempty"`
|
|
// Replay says this is history rather than news: it was built once, and this is the mesh
|
|
// telling a catalogue that missed it. A consumer registers it and announces nothing — an
|
|
// upgrade that happened months ago is not one anything should act on now.
|
|
Replay bool `json:"replay,omitempty"`
|
|
}
|
|
|
|
// Upgraded is what the catalogue says when a module's current version moves.
|
|
// SourceMoved is what the forge announces when a pull request is merged: which repository, into
|
|
// which branch, producing which commit. The mesh matches it against every module's recorded
|
|
// source and builds what moved, bases first.
|
|
type SourceMoved struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
Base string `json:"base"`
|
|
Head string `json:"head"`
|
|
Commit string `json:"merge_commit_sha"`
|
|
CloneURL string `json:"clone_url"`
|
|
HTMLURL string `json:"html_url"`
|
|
// MergedAt is when the forge merged it, RFC 3339. What decides whether this is news.
|
|
MergedAt string `json:"merged_at"`
|
|
|
|
// Paths are the files the merge changed, from the repository's root. Empty means the forge said
|
|
// nothing about them, and every module built from the repository is treated as affected.
|
|
Paths []string `json:"paths,omitempty"`
|
|
|
|
// PathsTruncated says the merge changed more files than the forge was asked to list, so Paths is
|
|
// a beginning rather than the whole change — and again, everything is treated as affected. Said
|
|
// rather than inferred from a round number, because "this is all of it" and "this is as much as
|
|
// I asked for" are the difference between rebuilding a module and leaving it stale.
|
|
PathsTruncated bool `json:"paths_truncated,omitempty"`
|
|
|
|
// Removed are the files among Paths the merge deleted. A module whose manifest is among them was
|
|
// deleted at its source: it is forgotten, or said, and never built (novox/hq ADR 0236). Empty from an
|
|
// announcer that does not say which files went, and then a build that finds no manifest says it.
|
|
Removed []string `json:"removed,omitempty"`
|
|
|
|
// ModuleDirs are the directories holding the files the merge changed that hold a `module.json` at
|
|
// the merge commit, from the repository's root (novox/hq issue 278). A changed file inside one of
|
|
// them is that module's business, whether or not the mesh holds the module and whether or not the
|
|
// merge touched its manifest; only a file in no such directory is shared code. Without it, a
|
|
// change to a module the mesh does not hold — whose manifest the merge left alone — read as shared
|
|
// and rebuilt every module built from the repository.
|
|
ModuleDirs []string `json:"module_dirs,omitempty"`
|
|
|
|
// ModuleDirsSaid says the announcer looked, so an empty ModuleDirs means "none of them is a
|
|
// module" rather than "not said". An announcer from before this says nothing, and the old rule
|
|
// stands: a directory is a module only when the merge changed its manifest.
|
|
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
|
}
|
|
|
|
// PullUpdated is what the forge announces when an open pull request's head moves — opened, or pushed
|
|
// to (novox/hq to-be 45 §9): what the controller asks the build seat to check before it merges.
|
|
type PullUpdated struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
Number int `json:"number"`
|
|
Title string `json:"title,omitempty"`
|
|
Base string `json:"base"`
|
|
Head string `json:"head"`
|
|
Commit string `json:"head_sha"`
|
|
CloneURL string `json:"clone_url"`
|
|
HTMLURL string `json:"html_url,omitempty"`
|
|
// Paths are the files the pull request changes; PathsTruncated says there were more.
|
|
Paths []string `json:"paths,omitempty"`
|
|
PathsTruncated bool `json:"paths_truncated,omitempty"`
|
|
// Removed are the files among Paths the change deletes: a module whose manifest is among them is one
|
|
// the merge would remove.
|
|
Removed []string `json:"removed,omitempty"`
|
|
// ModuleDirs are the directories above the changed files that hold a `module.json` at the head, read
|
|
// as the merge announcer reads them at a merge commit (issue 278); ModuleDirsSaid says it looked.
|
|
// What the controller finds a module the graph does not hold yet with: a directory the change adds a
|
|
// module.json in is a new module, checked before it merges.
|
|
ModuleDirs []string `json:"module_dirs,omitempty"`
|
|
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
|
|
// MergeCheck says the head holds a merge-check.sh at its root — the repository's own tests, the
|
|
// check's second layer; MergeCheckSaid says the announcer looked.
|
|
MergeCheck bool `json:"merge_check,omitempty"`
|
|
MergeCheckSaid bool `json:"merge_check_said,omitempty"`
|
|
}
|
|
|
|
// Checked is a pull request's merge check, judged: what the controller says as `checked`.
|
|
type Checked struct {
|
|
Owner string `json:"owner"`
|
|
Repo string `json:"repo"`
|
|
Number int `json:"number,omitempty"`
|
|
Commit string `json:"commit"`
|
|
// Verdict is pass, warning, fail, or error — the check could not be run, which is not the change's
|
|
// fault and is never read as a pass.
|
|
Verdict string `json:"verdict"`
|
|
Summary string `json:"summary"`
|
|
// Report is the check's own account, bounded.
|
|
Report string `json:"report,omitempty"`
|
|
// ID is the ask, and On the machine that ran it.
|
|
ID string `json:"id"`
|
|
On string `json:"on,omitempty"`
|
|
// Gate and RepoCheck are the check's two layers, each its own status on the pull request (novox/hq
|
|
// ADR 0237 as amended): the mesh's — the modules of the graph the change touches, every machine
|
|
// composed with it — as `mesh/merge-gate`, and the repository's own merge-check.sh as
|
|
// `mesh/repo-check`. Verdict and Summary above are the gate's, for a forge holder that reads no more.
|
|
// RepoCheck is nil when the repository is not the mesh's and touches nothing of it: nothing is said.
|
|
Gate *CheckLayer `json:"gate,omitempty"`
|
|
RepoCheck *CheckLayer `json:"repo-check,omitempty"`
|
|
// Plan is the change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build
|
|
// and send, posted with the verdict.
|
|
Plan *ChangePlan `json:"plan,omitempty"`
|
|
}
|
|
|
|
// ChangePlan is what a change does to the mesh, computed from its diffset — a repository, the branch it
|
|
// merges into and the commit at hand — by the planner (novox/hq ADR 0238): **one commit, one plan**, the
|
|
// object a pull request's check posts, the release follows and a person reads.
|
|
type ChangePlan struct {
|
|
Repository string `json:"repository"`
|
|
Base string `json:"base"`
|
|
Head string `json:"head"`
|
|
// Moved are the modules a merge moves itself, Dependents those built after them because they stand
|
|
// on them, New the directories it adds a module in, and Unread the changed files no build reads.
|
|
Moved []string `json:"moved,omitempty"`
|
|
Dependents []string `json:"dependents,omitempty"`
|
|
New []string `json:"new,omitempty"`
|
|
Unread []string `json:"unread,omitempty"`
|
|
// Tiers are the build plan's order: each tier built after the one before.
|
|
Tiers [][]string `json:"tiers,omitempty"`
|
|
// Machines are the deploy plan: what each machine is sent, in the build plan's order, and what waits
|
|
// there for a person.
|
|
Machines []MachinePlan `json:"machines,omitempty"`
|
|
// Steps are what is not an ordinary send: a planned bus step, a provider whose consumers are sent again,
|
|
// a module that waits for a person, a module that keeps data.
|
|
Steps []string `json:"steps,omitempty"`
|
|
// Summary is the plan in one line, for a commit status.
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// MachinePlan is one machine's part of a change plan.
|
|
type MachinePlan struct {
|
|
Machine string `json:"machine"`
|
|
Receives []string `json:"receives,omitempty"`
|
|
Waits []string `json:"waits,omitempty"`
|
|
}
|
|
|
|
// CheckLayer is one layer of a merge check, judged.
|
|
type CheckLayer struct {
|
|
// Verdict is pass, warning, fail or error; error is never a pass.
|
|
Verdict string `json:"verdict"`
|
|
Summary string `json:"summary"`
|
|
// Modules are, for the gate, the modules of the mesh's graph the change touches — `new:<dir>` for a
|
|
// module the graph does not hold yet; Dependents those a merge would build after them because they
|
|
// stand on them — the planner's own answer.
|
|
Modules []string `json:"modules,omitempty"`
|
|
Dependents []string `json:"dependents,omitempty"`
|
|
}
|
|
|
|
type Upgraded struct {
|
|
Module string `json:"module"`
|
|
Commit string `json:"commit"`
|
|
Previous string `json:"previous"`
|
|
}
|