Skip to content
quarry Artifact [41e77a1185]
Login

Artifact [41e77a1185]

Artifact 41e77a118531b840a9edfa7295ef6a7618c52f59e456dc11ecdc84c59953231d:


package main

import (
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"sync"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/ci"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// engineStub answers GET /runs/{id} with run and records every cancel.
type engineStub struct {
	mu        sync.Mutex
	run       ci.Run
	cancelled []string
}

func (s *engineStub) serve(t *testing.T) *ci.Engine {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		s.mu.Lock()
		defer s.mu.Unlock()
		if req.Method == "POST" && req.URL.Path == "/runs/"+s.run.RunID+"/cancel" {
			s.cancelled = append(s.cancelled, s.run.RunID)
			s.run.Status = "cancelled"
			return
		}
		json.NewEncoder(w).Encode(s.run)
	}))
	t.Cleanup(srv.Close)
	return ci.New(srv.URL)
}

func cancelDB(t *testing.T) *oltp.DB {
	t.Helper()
	db, err := oltp.Open(filepath.Join(t.TempDir(), "o.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })
	if err := db.AddCIRun(oltp.CIRun{RunID: "r1", Repo: "alpha", Checkin: "c1",
		Workflow: "w", Pipeline: "ci/p.json", Status: "running"}); err != nil {
		t.Fatal(err)
	}
	return db
}

func runRow(t *testing.T, db *oltp.DB) oltp.CIRun {
	t.Helper()
	r, ok, err := db.CIRunByID("alpha", "r1")
	if err != nil || !ok {
		t.Fatalf("run r1: %v %v", ok, err)
	}
	return r
}

// #297: a repository admin can end a run. The engine is told, the reason
// is recorded against the run, and the poller — which drains the run's
// output before marking it finished — settles it as cancelled.
func TestCancelCIRunEndsTheRunAsCancelled(t *testing.T) {
	stub := &engineStub{run: ci.Run{RunID: "r1", Status: "running",
		Steps: map[string]ci.Step{"test": {Status: "queued"}}}}
	engine := stub.serve(t)
	db := cancelDB(t)

	if err := cancelCIRun(engine, db, "beta", "r1", "dan"); !errors.Is(err, ops.ErrNotFound) {
		t.Fatalf("a run of another repository answered %v, want not found", err)
	}
	if err := cancelCIRun(engine, db, "alpha", "nope", "dan"); !errors.Is(err, ops.ErrNotFound) {
		t.Fatalf("an unknown run answered %v, want not found", err)
	}
	if len(stub.cancelled) != 0 {
		t.Fatalf("engine cancelled %v before a valid ask", stub.cancelled)
	}
	if err := cancelCIRun(engine, db, "alpha", "r1", "dan"); err != nil {
		t.Fatal(err)
	}
	if len(stub.cancelled) != 1 || runRow(t, db).Reason == "" {
		t.Fatalf("cancelled %v reason %q, want one engine cancel and a recorded reason", stub.cancelled, runRow(t, db).Reason)
	}

	pollCI(engine, db, nil, false, t.TempDir(), nil, nil)
	if r := runRow(t, db); r.Status != "cancelled" || r.Completed == "" || r.Reason == "" {
		t.Fatalf("after the poll: %+v, want cancelled, completed, reason kept", r)
	}

	if err := cancelCIRun(engine, db, "alpha", "r1", "dan"); !errors.Is(err, ops.ErrInvalid) {
		t.Fatalf("cancelling a finished run answered %v, want invalid", err)
	}
}

// #297: a run whose steps no worker has taken ends after a bounded time
// as cancelled with a reason, instead of reading "running" for ever. A
// young run, and one with a step running, are left alone.
func TestPollCIExpiresARunNoWorkerHasClaimed(t *testing.T) {
	old := time.Now().Add(-2 * ciUnclaimedAfter).UTC().Format(time.RFC3339Nano)
	fresh := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano)
	cases := []struct {
		name    string
		created string
		steps   map[string]ci.Step
		expires bool
	}{
		{"unclaimed and old", old, map[string]ci.Step{"test": {Status: "queued"}}, true},
		{"unclaimed but young", fresh, map[string]ci.Step{"test": {Status: "queued"}}, false},
		{"old with a step running", old, map[string]ci.Step{"test": {Status: "running"}}, false},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			stub := &engineStub{run: ci.Run{RunID: "r1", Status: "running", CreatedAt: c.created, Steps: c.steps}}
			engine := stub.serve(t)
			db := cancelDB(t)
			pollCI(engine, db, nil, false, t.TempDir(), nil, nil)
			r := runRow(t, db)
			if c.expires {
				if len(stub.cancelled) != 1 || r.Status != "cancelled" || r.Completed == "" || r.Reason == "" {
					t.Fatalf("cancels %v, row %+v: want engine cancelled, row cancelled with a reason", stub.cancelled, r)
				}
				return
			}
			if len(stub.cancelled) != 0 || r.Status != "running" || r.Reason != "" {
				t.Fatalf("cancels %v, row %+v: want the run left running", stub.cancelled, r)
			}
		})
	}
}