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: `.`. 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, .", 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"` } // CheckAskID refuses an id that is no ask's: base-36 milliseconds and six hex digits, as NewID gives. Its form keeps it apart from // a module's or a node's name in a key. func CheckAskID(id string) error { if !askIDOK.MatchString(id) { return fmt.Errorf("buildstep: %q is not an ask id (base-36 milliseconds and six hex digits)", id) } return nil }