291 lines
9.6 KiB
Go
291 lines
9.6 KiB
Go
package link
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
)
|
|
|
|
// A pull request's check is queued by the pull request (novox/hq ADR 0406 §3, §4, issue 974).
|
|
|
|
// SupersededByKey is the outcome of a check that stopped because its pull request's key moved on: its head or its trunk
|
|
// commit is no longer the one it was asked for. Said as the check's failure, as a kill is; nothing is asked of the forge.
|
|
const SupersededByKey = "superseded: the pull request's key moved on"
|
|
|
|
// unsafeToken is what may not stand in a subject token or a key.
|
|
var unsafeToken = strings.NewReplacer(".", "_", "*", "_", ">", "_", " ", "_", "/", "_", "\t", "_", "\n", "_")
|
|
|
|
// tokenOf is a name as one subject token: any dot, wildcard or space replaced by an underscore.
|
|
func tokenOf(name string) string {
|
|
if name == "" {
|
|
return "_"
|
|
}
|
|
return unsafeToken.Replace(name)
|
|
}
|
|
|
|
// PullKeyOf is a pull request's key in mesh-delivery's bucket, and the last tokens of the subject its check is asked on:
|
|
// `<owner>.<repo>.<number>`, a dot in a name made an underscore.
|
|
func PullKeyOf(owner, repo string, number int) string {
|
|
return tokenOf(owner) + "." + tokenOf(repo) + "." + strconv.Itoa(number)
|
|
}
|
|
|
|
// CheckSubjectOf is the subject a check is asked on, and whether it has one of its own: a check of a pull request on
|
|
// the ready queue or the other, by the pull request; a delivery group's composed check by `group.<name>`; any other
|
|
// check (a branch tried before its pull request) by its own id, so it replaces nothing.
|
|
func CheckSubjectOf(seat string, r BuildRequest) (string, bool) {
|
|
c := r.Check
|
|
if seat != broker.TheBuildSeat || c == nil {
|
|
return "", false
|
|
}
|
|
prefix := broker.CheckSubjectPrefix(seat, c.Ready)
|
|
switch {
|
|
case c.Group != "":
|
|
return prefix + "group." + tokenOf(c.Group), true
|
|
case c.Try || c.Number <= 0:
|
|
return prefix + "try." + tokenOf(r.ID), true
|
|
}
|
|
return prefix + PullKeyOf(c.Owner, c.Repo, c.Number), true
|
|
}
|
|
|
|
// WorkSubjectOf is where an ask is put: a check on the build seat on its own queue, anything else on the seat's.
|
|
func WorkSubjectOf(seat string, r BuildRequest) string {
|
|
if subject, ok := CheckSubjectOf(seat, r); ok {
|
|
return subject
|
|
}
|
|
return BuildWorkOf(seat)
|
|
}
|
|
|
|
// PullKey is what mesh-delivery keeps of a pull request (ADR 0406 §4): its head, the trunk commit its current check is
|
|
// asked against, and its verdict once judged.
|
|
type PullKey struct {
|
|
Head string `json:"head"`
|
|
Trunk string `json:"trunk"`
|
|
Verdict string `json:"verdict,omitempty"`
|
|
}
|
|
|
|
// sameCommit says two spellings name one commit; either may be short, but not under twelve characters.
|
|
func sameCommit(a, b string) bool {
|
|
return a == b || SameHead(a, b)
|
|
}
|
|
|
|
// Moved says a key no longer holds what a check was asked for: its head or its trunk commit differs.
|
|
func (k PullKey) Moved(head, trunk string) bool {
|
|
return !sameCommit(k.Head, head) || (trunk != "" && k.Trunk != "" && !sameCommit(k.Trunk, trunk))
|
|
}
|
|
|
|
// Judged says a key holds the verdict of the check of this head against this trunk commit.
|
|
func (k PullKey) Judged(head, trunk string) bool {
|
|
return k.Verdict != "" && sameCommit(k.Head, head) && sameCommit(k.Trunk, trunk) && trunk != ""
|
|
}
|
|
|
|
// ReadPullKey reads a pull request's key from mesh-delivery's bucket: false when it has none.
|
|
func ReadPullKey(ctx context.Context, conn *nats.Conn, owner, repo string, number int) (PullKey, bool, error) {
|
|
api, err := jetstream.New(conn)
|
|
if err != nil {
|
|
return PullKey{}, false, err
|
|
}
|
|
kv, err := api.KeyValue(ctx, broker.PullsBucket)
|
|
if err != nil {
|
|
return PullKey{}, false, fmt.Errorf("the pull requests' keys: %w", err)
|
|
}
|
|
e, err := kv.Get(ctx, PullKeyOf(owner, repo, number))
|
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
|
return PullKey{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return PullKey{}, false, err
|
|
}
|
|
var key PullKey
|
|
if err := json.Unmarshal(e.Value(), &key); err != nil {
|
|
return PullKey{}, false, fmt.Errorf("the key of %s/%s#%d is not a pull request's: %w", owner, repo, number, err)
|
|
}
|
|
return key, true, nil
|
|
}
|
|
|
|
// WatchPull calls moved once when the pull request's key moves on from what this check was asked for — its head or its
|
|
// trunk commit differs — and not for a key that only gains its verdict. It hears updates, not the key as it stands: the
|
|
// key and the request are written for one announcement by two writers, so a key found older than the request is not
|
|
// a move. The check is then stopped by whoever asked for it, without asking the forge (novox/hq issue 726). The stop
|
|
// ends the watch.
|
|
func WatchPull(ctx context.Context, conn *nats.Conn, r BuildRequest, moved func(PullKey)) (stop func(), err error) {
|
|
c := r.Check
|
|
if c == nil || c.Group != "" || c.Try || c.Number <= 0 {
|
|
return func() {}, nil
|
|
}
|
|
api, err := jetstream.New(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kv, err := api.KeyValue(ctx, broker.PullsBucket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the pull requests' keys: %w", err)
|
|
}
|
|
watching, end := context.WithCancel(ctx)
|
|
w, err := kv.Watch(watching, PullKeyOf(c.Owner, c.Repo, c.Number), jetstream.UpdatesOnly())
|
|
if err != nil {
|
|
end()
|
|
return nil, err
|
|
}
|
|
go func() {
|
|
defer w.Stop()
|
|
for {
|
|
select {
|
|
case <-watching.Done():
|
|
return
|
|
case e, ok := <-w.Updates():
|
|
if !ok {
|
|
return
|
|
}
|
|
if e == nil || e.Operation() != jetstream.KeyValuePut {
|
|
continue
|
|
}
|
|
var key PullKey
|
|
if json.Unmarshal(e.Value(), &key) != nil {
|
|
continue
|
|
}
|
|
if key.Moved(r.Ref, c.Trunk) {
|
|
moved(key)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return end, nil
|
|
}
|
|
|
|
// checkTakeWait is how long a taking loop waits on one queue for an ask that is not there, and takeIdleWait how long it
|
|
// waits on the first when all three are empty.
|
|
const (
|
|
checkTakeWait = 30 * time.Millisecond
|
|
takeIdleWait = time.Second
|
|
)
|
|
|
|
// CheckRunning is what a builder says of the check it is running, in the bucket of the checks running (novox/hq ADR 0406
|
|
// §3): the head and trunk commit it checks, the builder and its build. The key is the pull request's (RunningKeyOf).
|
|
type CheckRunning struct {
|
|
Head string `json:"head"`
|
|
Trunk string `json:"trunk"`
|
|
Builder string `json:"builder"`
|
|
Build string `json:"build"`
|
|
}
|
|
|
|
// RunningKeyOf is the key a check is said running under: its pull request's tokens, or `group.<name>` for a group's
|
|
// composed check; none for a try, which no other request replaces.
|
|
func RunningKeyOf(r BuildRequest) (string, bool) {
|
|
c := r.Check
|
|
switch {
|
|
case c == nil, c.Try:
|
|
return "", false
|
|
case c.Group != "":
|
|
return "group." + tokenOf(c.Group), true
|
|
case c.Number <= 0:
|
|
return "", false
|
|
}
|
|
return PullKeyOf(c.Owner, c.Repo, c.Number), true
|
|
}
|
|
|
|
// runningBucket opens the bucket of the checks running.
|
|
func runningBucket(ctx context.Context, conn *nats.Conn) (jetstream.KeyValue, error) {
|
|
api, err := jetstream.New(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kv, err := api.KeyValue(ctx, broker.CheckRunningBucket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the checks running: %w", err)
|
|
}
|
|
return kv, nil
|
|
}
|
|
|
|
// ReadRunning reads the key of the check running for a request's pull request: false when there is none.
|
|
func ReadRunning(ctx context.Context, conn *nats.Conn, r BuildRequest) (CheckRunning, bool, error) {
|
|
key, ok := RunningKeyOf(r)
|
|
if !ok {
|
|
return CheckRunning{}, false, nil
|
|
}
|
|
kv, err := runningBucket(ctx, conn)
|
|
if err != nil {
|
|
return CheckRunning{}, false, err
|
|
}
|
|
e, err := kv.Get(ctx, key)
|
|
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
|
return CheckRunning{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return CheckRunning{}, false, err
|
|
}
|
|
var running CheckRunning
|
|
if err := json.Unmarshal(e.Value(), &running); err != nil {
|
|
return CheckRunning{}, false, nil // a key of a builder from before this means no match
|
|
}
|
|
return running, true, nil
|
|
}
|
|
|
|
// Same says a running check is for this request's head and trunk commit: both said, and the same. A key that names neither
|
|
// (an older builder's) is no match.
|
|
func (c CheckRunning) Same(r BuildRequest) bool {
|
|
return r.Check != nil && c.Head != "" && c.Trunk != "" && r.Check.Trunk != "" && sameCommit(c.Head, r.Ref) &&
|
|
sameCommit(c.Trunk, r.Check.Trunk)
|
|
}
|
|
|
|
// keepRunning says a check is running: its key put now, put again at each heartbeat, and deleted by the stop it returns,
|
|
// whatever the outcome. Said to nobody that cannot be told: a bucket that is not there is no reason not to run the check.
|
|
func keepRunning(ctx context.Context, conn *nats.Conn, r BuildRequest, builder string, every time.Duration) (stop func()) {
|
|
key, ok := RunningKeyOf(r)
|
|
if !ok {
|
|
return func() {}
|
|
}
|
|
kv, err := runningBucket(ctx, conn)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s runs without saying so: %v\n", r.ID, err)
|
|
return func() {}
|
|
}
|
|
trunk := ""
|
|
if r.Check != nil {
|
|
trunk = r.Check.Trunk
|
|
}
|
|
body, _ := json.Marshal(CheckRunning{Head: r.Ref, Trunk: trunk, Builder: builder, Build: r.ID})
|
|
put := func() {
|
|
putting, end := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer end()
|
|
if _, err := kv.Put(putting, key, body); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s could not say it is running: %v\n", r.ID, err)
|
|
}
|
|
}
|
|
put()
|
|
done := make(chan struct{})
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
defer close(finished)
|
|
tick := time.NewTicker(every)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-tick.C:
|
|
put()
|
|
}
|
|
}
|
|
}()
|
|
return func() {
|
|
close(done)
|
|
<-finished
|
|
deleting, end := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer end()
|
|
if err := kv.Delete(deleting, key); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s could not take its running key away; it ages out: %v\n", r.ID, err)
|
|
}
|
|
}
|
|
}
|