100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
|
|
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
|
|
// (issue 1473) and the fetch of one commit over SSH (issue 1474).
|
|
package buildstep
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
)
|
|
|
|
// Put writes one key of a state: stdio.State(delivery.StepsState).Put's shape (go/state.go).
|
|
type Put func(key string, value any) (uint64, error)
|
|
|
|
// EndAttempts and EndRetry bound how a step's end is written: a write that fails is tried again, since the end
|
|
// is the only way the step's reader learns it is over.
|
|
var (
|
|
EndAttempts = 5
|
|
EndRetry = 2 * time.Second
|
|
)
|
|
|
|
var now = time.Now
|
|
|
|
// NewID is a step id: the time in milliseconds in base 36 and six random hex digits, lower-case letters and
|
|
// digits only, so it is one key token and differs between two steps of one node even in one millisecond.
|
|
func NewID() string {
|
|
var b [3]byte
|
|
_, _ = rand.Read(b[:])
|
|
return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// Start begins a step of tool on node and answers its id at once: the key <node>.<id> holds state running when
|
|
// Start returns. run is then called in a goroutine of its own, under ctx (the process's, not the tool call's: the
|
|
// call is cut at 30 s, the step is not). When it returns, the key is written passed with run's result, failed with
|
|
// the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and
|
|
// the process goes on. If the running write fails, nothing was started and Start answers the error.
|
|
func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) {
|
|
step := delivery.Step{ID: NewID(), Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC()}
|
|
key, err := delivery.StepKey(node, step.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := write(put, key, step); err != nil {
|
|
return "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err)
|
|
}
|
|
go func() {
|
|
result, err := protect(ctx, run)
|
|
step.Ended = now().UTC()
|
|
switch ref := (*Refusal)(nil); {
|
|
case err == nil:
|
|
step.State = delivery.StepPassed
|
|
if result != nil {
|
|
raw, merr := json.Marshal(result)
|
|
if merr != nil {
|
|
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
|
|
} else {
|
|
step.Result = raw
|
|
}
|
|
}
|
|
case errors.As(err, &ref):
|
|
step.State, step.Reason = delivery.StepRefused, err.Error()
|
|
default:
|
|
step.State, step.Reason = delivery.StepFailed, err.Error()
|
|
}
|
|
for attempt := 1; ; attempt++ {
|
|
if werr := write(put, key, step); werr == nil || attempt >= EndAttempts {
|
|
return
|
|
}
|
|
time.Sleep(EndRetry)
|
|
}
|
|
}()
|
|
return step.ID, nil
|
|
}
|
|
|
|
// protect is run with a panic turned into the error that ends the step.
|
|
func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (result any, err error) {
|
|
defer func() {
|
|
if p := recover(); p != nil {
|
|
result, err = nil, fmt.Errorf("the step panicked: %v", p)
|
|
}
|
|
}()
|
|
return run(ctx)
|
|
}
|
|
|
|
func write(put Put, key string, step delivery.Step) error {
|
|
raw, err := delivery.EncodeStep(step)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = put(key, json.RawMessage(raw))
|
|
return err
|
|
}
|