Merge remote-tracking branch 'origin/main' into merge-tmp
This commit is contained in:
@@ -9,7 +9,7 @@ package catalogue
|
||||
// DeliverySeat is the seat mesh-delivery holds.
|
||||
const DeliverySeat = "mesh-delivery"
|
||||
|
||||
// deliveryVerbs are the delivery seat's tools: five that read, and the acts of a person and of healer H2.
|
||||
// deliveryVerbs are the delivery seat's tools: six that read, and the acts of a person and of healer H2.
|
||||
func deliveryVerbs() []Verb {
|
||||
return []Verb{
|
||||
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
||||
@@ -32,6 +32,16 @@ func deliveryVerbs() []Verb {
|
||||
Input: schema(map[string]string{"repository": "owner/repository",
|
||||
"paths": "the files it changes, comma-separated, from the repository's root",
|
||||
"base": "the branch it merges into (default main)"}, []string{"repository", "paths"})},
|
||||
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
|
||||
"of the commit's mesh statuses (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, " +
|
||||
"description and when it was set; the merge check's full verdict as the controller said it — each " +
|
||||
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
|
||||
"protection would let it merge, every required status being success.",
|
||||
Input: schema(map[string]string{"repository": "owner/repository",
|
||||
"number": "a pull request's number: its head is read",
|
||||
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
|
||||
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
|
||||
Optional: true},
|
||||
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
|
||||
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
|
||||
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
|
||||
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
|
||||
seat, ok := SeatNamed(DeliverySeat)
|
||||
if !ok {
|
||||
t.Fatal("the delivery seat is not in the set")
|
||||
}
|
||||
var checks *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "checks" {
|
||||
checks = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if checks == nil {
|
||||
t.Fatalf("the delivery seat promises %v and not checks", VerbNames(seat.Serves))
|
||||
}
|
||||
props, _ := checks.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"repository", "number", "commit"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("checks takes no %q", arg)
|
||||
}
|
||||
}
|
||||
if req, _ := checks.Input["required"].([]string); !reflect.DeepEqual(req, []string{"repository"}) {
|
||||
t.Errorf("checks requires %v, wanted only the repository", req)
|
||||
}
|
||||
|
||||
// Added after the seat's holder shipped, it is optional: the holder serving the ten verbs before it still
|
||||
// holds the seat, and one serving all eleven does too — so the controller and the catalogue can move in
|
||||
// either order, and no check of the catalogue fails on a module nobody touched between the two.
|
||||
if !checks.Optional {
|
||||
t.Fatal("checks is a condition of holding before its holder serves it")
|
||||
}
|
||||
var before []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "checks" {
|
||||
before = append(before, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: before}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without checks yet: %v", err)
|
||||
}
|
||||
m.Claims[0].Serves = VerbNames(seat.Serves)
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder serving every verb: %v", err)
|
||||
}
|
||||
// Every other verb is still a condition of holding.
|
||||
m.Claims[0].Serves = append([]string{"checks"}, before[1:]...)
|
||||
if err := CanHold(m, seat); err == nil || !strings.Contains(err.Error(), before[0]) {
|
||||
t.Fatalf("a holder without %s: %v", before[0], err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a move does to a module's containers (novox/hq ADR 0242).
|
||||
//
|
||||
// A container whose declaration changes is recreated, whatever changed in it: an image, an environment
|
||||
// variable, a health check adopted. A module whose containers are recreated in one send is down while
|
||||
// they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks
|
||||
// recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a
|
||||
// send says, per module, how many of its containers it recreates and whether any image changed, before
|
||||
// it is sent and in the plan that sent it.
|
||||
|
||||
// Recreation is what moving a module from one build's manifest to another's does to its containers.
|
||||
type Recreation struct {
|
||||
// Containers is how many containers the new build declares.
|
||||
Containers int
|
||||
// Recreated are the ids of the containers whose declaration differs — changed, added or gone —
|
||||
// sorted.
|
||||
Recreated []string
|
||||
// Images are the ids among them whose image changed (or that are added or gone).
|
||||
Images []string
|
||||
}
|
||||
|
||||
// Recreates compares two builds of a module, container by container, by resource id.
|
||||
func Recreates(from, to Manifest) Recreation {
|
||||
before := containersByID(from)
|
||||
after := containersByID(to)
|
||||
var r Recreation
|
||||
r.Containers = len(after)
|
||||
for id, now := range after {
|
||||
was, held := before[id]
|
||||
switch {
|
||||
case !held:
|
||||
r.Recreated = append(r.Recreated, id)
|
||||
r.Images = append(r.Images, id)
|
||||
case !sameDeclaration(was, now):
|
||||
r.Recreated = append(r.Recreated, id)
|
||||
if !sameDeclaration(was["image"], now["image"]) {
|
||||
r.Images = append(r.Images, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range before {
|
||||
if _, kept := after[id]; !kept {
|
||||
r.Recreated = append(r.Recreated, id)
|
||||
r.Images = append(r.Images, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(r.Recreated)
|
||||
sort.Strings(r.Images)
|
||||
return r
|
||||
}
|
||||
|
||||
// SpecOnly is whether the move recreates containers without any new image: a change to how they are
|
||||
// declared only — a health check adopted, a variable — which a person may well not expect to interrupt.
|
||||
func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 }
|
||||
|
||||
// Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing.
|
||||
func (r Recreation) Say(module string) string {
|
||||
if len(r.Recreated) == 0 {
|
||||
return ""
|
||||
}
|
||||
what := "with a new image"
|
||||
switch {
|
||||
case r.SpecOnly():
|
||||
what = "no new image, only their declaration"
|
||||
case len(r.Images) < len(r.Recreated):
|
||||
what = fmt.Sprintf("%d with a new image", len(r.Images))
|
||||
}
|
||||
whole := ""
|
||||
if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers {
|
||||
whole = ", every one at once: its service is interrupted until they are up again"
|
||||
} else if len(r.Recreated) > 1 {
|
||||
whole = ", at once: what they serve is interrupted until they are up again"
|
||||
}
|
||||
return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers,
|
||||
what, strings.Join(r.Recreated, ", "), whole)
|
||||
}
|
||||
|
||||
func containersByID(m Manifest) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if t, _ := r["type"].(string); t != "container" {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
out[id] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a
|
||||
// float are one value.
|
||||
func sameDeclaration(a, b any) bool {
|
||||
ra, errA := json.Marshal(a)
|
||||
rb, errB := json.Marshal(b)
|
||||
if errA != nil || errB != nil {
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
var na, nb any
|
||||
_ = json.Unmarshal(ra, &na)
|
||||
_ = json.Unmarshal(rb, &nb)
|
||||
return reflect.DeepEqual(na, nb)
|
||||
}
|
||||
@@ -22,6 +22,14 @@ type Verb struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Input map[string]any `json:"input,omitempty"`
|
||||
Output map[string]any `json:"output,omitempty"`
|
||||
// Optional marks a verb added to a mesh seat whose holder lives in another repository (design 33 §7,
|
||||
// additive within a version): a holder that serves it is accepted, and one that does not yet still
|
||||
// holds the seat. Without it the addition would be a deadlock — this controller refusing the holder that
|
||||
// does not serve the verb, the controller before it refusing the holder that does — and every check of
|
||||
// the catalogue between the two would fail on a module nobody touched. Once every holder serves it,
|
||||
// the mark is removed and the verb is a condition of holding like the rest. Never stored or said: the
|
||||
// seat set's protocol is the compiled one.
|
||||
Optional bool `json:"-"`
|
||||
}
|
||||
|
||||
func (v *Verb) UnmarshalJSON(raw []byte) error {
|
||||
@@ -442,7 +450,7 @@ func unservedVerbs(tools []string, promised []Verb) []string {
|
||||
}
|
||||
var missing []string
|
||||
for _, v := range promised {
|
||||
if !has[v.Name] {
|
||||
if !has[v.Name] && !v.Optional {
|
||||
missing = append(missing, v.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,3 +307,38 @@ func (i *Inventory) BuildOf(ctx context.Context, module, commit string) (string,
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ManifestAt is the manifest a module was registered with at a commit (or a commit it abbreviates): the
|
||||
// newest successful build from it, with the artifacts of the build standing for it when its source was
|
||||
// unchanged (issue 280) — what a machine last sent that build runs. False when no such build, or none
|
||||
// with a manifest, is kept (novox/hq issue 295: a recorded module is composed at the build its machine
|
||||
// runs until a person's push moves it).
|
||||
func (i *Inventory) ManifestAt(ctx context.Context, module, commit string) (catalogue.Manifest, bool, error) {
|
||||
if commit == "" {
|
||||
return catalogue.Manifest{}, false, nil
|
||||
}
|
||||
var id string
|
||||
var raw []byte
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select id, manifest from build
|
||||
where module = $1 and failed = '' and manifest is not null and manifest::text <> 'null'
|
||||
and (commit_hash = $2 or starts_with(commit_hash, $2))
|
||||
order by at desc limit 1`, module, commit).Scan(&id, &raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return catalogue.Manifest{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false, err
|
||||
}
|
||||
if stands, same, err := i.StandingBuild(ctx, module, id); err != nil {
|
||||
return catalogue.Manifest{}, false, err
|
||||
} else if stands != "" && stands != id && len(same) > 0 {
|
||||
raw = same
|
||||
}
|
||||
m, err := catalogue.ParseManifest(raw)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, false, fmt.Errorf("%s's build from %s is not a manifest the mesh can compose: %w",
|
||||
module, commit, err)
|
||||
}
|
||||
return m, true, nil
|
||||
}
|
||||
|
||||
@@ -152,6 +152,10 @@ type CarriedMove struct {
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to"`
|
||||
Build string `json:"build,omitempty"`
|
||||
// Recreates is what the send does to the module's containers, in the mesh's words — how many it
|
||||
// recreates, and whether with a new image or only their declaration (novox/hq ADR 0242); empty when
|
||||
// it recreates none, or when the build the machine ran is not known.
|
||||
Recreates string `json:"recreates,omitempty"`
|
||||
}
|
||||
|
||||
// PlanRelease is a release plan's walk through the machines (novox/hq ADR 0236): every module build
|
||||
|
||||
Reference in New Issue
Block a user