Files
mesh-sdk/go/delivery/stepasks_run.go
T
jschoubben ceb319fb86 Add the build seat's ask run: a program built from a change, run over the heads (issue 1921)
A run ask has a program built at a commit and run in the build seat's
sandbox over named heads, its input kept by the asker by digest and
measured against a bound before asking (ADR 0644, ADR 0656; design 47's
ask run). The checker asks it for the checking program in milestone
47/B0.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-06 22:06:09 +01:00

163 lines
7.1 KiB
Go

package delivery
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path"
"regexp"
"strings"
)
// **A run ask has a program built from a change and run in the build seat's sandbox** (novox/hq ADR 0644, ADR 0656;
// design 47, *What ADR 0656 leaves to this design*: the build seat's ask `run`; issue 1921). It goes on the Go seat's
// check stream, a whole job pulled where there is capacity, like a compose: the build module fetches Program at its
// commit and builds it once per RunKey, in a step that runs nothing but the build; fetches every head of Heads and lays
// each read-only under the directory RunHeadsEnv names, at its repository's path (`<owner>/<name>`); reads the input
// Input names, checks its digest and writes it to the file RunInputEnv names; then runs the program with Args, no
// network and no bus account (ADR 0448 §2, §4), and answers RunAnswer on its step key.
//
// The input is carried by reference, as a compose carries its manifests: the asker keeps it in a state of its own by its
// digest (RunInput), measured against RunInputBound before it asks, so a message on the bus never carries it (issue 1930).
// RunJob is a run ask's job.
type RunJob struct {
// Program is where the program is built: the repository and full commit fetched, the Go module's directory in it and
// the program's import path. The ask's Packages are the mesh package versions it builds against (ADR 0573 §2, §4).
Program RunProgram `json:"program"`
// Heads are the commits the program reads, each laid read-only at its repository's path; a repository at most once.
Heads []RunHead `json:"heads"`
// Input names the program's input, kept by the asker by its digest.
Input RunInput `json:"input"`
// Args are the program's arguments (a checking program's `-seats` or `-verdict`), each a word or a flag.
Args []string `json:"args,omitempty"`
// Reader names, for a reader of the ask, what the run is for ("checking program of mesh-delivery head").
Reader string `json:"reader,omitempty"`
}
// RunProgram is where a run's program is built from.
type RunProgram struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
Dir string `json:"dir"`
Package string `json:"package"`
}
// RunHead is one commit a run's program reads.
type RunHead struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
}
// RunInput is a run's input by reference: the state it is kept in, named as a manifest's `reads` names a state
// (`<module>.<state>`), its key (the sha256 of its bytes, 64 lowercase hex digits) and its size in bytes.
type RunInput struct {
State string `json:"state"`
Digest string `json:"digest"`
Size int `json:"size"`
}
// RunInputBound is the most bytes a run's input may hold: under the bus's message limit, so the asker can keep it as
// one value.
const RunInputBound = 768 << 10
// RunHeadsEnv names, for the program, the directory its heads are laid in; RunInputEnv the file its input is written to.
const (
RunHeadsEnv = "MESH_RUN_HEADS"
RunInputEnv = "MESH_RUN_INPUT"
)
// RunInputOf is the reference to body kept in state, refusing a body over RunInputBound, naming its size, so an asker
// refuses it before asking.
func RunInputOf(state string, body []byte) (RunInput, error) {
if len(body) > RunInputBound {
return RunInput{}, fmt.Errorf("delivery: a run's input of %d bytes is over its bound of %d bytes", len(body), RunInputBound)
}
sum := sha256.Sum256(body)
in := RunInput{State: state, Digest: hex.EncodeToString(sum[:]), Size: len(body)}
return in, in.check("")
}
// Verify refuses body when it is not the input i names: another size or another digest.
func (i RunInput) Verify(body []byte) error {
sum := sha256.Sum256(body)
if len(body) != i.Size || hex.EncodeToString(sum[:]) != i.Digest {
return fmt.Errorf("delivery: the input read from %s under %s is not the one the ask names (%d bytes, %d named)", i.State, i.Digest, len(body), i.Size)
}
return nil
}
var (
runStateOK = regexp.MustCompile(`^[a-z0-9][-a-z0-9]*\.[a-z0-9][-a-z0-9]*$`)
runDigestOK = regexp.MustCompile(`^[0-9a-f]{64}$`)
runArgOK = regexp.MustCompile(`^[-=_./a-zA-Z0-9]+$`)
)
func (i RunInput) check(id string) error {
switch {
case !runStateOK.MatchString(i.State):
return fmt.Errorf("delivery: the run ask %s keeps its input in %q, which is no <module>.<state>", id, i.State)
case !runDigestOK.MatchString(i.Digest):
return fmt.Errorf("delivery: the run ask %s names its input by %q, which is no sha256 digest", id, i.Digest)
case i.Size <= 0 || i.Size > RunInputBound:
return fmt.Errorf("delivery: the run ask %s names an input of %d bytes, outside 1 to %d", id, i.Size, RunInputBound)
}
return nil
}
func (r RunJob) check(id string) error {
p := r.Program
if err := checkRepository(p.Repository); err != nil {
return fmt.Errorf("the run ask %s's program: %w", id, err)
}
if !askCommitOK.MatchString(p.Commit) {
return fmt.Errorf("delivery: the run ask %s builds its program at no full commit", id)
}
if p.Dir == "" || p.Dir != path.Clean(p.Dir) || strings.HasPrefix(p.Dir, "/") || p.Dir == ".." || strings.HasPrefix(p.Dir, "../") {
return fmt.Errorf("delivery: the run ask %s names no clean directory inside the repository (%q)", id, p.Dir)
}
if p.Package == "" || strings.ContainsAny(p.Package, " \t\n") {
return fmt.Errorf("delivery: the run ask %s names no package to build (%q)", id, p.Package)
}
if len(r.Heads) == 0 {
return fmt.Errorf("delivery: the run ask %s names no head", id)
}
seen := map[string]bool{}
for _, h := range r.Heads {
if err := checkRepository(h.Repository); err != nil {
return fmt.Errorf("the run ask %s's head: %w", id, err)
}
if !askCommitOK.MatchString(h.Commit) {
return fmt.Errorf("delivery: the run ask %s names the head of %s at no full commit", id, h.Repository)
}
if seen[h.Repository] {
return fmt.Errorf("delivery: the run ask %s names %s twice", id, h.Repository)
}
seen[h.Repository] = true
}
for _, a := range r.Args {
if !runArgOK.MatchString(a) {
return fmt.Errorf("delivery: the run ask %s names the argument %q, which is no word or flag", id, a)
}
}
return r.Input.check(id)
}
// RunAnswer is a run ask's step result: whether this run built the program or took the one kept under its key, the
// key, the program's exit, the last line of its standard output (Output, raw when it is JSON, else as a JSON string),
// and Said: the first line of its standard error, or why the step could not run it.
type RunAnswer struct {
Built bool `json:"built"`
Key string `json:"key"`
Exit int `json:"exit"`
Output json.RawMessage `json:"output,omitempty"`
Said string `json:"said,omitempty"`
}
// RunKey is the key a build module keeps a built program under, as ComposerKey keeps a composer: what it was built
// from, the toolchain and the asked package versions, hashed (`program-` and 16 hex digits).
func RunKey(p RunProgram, toolchain ToolchainUsed, versions PackageVersions) string {
return "program-" + strings.TrimPrefix(ComposerKey(p.Repository, p.Commit, p.Dir, p.Package, toolchain, versions), "composer-")
}