Files
mesh-delivery/sdk/buildstep/steps.go
T

190 lines
6.2 KiB
Go

package buildstep
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// The layouts this package reads and writes are the part `build-step`'s, shared by the three build seats'
// contracts in mesh-delivery-contracts (novox/hq ADR 0634 §6): this package imports no seat's contract, so the Go,
// image and Node build modules each use it with their own seat's, and the layout is held equal by the part's
// fixtures (a step's value decodes in both).
// Version is the layout version of a step value; a reader refuses another.
const Version = 1
// ErrUnknownVersion is what DecodeStep returns for a value whose version is missing or not Version.
var ErrUnknownVersion = errors.New("buildstep: unknown value version")
// The states of a step.
const (
StepRunning = "running"
StepPassed = "passed"
StepFailed = "failed"
StepRefused = "refused"
)
// 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.
const StepBound = 30 * time.Minute
// Step is one value of a build module's `steps` state.
type Step struct {
V int `json:"v"`
ID string `json:"id"`
Tool string `json:"tool"`
Node string `json:"node"`
State string `json:"state"`
Started time.Time `json:"started"`
Ended time.Time `json:"ended,omitzero"`
Reason string `json:"reason,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
What string `json:"what,omitempty"`
Phase string `json:"phase,omitempty"`
PhaseAt time.Time `json:"phase-at,omitzero"`
Owner string `json:"owner,omitempty"`
}
var (
nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
stepIDOK = regexp.MustCompile(`^[a-z0-9]+$`)
askIDOK = regexp.MustCompile(`^[0-9a-z]{8,9}[0-9a-f]{6}$`)
)
func checkNode(node string) error {
if node == "" || !nameOK.MatchString(node) {
return fmt.Errorf("buildstep: the node name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", node)
}
return nil
}
// StepKey is the key of a step in the steps state: `<node>.<id>`.
func StepKey(node, id string) (string, error) {
if err := checkNode(node); err != nil {
return "", err
}
if !stepIDOK.MatchString(id) {
return "", fmt.Errorf("buildstep: the step id %q is not lower-case letters and digits", id)
}
return node + "." + id, nil
}
// ParseStepKey is the inverse of StepKey.
func ParseStepKey(key string) (node, id string, err error) {
node, id, ok := strings.Cut(key, ".")
if !ok {
return "", "", fmt.Errorf("buildstep: %q is not a step key, <node>.<id>", key)
}
if _, err := StepKey(node, id); err != nil {
return "", "", fmt.Errorf("buildstep: %q is not a step key: %w", key, err)
}
return node, id, nil
}
// ParseAskStepKey is ParseStepKey for the key of a pulled ask's step, whose id is the ask's (base-36 milliseconds
// and six hex digits).
func ParseAskStepKey(key string) (node, askID string, err error) {
node, askID, err = ParseStepKey(key)
if err != nil {
return "", "", err
}
if !askIDOK.MatchString(askID) {
return "", "", fmt.Errorf("buildstep: %q is not an ask's step key: %q is not an ask id", key, askID)
}
return node, askID, nil
}
func (s Step) check() error {
if _, err := StepKey(s.Node, s.ID); err != nil {
return err
}
if s.Tool == "" {
return fmt.Errorf("buildstep: a step needs its tool")
}
switch s.State {
case StepRunning, StepPassed, StepFailed, StepRefused:
default:
return fmt.Errorf("buildstep: the step state %q is not running, passed, failed or refused", s.State)
}
if len(s.Result) > 0 && !json.Valid(s.Result) {
return fmt.Errorf("buildstep: the step's result is not JSON")
}
return nil
}
// EncodeStep is a step as its value, refusing one that is not whole.
func EncodeStep(s Step) ([]byte, error) {
if err := s.check(); err != nil {
return nil, err
}
s.V = Version
return json.Marshal(s)
}
// DecodeStep reads a steps value; it refuses an unknown version (ErrUnknownVersion) and what EncodeStep refuses.
func DecodeStep(data []byte) (Step, error) {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return Step{}, fmt.Errorf("buildstep: value is not JSON: %w", err)
}
if probe.V == nil || *probe.V != Version {
return Step{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version)
}
var s Step
if err := json.Unmarshal(data, &s); err != nil {
return Step{}, fmt.Errorf("buildstep: value does not fit its shape: %w", err)
}
if err := s.check(); err != nil {
return Step{}, err
}
return s, nil
}
var (
askCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
// PackageSemver is a Go semantic version as a delivery group's check names a tagged version (`v0.1.15-feat-x.2`).
PackageSemver = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
)
// PackageVersions are the versions of the mesh packages a build or a check builds against, by package (a Go module's
// path): each a full commit or a Go semantic version.
type PackageVersions map[string]string
// Check refuses a package that is no module path or a version that is neither a full commit nor a Go semantic
// version, naming it.
func (p PackageVersions) Check() error {
for pkg, version := range p {
if pkg == "" || strings.ContainsAny(pkg, " \t\n@") || strings.HasPrefix(pkg, "/") {
return fmt.Errorf("buildstep: the package %q is no module path", pkg)
}
if !askCommitOK.MatchString(version) && !PackageSemver.MatchString(version) {
return fmt.Errorf("buildstep: the package %s names the version %q, which is neither a full commit nor a Go semantic version (v0.1.15-feat-x.2)", pkg, version)
}
}
return nil
}
// Ask is the part of a build seat's ask that AskWhat reads: the layout is the part's `StepAsk`.
type Ask struct {
Kind string `json:"kind"`
Build *struct {
Module string `json:"module"`
FP string `json:"fp"`
Fetch map[string]any `json:"fetch"`
} `json:"build"`
Check *struct {
Fetch map[string]any `json:"fetch"`
Levels []string `json:"levels"`
Run string `json:"run"`
} `json:"check"`
Compose *struct {
Fetch map[string]any `json:"fetch"`
} `json:"compose"`
}