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: // `..`, 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.`; 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.` 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) } } }