mesh/delivery-group group fix/capacity-follows-cpu-share rejected: composed together: the checker's verdict at the members' heads (63694fe9222d): unit: ..test
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: ..test: ✔ a transient empty list for four passes retires nothing; five stable passes retire, data kept (418.253369ms) | d00da…
The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00. A build-hours setting names a window per node in its local time (crossing midnight allowed, absent = any hour); outside it the capacity judged by is 0, running steps finish, and the puller wakes on one timer at the next edge. The change is said in the log and in capacity.<node> with the window and when it opens.
753 lines
31 KiB
Go
753 lines
31 KiB
Go
package delivery
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// **Asks: a build or a check step is a message a build node pulls when it has a free slot** (novox/hq ADR 0525, as
|
|
// ADR 0536 §4 changes it; issue 1887). The builder puts a trunk fingerprint's build on the delivery stream of the
|
|
// artifact's language and a candidate's on the check stream; the checker puts a check of one level on the check stream,
|
|
// and a re-judge after a trunk move on the recheck stream. Each build module claims the node seat of its language
|
|
// (`node-build-go`, `node-build-node`, `node-build-image`), whose accepted kinds the broker derives as the work queue
|
|
// `SEAT_NODE_BUILD_<LANGUAGE>` with one shared pull worker per kind; a holder pulls delivery first, then check, then
|
|
// recheck, at most its node's build capacity at once (ADR 0525 §4, §5).
|
|
//
|
|
// **An ask is one whole job on one node** (ADR 0525 §3): a build ask is the fetch, the build (or copy) of each artifact
|
|
// and the push; a check ask is the fetch, the digest and the tests of one level. Its end is written on the build module's
|
|
// step key `<node>.<ask id>` (StepsBucket), which the asker watches with AskStepWatch (ADR 0525 §6).
|
|
|
|
// The kinds of ask: the seat's accepted kinds, in the order a holder pulls them.
|
|
const (
|
|
AskDelivery = "delivery"
|
|
AskCheck = "check"
|
|
AskRecheck = "recheck"
|
|
)
|
|
|
|
// AskKinds are the accepted kinds of a build seat, in the order a holder pulls them (ADR 0536 §4).
|
|
var AskKinds = []string{AskDelivery, AskCheck, AskRecheck}
|
|
|
|
// The languages of the build seats: each is one build module's (mesh-build-<language>) and one seat's
|
|
// (node-build-<language>).
|
|
const (
|
|
AskGo = "go"
|
|
AskNode = "node"
|
|
AskImage = "image"
|
|
)
|
|
|
|
// AskLanguages are the build seats' languages.
|
|
var AskLanguages = []string{AskGo, AskNode, AskImage}
|
|
|
|
// **An ask may name what its node needs** (issue 890, novox/hq ADR 0525 §1 as the placement record amends it). The seat's
|
|
// workers are shared, so the asker still chooses no node; a check ask whose packages' tests need something only some
|
|
// nodes have names it in Needs, and a holder whose node lacks one of them takes no part of the ask: it ends the ask's step
|
|
// refused as declined (StepDeclined) and drops the message, and the asker puts the ask again under a new id, so a node
|
|
// that has the need takes it. A need is a node seat whose holder installs what the tests read on its node; a package
|
|
// declares it beside its tests, and the build module's digest reports it (LevelDigest.Needs).
|
|
|
|
// NeedNodeBuildNode is the need of a package whose integration tests read the Node build module's installed toolchain
|
|
// (ADR 0533 §1): the node holds the seat node-build-node, so its toolchain is installed where the Go build module's
|
|
// integration level is given it.
|
|
const NeedNodeBuildNode = "node-build-node"
|
|
|
|
// AskNeeds are the needs an ask may name.
|
|
var AskNeeds = []string{NeedNodeBuildNode}
|
|
|
|
// CheckNeed refuses a need that is not one of AskNeeds.
|
|
func CheckNeed(need string) error {
|
|
for _, n := range AskNeeds {
|
|
if need == n {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("delivery: %q is not a need an ask may name (%s)", need, strings.Join(AskNeeds, ", "))
|
|
}
|
|
|
|
// Lacks is what of needs has does not say the node has, in the order of needs; a nil has has nothing.
|
|
func Lacks(needs []string, has func(need string) bool) []string {
|
|
var out []string
|
|
for _, n := range needs {
|
|
if has == nil || !has(n) {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// PackageNeeds is one package and what its tests need of the node at a level.
|
|
type PackageNeeds struct {
|
|
Package string `json:"package"`
|
|
Needs []string `json:"needs"`
|
|
}
|
|
|
|
// StepDeclined is the result of the step of an ask a holder declined: the ask's needs, and those the holder's node lacks.
|
|
// The step's state is refused; the asker puts the ask again under a new id (DeclinedOf).
|
|
type StepDeclined struct {
|
|
Declined bool `json:"declined"`
|
|
Needs []string `json:"needs"`
|
|
Lacks []string `json:"lacks"`
|
|
}
|
|
|
|
// DeclinedOf says whether step is a declined ask's, and what its node lacked.
|
|
func DeclinedOf(step Step) (StepDeclined, bool) {
|
|
if step.State != StepRefused || len(step.Result) == 0 {
|
|
return StepDeclined{}, false
|
|
}
|
|
var d StepDeclined
|
|
if err := json.Unmarshal(step.Result, &d); err != nil || !d.Declined {
|
|
return StepDeclined{}, false
|
|
}
|
|
return d, true
|
|
}
|
|
|
|
// UnmetNeeds are the needs of the packages the levels left untested (LevelAnswer.Unmet), each once, sorted.
|
|
func UnmetNeeds(levels []LevelAnswer) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, l := range levels {
|
|
for _, u := range l.Unmet {
|
|
for _, n := range u.Needs {
|
|
if !seen[n] {
|
|
seen[n] = true
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// AskRuns is how many times an ask is run before it is taken for poison (issue 1907): an ask whose run never ends (it
|
|
// takes its node down, or its process dies with it) is delivered again by its stream to the next free node (ADR 0525
|
|
// §6); the holder that takes it past AskRuns runs it no more, writes its step failed saying so, and ends it, so the
|
|
// asker's key fails with why and no further node is taken down. NATS-native: the count is the stream's own delivery
|
|
// count of the message, no timer.
|
|
const AskRuns = 3
|
|
|
|
// AskMaxDeliver is the build seats' workers' bound of deliveries (the broker's MaxDeliver): past AskRuns, so the holder
|
|
// that says the poison gets its delivery, with one more for a holder that dies saying it.
|
|
const AskMaxDeliver = AskRuns + 2
|
|
|
|
// StepAskVersion is the version of an Ask value; a reader refuses another (ErrUnknownVersion).
|
|
const StepAskVersion = 1
|
|
|
|
func checkAskKind(kind string) error {
|
|
for _, k := range AskKinds {
|
|
if kind == k {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("delivery: the ask kind %q is not delivery, check or recheck", kind)
|
|
}
|
|
|
|
func checkAskLanguage(language string) error {
|
|
for _, l := range AskLanguages {
|
|
if language == l {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("delivery: the ask language %q is not go, node or image", language)
|
|
}
|
|
|
|
// BuildSeat is the node seat a build module of language claims: node-build-<language>.
|
|
func BuildSeat(language string) (string, error) {
|
|
if err := checkAskLanguage(language); err != nil {
|
|
return "", err
|
|
}
|
|
return "node-build-" + language, nil
|
|
}
|
|
|
|
// BuildModuleOf is the build module that holds language's seat: mesh-build-<language>.
|
|
func BuildModuleOf(language string) (string, error) {
|
|
if err := checkAskLanguage(language); err != nil {
|
|
return "", err
|
|
}
|
|
return "mesh-build-" + language, nil
|
|
}
|
|
|
|
// AskSubject is the subject an ask of kind for language is published on: the seat's accept subject,
|
|
// `mesh.seat.node-build-<language>.accept.<kind>` (mesh-controller internal/broker SeatStreams).
|
|
func AskSubject(language, kind string) (string, error) {
|
|
seat, err := BuildSeat(language)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := checkAskKind(kind); err != nil {
|
|
return "", err
|
|
}
|
|
return "mesh.seat." + seat + ".accept." + kind, nil
|
|
}
|
|
|
|
// ParseAskSubject is the inverse of AskSubject; it refuses a subject of another seat or kind.
|
|
func ParseAskSubject(subject string) (language, kind string, err error) {
|
|
rest, ok := strings.CutPrefix(subject, "mesh.seat.node-build-")
|
|
if !ok {
|
|
return "", "", fmt.Errorf("delivery: %q is not a build seat's accept subject", subject)
|
|
}
|
|
language, kind, ok = strings.Cut(rest, ".accept.")
|
|
if !ok {
|
|
return "", "", fmt.Errorf("delivery: %q is not a build seat's accept subject", subject)
|
|
}
|
|
if want, err := AskSubject(language, kind); err != nil || want != subject {
|
|
return "", "", fmt.Errorf("delivery: %q is not a build seat's accept subject: %v", subject, err)
|
|
}
|
|
return language, kind, nil
|
|
}
|
|
|
|
// AskStream is the work-queue stream of language's seat, as the broker names a seat's stream: SEAT_NODE_BUILD_<LANGUAGE>.
|
|
func AskStream(language string) (string, error) {
|
|
seat, err := BuildSeat(language)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "SEAT_" + strings.ToUpper(strings.ReplaceAll(seat, "-", "_")), nil
|
|
}
|
|
|
|
// AskWorker is the shared pull consumer of one kind on language's stream, filtered on that kind's subject:
|
|
// SEAT_NODE_BUILD_<LANGUAGE>_<KIND>_worker. Every holder of the seat pulls from it, so the work is shared without a
|
|
// holder knowing the others (ADR 0190), and the kinds' workers are apart so a holder can pull them in their order.
|
|
func AskWorker(language, kind string) (string, error) {
|
|
stream, err := AskStream(language)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := checkAskKind(kind); err != nil {
|
|
return "", err
|
|
}
|
|
return stream + "_" + strings.ToUpper(kind) + "_worker", nil
|
|
}
|
|
|
|
// An ask id is a step id of the form buildstep.NewID gives: the time in milliseconds in base 36 (8 or 9 digits) and six
|
|
// random hex digits. Its form keeps it apart from a module's or a node's name in a key.
|
|
var askIDOK = regexp.MustCompile(`^[0-9a-z]{8,9}[0-9a-f]{6}$`)
|
|
|
|
func checkAskID(id string) error {
|
|
if !askIDOK.MatchString(id) {
|
|
return fmt.Errorf("delivery: %q is not an ask id (base-36 milliseconds and six hex digits)", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StepAsk is the message on a build seat's stream: who asked what, of which kind and language, and the one whole job.
|
|
// Exactly one of Build, Check and Compose is set: a delivery ask is a build; a check ask a build (a candidate's
|
|
// fingerprint), a check or, on the Go seat, a compose; a recheck ask a check; an image ask is always a build.
|
|
type StepAsk struct {
|
|
V int `json:"v"`
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Language string `json:"language"`
|
|
// Asker is the module that put the ask (mesh-delivery-builder or mesh-delivery-checker); the stream's grant is what
|
|
// makes it true, this field only names it to a reader.
|
|
Asker string `json:"asker"`
|
|
At time.Time `json:"at"`
|
|
Build *BuildJob `json:"build,omitempty"`
|
|
Check *CheckJob `json:"check,omitempty"`
|
|
Compose *ComposeJob `json:"compose,omitempty"`
|
|
// Needs are what the node that runs the ask must have (AskNeeds); a check ask's alone. Empty: any node.
|
|
Needs []string `json:"needs,omitempty"`
|
|
}
|
|
|
|
// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer
|
|
// command (Package) in the Go module at Dir of the fetched tree, once per ComposerKey, run it in a run step shut off
|
|
// with Request on its standard input, and answer its standard output whole (ComposeAnswer). The assigner asks it, to
|
|
// compose a group with its mesh-sdk member's head, and to read the group's manifests with the mesh-sdk the running
|
|
// controller and node-engine pin (ADR 0482 clause 5, issue 1623).
|
|
type ComposeJob struct {
|
|
// Fetch is the fetch tool's arguments: the repository and the full commit whose mesh-sdk composes (mesh-sdk itself
|
|
// at a head, or a module that requires mesh-sdk, at the version it pins).
|
|
Fetch map[string]any `json:"fetch"`
|
|
// Dir is the directory of the Go module the composer is built in, relative to the repository's root ("go" for
|
|
// mesh-sdk, RootPath for a module at the root); Package is the composer's import path (mesh-sdk composer.Package).
|
|
Dir string `json:"dir"`
|
|
Package string `json:"package"`
|
|
// Request is the composer's request (mesh-sdk composer.Request), its manifests named by their digests in the
|
|
// indexer's ManifestsBucket: the build module fills them before the run (composer.Fill).
|
|
Request json.RawMessage `json:"request"`
|
|
// Reader names, for a reader of the ask, whose composition this is: the mesh-sdk head of a group, or a module whose
|
|
// mesh-sdk reads the group's manifests (mesh-controller, mesh-host).
|
|
Reader string `json:"reader,omitempty"`
|
|
}
|
|
|
|
// ComposeAnswer is a compose ask's step result: whether this run built the composer or took the one kept under its
|
|
// key, the key, and the composer's verdict (mesh-sdk composer.Verdict) whole.
|
|
type ComposeAnswer struct {
|
|
Built bool `json:"built"`
|
|
Key string `json:"key"`
|
|
Verdict json.RawMessage `json:"verdict"`
|
|
}
|
|
|
|
// ComposerKey is the key a build module keeps a built composer under: the repository, commit, directory and package it
|
|
// was built from and the toolchain it was built with, hashed (`composer-` and 16 hex digits), so each head is built once
|
|
// on a node (ADR 0482 clause 4).
|
|
func ComposerKey(repository, commit, dir, pkg string, toolchain ToolchainUsed) string {
|
|
sum := sha256.Sum256([]byte(strings.Join([]string{repository, commit, dir, pkg, toolchain.Version, toolchain.Recipe}, "\n")))
|
|
return "composer-" + hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
// BuildJob is a build ask's job: fetch the commit (unless every artifact is a copy), then build or copy each artifact,
|
|
// pushing it when Publish says so, and answer each artifact's digest and reference (BuildAnswer).
|
|
type BuildJob struct {
|
|
// Module is the module's name; Path its directory in the repository (the build tool's `module`, "." for its root).
|
|
Module string `json:"module"`
|
|
Path string `json:"path,omitempty"`
|
|
// FP is the fingerprint the build is of (the builder's key), named for a reader of the ask.
|
|
FP string `json:"fp,omitempty"`
|
|
// Fetch is the fetch tool's arguments, as the build module's fetch tool takes them (buildstep.FetchRequest's Args);
|
|
// absent when every artifact is a copy.
|
|
Fetch map[string]any `json:"fetch,omitempty"`
|
|
// Artifacts are built in this order.
|
|
Artifacts []AskArtifact `json:"artifacts"`
|
|
// Publish says the build pushes an image or publishes a package: a trunk build only (ADR 0487 clause 12).
|
|
Publish bool `json:"publish,omitempty"`
|
|
// Toolchain is the toolchain the fingerprint was computed with; a build module running another refuses the ask.
|
|
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
|
|
}
|
|
|
|
// AskArtifact is one artifact of a build ask: built from the fetched tree, or copied from From, an upstream pin (ADR
|
|
// 0498 clause 4).
|
|
type AskArtifact struct {
|
|
Artifact string `json:"artifact"`
|
|
From string `json:"from,omitempty"`
|
|
}
|
|
|
|
// CheckJob is a check ask's job, the levels of one check (ADR 0525 §3): fetch the head merged onto its trunk once, then
|
|
// for each level in order digest its packages, take the kept verdicts for the digests that have one, and test the rest,
|
|
// the packages the change's own tests are in first, stopping at the first level that does not pass (CheckAnswer).
|
|
type CheckJob struct {
|
|
// Fetch is the fetch tool's arguments (repository, commit, ref, base, and the build module's own: beside, package).
|
|
Fetch map[string]any `json:"fetch"`
|
|
// Levels are run in this order (LevelUnit, LevelIntegration); a try may name one.
|
|
Levels []string `json:"levels"`
|
|
// Kept says the build module takes the kept verdicts of the checker's kept bucket (CheckerKeptBucket, by KeptKey)
|
|
// for the packages whose digest has one and covers the siblings the fetch placed, and tests only the others (issue
|
|
// 1887's design limit 1). A try that runs some tests (Run), and a level the candidate's own build module judges
|
|
// (Runner), keep nothing.
|
|
Kept bool `json:"kept,omitempty"`
|
|
// Runner is the test step's runner ("workspace": the candidate's own build module judges it, ADR 0535 §4), used
|
|
// when the merge changes a path under RunnerWhen (every merge when RunnerWhen is empty).
|
|
Runner string `json:"runner,omitempty"`
|
|
RunnerWhen string `json:"runner_when,omitempty"`
|
|
// Run is a try's test pattern (go test -run): each level runs it alone, digesting nothing.
|
|
Run string `json:"run,omitempty"`
|
|
// Packages, when set, are the only packages each level tests (issue 890, ADR 0552 §4): a placed ask names the
|
|
// packages the first ask left unmet, and the others are neither tested nor taken kept, so nothing is judged twice.
|
|
Packages []string `json:"packages,omitempty"`
|
|
}
|
|
|
|
func (a StepAsk) check() error {
|
|
if err := checkAskID(a.ID); err != nil {
|
|
return err
|
|
}
|
|
if err := checkAskKind(a.Kind); err != nil {
|
|
return err
|
|
}
|
|
if err := checkAskLanguage(a.Language); err != nil {
|
|
return err
|
|
}
|
|
if err := checkName("asker", a.Asker); err != nil {
|
|
return err
|
|
}
|
|
if a.At.IsZero() {
|
|
return fmt.Errorf("delivery: the ask %s names no time", a.ID)
|
|
}
|
|
jobs := 0
|
|
for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil} {
|
|
if set {
|
|
jobs++
|
|
}
|
|
}
|
|
switch {
|
|
case jobs != 1:
|
|
return fmt.Errorf("delivery: the ask %s holds a build, a check or a compose, exactly one", a.ID)
|
|
case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo):
|
|
return fmt.Errorf("delivery: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID)
|
|
case a.Build != nil && a.Kind == AskRecheck:
|
|
return fmt.Errorf("delivery: the ask %s is a build on the recheck stream, which carries checks alone", a.ID)
|
|
case a.Check != nil && a.Kind == AskDelivery:
|
|
return fmt.Errorf("delivery: the ask %s is a check on the delivery stream, which carries trunk builds alone", a.ID)
|
|
case a.Check != nil && a.Language == AskImage:
|
|
return fmt.Errorf("delivery: the ask %s is a check of an image, which has no tests", a.ID)
|
|
case len(a.Needs) > 0 && a.Check == nil:
|
|
return fmt.Errorf("delivery: the ask %s names needs and is no check, whose tests alone need a node", a.ID)
|
|
}
|
|
for _, n := range a.Needs {
|
|
if err := CheckNeed(n); err != nil {
|
|
return fmt.Errorf("the ask %s: %w", a.ID, err)
|
|
}
|
|
}
|
|
if a.Build != nil {
|
|
return a.Build.check(a.ID)
|
|
}
|
|
if a.Compose != nil {
|
|
return a.Compose.check(a.ID)
|
|
}
|
|
return a.Check.check(a.ID)
|
|
}
|
|
|
|
func (c ComposeJob) check(id string) error {
|
|
if err := checkFetchArgs(id, c.Fetch); err != nil {
|
|
return err
|
|
}
|
|
if c.Dir == "" || c.Dir != path.Clean(c.Dir) || strings.HasPrefix(c.Dir, "/") || c.Dir == ".." || strings.HasPrefix(c.Dir, "../") {
|
|
return fmt.Errorf("delivery: the compose ask %s names no clean directory inside the repository (%q)", id, c.Dir)
|
|
}
|
|
if c.Package == "" {
|
|
return fmt.Errorf("delivery: the compose ask %s names no package to build", id)
|
|
}
|
|
if !json.Valid(c.Request) || len(c.Request) == 0 || c.Request[0] != '{' {
|
|
return fmt.Errorf("delivery: the compose ask %s holds no request object", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b BuildJob) check(id string) error {
|
|
if err := checkName("module", b.Module); err != nil {
|
|
return err
|
|
}
|
|
if len(b.Artifacts) == 0 {
|
|
return fmt.Errorf("delivery: the build ask %s names no artifact", id)
|
|
}
|
|
fetch := false
|
|
for _, a := range b.Artifacts {
|
|
if err := checkName("artifact", a.Artifact); err != nil {
|
|
return err
|
|
}
|
|
fetch = fetch || a.From == ""
|
|
}
|
|
if fetch {
|
|
if b.Path == "" {
|
|
return fmt.Errorf("delivery: the build ask %s names no directory (path) for the module %s", id, b.Module)
|
|
}
|
|
if err := checkFetchArgs(id, b.Fetch); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c CheckJob) check(id string) error {
|
|
if len(c.Levels) == 0 {
|
|
return fmt.Errorf("delivery: the check ask %s names no level", id)
|
|
}
|
|
for _, l := range c.Levels {
|
|
if err := checkLevel(l); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, p := range c.Packages {
|
|
if p == "" || strings.ContainsAny(p, " \t\n") {
|
|
return fmt.Errorf("delivery: the check ask %s names a package %q that is no import path", id, p)
|
|
}
|
|
}
|
|
return checkFetchArgs(id, c.Fetch)
|
|
}
|
|
|
|
var askCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
|
|
|
// checkFetchArgs refuses fetch arguments with no repository or no full commit; the rest is the build module's fetch to
|
|
// judge, as a direct call's is.
|
|
func checkFetchArgs(id string, args map[string]any) error {
|
|
repository, _ := args["repository"].(string)
|
|
commit, _ := args["commit"].(string)
|
|
if repository == "" || !strings.Contains(repository, "/") {
|
|
return fmt.Errorf("delivery: the ask %s fetches no repository <owner>/<name>", id)
|
|
}
|
|
if !askCommitOK.MatchString(commit) {
|
|
return fmt.Errorf("delivery: the ask %s fetches no full commit", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EncodeStepAsk is a as JSON at StepAskVersion; it refuses what DecodeStepAsk refuses.
|
|
func EncodeStepAsk(a StepAsk) ([]byte, error) {
|
|
a.V = StepAskVersion
|
|
a.At = a.At.UTC()
|
|
if err := a.check(); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(a)
|
|
}
|
|
|
|
// DecodeStepAsk reads an ask; it refuses an unknown version (ErrUnknownVersion), an unknown kind or language, an id that
|
|
// is not an ask id, a build and a check together or neither, a job on a stream that does not carry it, and a fetch with
|
|
// no repository or no full commit.
|
|
func DecodeStepAsk(data []byte) (StepAsk, error) {
|
|
var probe struct {
|
|
V *int `json:"v"`
|
|
}
|
|
if err := json.Unmarshal(data, &probe); err != nil {
|
|
return StepAsk{}, fmt.Errorf("delivery: an ask is not JSON: %w", err)
|
|
}
|
|
if probe.V == nil || *probe.V != StepAskVersion {
|
|
return StepAsk{}, fmt.Errorf("%w: an ask of version %v, this reader reads %d", ErrUnknownVersion, probe.V, StepAskVersion)
|
|
}
|
|
var a StepAsk
|
|
if err := json.Unmarshal(data, &a); err != nil {
|
|
return StepAsk{}, fmt.Errorf("delivery: an ask does not fit its shape: %w", err)
|
|
}
|
|
if err := a.check(); err != nil {
|
|
return StepAsk{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// BuildAnswer is a build ask's step result: each artifact's digest and, for a pushed one, its reference.
|
|
type BuildAnswer struct {
|
|
Artifacts []AskBuilt `json:"artifacts"`
|
|
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
|
|
}
|
|
|
|
// AskBuilt is one artifact a build ask built.
|
|
type AskBuilt struct {
|
|
Artifact string `json:"artifact"`
|
|
Digest string `json:"digest"`
|
|
Reference string `json:"reference,omitempty"`
|
|
}
|
|
|
|
// CheckAnswer is a check ask's step result, each part the build module's own tool's answer whole: the fetch's, then one
|
|
// LevelAnswer per level run, in order; a level that did not pass is the last. The checker judges and keeps from it as it
|
|
// did from the tools' answers.
|
|
type CheckAnswer struct {
|
|
Fetch json.RawMessage `json:"fetch"`
|
|
Levels []LevelAnswer `json:"levels,omitempty"`
|
|
}
|
|
|
|
// LevelAnswer is one level of a check ask: the digest's answer (absent when nothing was digested: the level ran whole and
|
|
// keeps nothing), the kept verdicts taken, and one test result per test step run, in the order run.
|
|
type LevelAnswer struct {
|
|
Level string `json:"level"`
|
|
// Judged is what the digest under the candidate's runner said of the program that ran it ("candidate <merge>"),
|
|
// when the candidate judged the level (ADR 0535 §4, §5); empty otherwise.
|
|
Judged string `json:"judged,omitempty"`
|
|
Digest *LevelDigest `json:"digest,omitempty"`
|
|
Kept []KeptTaken `json:"kept,omitempty"`
|
|
Tests []json.RawMessage `json:"tests,omitempty"`
|
|
// Unmet are the packages the level did not test on this node, because their tests need what it lacks (issue 890): the
|
|
// asker keeps what passed and asks them again of a node that has it (StepAsk.Needs).
|
|
Unmet []PackageNeeds `json:"unmet,omitempty"`
|
|
}
|
|
|
|
// LevelDigest is the digest's answer as a level keeps it in the ask's step result: its packages a list of
|
|
// {package, digest} sorted by package, never a map keyed by the package's path (issue 1930). The step result is a
|
|
// state value, and the node runtime refuses a value with a field name ending in a credential word (novox/hq ADR 0201):
|
|
// a Go package such as .../internal/token is such a name as a map key, and is a plain value in the list. It decodes
|
|
// the digest tool's own answer, and a value stored before issue 1930, with packages as a map too.
|
|
type LevelDigest struct {
|
|
Level string `json:"level,omitempty"`
|
|
Packages []PackageDigest `json:"packages"`
|
|
Beside []string `json:"beside,omitempty"`
|
|
WithoutTests []string `json:"without_tests,omitempty"`
|
|
JudgedBy string `json:"judged_by,omitempty"`
|
|
// Needs are the packages whose tests at this level need something of the node (issue 890), as they declare it.
|
|
Needs []PackageNeeds `json:"needs,omitempty"`
|
|
}
|
|
|
|
// PackageDigest is one package's digest at a level.
|
|
type PackageDigest struct {
|
|
Package string `json:"package"`
|
|
Digest string `json:"digest"`
|
|
}
|
|
|
|
// UnmarshalJSON reads packages as the list or as the map keyed by package path (the digest tool's answer, and a level
|
|
// answer stored before issue 1930), and sorts them by package.
|
|
func (d *LevelDigest) UnmarshalJSON(raw []byte) error {
|
|
var v struct {
|
|
Level string `json:"level"`
|
|
Packages json.RawMessage `json:"packages"`
|
|
Beside []string `json:"beside"`
|
|
WithoutTests []string `json:"without_tests"`
|
|
JudgedBy string `json:"judged_by"`
|
|
Needs []PackageNeeds `json:"needs"`
|
|
}
|
|
if err := json.Unmarshal(raw, &v); err != nil {
|
|
return err
|
|
}
|
|
var pkgs []PackageDigest
|
|
if p := strings.TrimSpace(string(v.Packages)); p != "" && p != "null" {
|
|
if strings.HasPrefix(p, "{") {
|
|
var m map[string]string
|
|
if err := json.Unmarshal(v.Packages, &m); err != nil {
|
|
return fmt.Errorf("a digest's packages: %w", err)
|
|
}
|
|
for pkg, digest := range m {
|
|
pkgs = append(pkgs, PackageDigest{Package: pkg, Digest: digest})
|
|
}
|
|
} else if err := json.Unmarshal(v.Packages, &pkgs); err != nil {
|
|
return fmt.Errorf("a digest's packages: %w", err)
|
|
}
|
|
}
|
|
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Package < pkgs[j].Package })
|
|
*d = LevelDigest{Level: v.Level, Packages: pkgs, Beside: v.Beside, WithoutTests: v.WithoutTests, JudgedBy: v.JudgedBy, Needs: v.Needs}
|
|
return nil
|
|
}
|
|
|
|
// NeedsOf is what the package's tests need of the node at the digest's level; nil when it declares nothing.
|
|
func (d *LevelDigest) NeedsOf(pkg string) []string {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
for _, n := range d.Needs {
|
|
if n.Package == pkg {
|
|
return n.Needs
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Map is the digests by package, for a lookup; nil when it names none.
|
|
func (d *LevelDigest) Map() map[string]string {
|
|
if d == nil || len(d.Packages) == 0 {
|
|
return nil
|
|
}
|
|
m := make(map[string]string, len(d.Packages))
|
|
for _, p := range d.Packages {
|
|
m[p.Package] = p.Digest
|
|
}
|
|
return m
|
|
}
|
|
|
|
// KeptTaken is a package whose kept verdict the check took instead of testing it.
|
|
type KeptTaken struct {
|
|
Package string `json:"package"`
|
|
Digest string `json:"digest"`
|
|
Tests int `json:"tests"`
|
|
}
|
|
|
|
// AskStepKey is the key, in the build module's StepsBucket, of the step that runs an ask on node: `<node>.<ask id>`
|
|
// (ADR 0525 §6). The node is the one that pulled the ask, which the asker learns from the key.
|
|
func AskStepKey(node, askID string) (string, error) {
|
|
if err := checkAskID(askID); err != nil {
|
|
return "", err
|
|
}
|
|
return StepKey(node, askID)
|
|
}
|
|
|
|
// AskStepWatch is the pattern an asker watches its ask's step with, the node a wildcard: `*.<ask id>`.
|
|
func AskStepWatch(askID string) (string, error) {
|
|
if err := checkAskID(askID); err != nil {
|
|
return "", err
|
|
}
|
|
return "*." + askID, nil
|
|
}
|
|
|
|
// ParseAskStepKey reads the node and the ask id from a step key of an ask; it refuses a key that is not `<node>.<ask
|
|
// id>`, such as a have key `<node>.<module>` or a step key a direct tool call wrote under another id form.
|
|
func ParseAskStepKey(key string) (node, askID string, err error) {
|
|
node, askID, err = ParseStepKey(key)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := checkAskID(askID); err != nil {
|
|
return "", "", fmt.Errorf("delivery: %q is not an ask's step key: %w", key, err)
|
|
}
|
|
return node, askID, nil
|
|
}
|
|
|
|
// StepBound is how long an asker waits for one ask's step once a build node took it (30 minutes): past it the asker has
|
|
// stopped waiting, so a key still running is nobody's. The build modules end such a key (buildstep.EndStale); the
|
|
// checker's, the builder's and the watchman's own copies of the number are to read this one (issue 1916).
|
|
const StepBound = 30 * time.Minute
|
|
|
|
// PausesState is the state a build module keeps its nodes' pause keys in. It has no age, unlike StepsState, whose keys
|
|
// are per step and dropped after their age: a pause kept among the steps would end by itself.
|
|
const PausesState = "pauses"
|
|
|
|
// PauseKey is the key, in a build module's PausesState, that keeps whether the module on node takes asks:
|
|
// `paused.<node>` (issue 1887's design limit 2: every holder pulls from one shared worker, so a node's pause is its holder
|
|
// not fetching, kept so a restart keeps it). A pause key is never an ask's step key: its second part is a node.
|
|
func PauseKey(node string) (string, error) {
|
|
if err := checkName("node", node); err != nil {
|
|
return "", err
|
|
}
|
|
return "paused." + node, nil
|
|
}
|
|
|
|
// Pause is the value of a pause key: whether the node's module takes no asks, who said so and when.
|
|
type Pause struct {
|
|
V int `json:"v"`
|
|
Paused bool `json:"paused"`
|
|
By string `json:"by,omitempty"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// EncodePause is p as JSON at Version.
|
|
func EncodePause(p Pause) ([]byte, error) {
|
|
p.V = Version
|
|
p.At = p.At.UTC()
|
|
return json.Marshal(p)
|
|
}
|
|
|
|
// DecodePause reads a pause value; it refuses an unknown version (ErrUnknownVersion).
|
|
func DecodePause(data []byte) (Pause, error) {
|
|
var p Pause
|
|
if err := decode(data, &p); err != nil {
|
|
return Pause{}, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// CapacityKey is the key, in a build module's PausesState, where the module on node says the capacity it judges by
|
|
// now and why: `capacity.<node>` (issue 1806's follow-up). The pauses state has no age, so the value stands until the
|
|
// module writes another; a reader such as the delivery view's build_usage says it in place of the configured capacity.
|
|
func CapacityKey(node string) (string, error) {
|
|
if err := checkName("node", node); err != nil {
|
|
return "", err
|
|
}
|
|
return "capacity." + node, nil
|
|
}
|
|
|
|
// Capacity is a build seat's capacity on one node as its holder judges by it: the configured capacity (the module's
|
|
// setting `capacity`), bounded by the cores its node's build slice gives (its CPU quota, read live) over the cores one
|
|
// step needs (the module's setting `cores-per-step`). Effective is min(Configured, ByCPU) when a quota is known, else
|
|
// Configured; one step at least while Configured is above zero, so a node whose quota is under one step's need still
|
|
// builds, slowly.
|
|
type Capacity struct {
|
|
V int `json:"v"`
|
|
Effective int `json:"effective"`
|
|
Configured int `json:"configured"`
|
|
// ConfiguredFrom says where Configured was read: mesh-config's value as the config watch handed it, or the settings
|
|
// file.
|
|
ConfiguredFrom string `json:"configured-from,omitempty"`
|
|
// QuotaCores is the build slice's CPU quota in processors (cpu.max: 1200% is 12), 0 when none is known; QuotaFrom
|
|
// says where it was read (the cgroup, or the share the composer gave) or why none is known.
|
|
QuotaCores float64 `json:"quota-cores,omitempty"`
|
|
QuotaFrom string `json:"quota-from"`
|
|
// CoresPerStep is what one step of this module needs; ByCPU is floor(QuotaCores / CoresPerStep), at least 1, and 0
|
|
// when no quota is known.
|
|
CoresPerStep float64 `json:"cores-per-step"`
|
|
ByCPU int `json:"by-cpu,omitempty"`
|
|
// BuildHours is the node's build window (HH:MM-HH:MM, its local time and zone), absent when it builds at any hour;
|
|
// OutsideHours says the time judged fell outside it, so Effective is 0 and the next step waits for the window to open.
|
|
BuildHours string `json:"build-hours,omitempty"`
|
|
OutsideHours bool `json:"outside-hours,omitempty"`
|
|
// BuildHoursFault is why the node's build-hours value cannot be read, naming the value: it then builds at any hour.
|
|
BuildHoursFault string `json:"build-hours-fault,omitempty"`
|
|
// Why is the capacity in words: what bounds it.
|
|
Why string `json:"why"`
|
|
At time.Time `json:"at,omitzero"`
|
|
}
|
|
|
|
// EncodeCapacity is c as JSON at Version.
|
|
func EncodeCapacity(c Capacity) ([]byte, error) {
|
|
c.V = Version
|
|
c.At = c.At.UTC()
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
// DecodeCapacity reads a capacity value; it refuses an unknown version (ErrUnknownVersion).
|
|
func DecodeCapacity(data []byte) (Capacity, error) {
|
|
var c Capacity
|
|
if err := decode(data, &c); err != nil {
|
|
return Capacity{}, err
|
|
}
|
|
return c, nil
|
|
}
|