Skip to content
quarry Artifact [644a1af05e]
Login

Artifact [644a1af05e]

Artifact 644a1af05e690450eb0c26754dadf2990cc67157685ab15ac7fbda7e0ddd2ae0:


package runner

import (
	"context"
	"encoding/json"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// A TASK THAT CARRIES A WORKFLOW JOB RUNS THE JOB'S STEPS, NOT A MANIFEST.
//
// quarry RFC 0019: a job's `run:` steps travel in the step's metadata
// (JobKey), so a repository with YAML workflows needs no ci/runner.json.
// Steps run in order with the job's env under each step's, in the job's
// working directory unless a step names its own; the first failure ends
// the job; a job asking for secrets is refused until they can be
// delivered safely.
func TestATaskCarryingAJobRunsItsSteps(t *testing.T) {
	job := func(j Job) map[string]string {
		b, _ := json.Marshal(j)
		return map[string]string{JobKey: string(b)}
	}
	with := func(id string, meta map[string]string) polled {
		p := task(id, "c1")
		p.Metadata = meta
		return p
	}
	b := &fakeBridge{queue: map[string][]polled{"t-job": {
		with("ok", job(Job{Env: map[string]string{"WHO": "job", "KEEP": "kept"}, Steps: []JobStep{
			{Name: "first", Run: "echo first $WHO $KEEP"},
			{Run: "echo second $WHO\npwd", Env: map[string]string{"WHO": "step"}, WorkingDirectory: "app"},
		}})),
		with("stops", job(Job{Steps: []JobStep{{Name: "breaks", Run: "false"}, {Name: "never", Run: "echo never ran"}}})),
		with("secret", job(Job{Secrets: []string{"CLOUDFLARE_API_TOKEN"}, Steps: []JobStep{{Run: "echo deploying"}}})),
		with("escape", job(Job{Steps: []JobStep{{Run: "true", WorkingDirectory: "../out"}}})),
		with("stepsecret", job(Job{Steps: []JobStep{{Run: "echo deploying", Secrets: []string{"STEP_TOKEN"}}}})),
	}}, resolved: map[string]map[string]any{}, logs: map[string]string{}}
	srv := httptest.NewServer(b.handler())
	defer srv.Close()
	r, err := New(Config{
		Bridge: srv.URL, Token: "WT", Tasks: []string{"t-job"}, MaxTasks: 1,
		WorkDir: t.TempDir(), Checkout: appCheckout{}, Heartbeat: 50 * time.Millisecond, Logf: t.Logf,
	})
	if err != nil {
		t.Fatal(err)
	}
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() { r.Run(ctx); close(done) }()
	for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
		b.mu.Lock()
		n := len(b.resolved)
		b.mu.Unlock()
		if n == 5 {
			break
		}
	}
	cancel()
	<-done
	b.mu.Lock()
	defer b.mu.Unlock()
	if got := b.resolved["ok"]; got["action"] != "complete" {
		t.Fatalf("FAIL: a good job resolved %v, log %q", got, b.logs["ok"])
	}
	log := b.logs["ok"]
	if !strings.Contains(log, "first job kept") || !strings.Contains(log, "second step") || !strings.Contains(log, "/app\n") {
		t.Errorf("FAIL: the job's log %q; want job env, step env over it, and the step's working directory", log)
	}
	if !strings.Contains(log, "$ first") || !strings.Contains(log, "$ echo second $WHO") {
		t.Errorf("FAIL: the job's log %q; want each step labelled by its name or first line", log)
	}
	if got := b.resolved["stops"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "breaks") || strings.Contains(b.logs["stops"], "never ran") {
		t.Errorf("FAIL: a failing step resolved %v with log %q; want the job to stop there, naming it", got, b.logs["stops"])
	}
	if got := b.resolved["secret"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "CLOUDFLARE_API_TOKEN") || strings.Contains(b.logs["secret"], "deploying") {
		t.Errorf("FAIL: a job asking for a secret resolved %v with log %q; want it refused, unrun", got, b.logs["secret"])
	}
	if got := b.resolved["stepsecret"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "STEP_TOKEN") || strings.Contains(b.logs["stepsecret"], "deploying") {
		t.Errorf("FAIL: a step asking for a secret resolved %v; want it refused, unrun", got)
	}
	if got := b.resolved["escape"]; got["action"] != "fail" || !strings.Contains(got["error"].(string), "leaves the tree") {
		t.Errorf("FAIL: a step outside the tree resolved %v; want a refusal", got)
	}
	t.Log("ok: steps run in order with merged env and directory; failure stops; secrets and escapes refused")
}

// appCheckout writes a tree with an app/ directory and no manifest: a
// job needs none.
type appCheckout struct{}

func (appCheckout) Checkout(_ context.Context, dir, _ string) error {
	return os.MkdirAll(filepath.Join(dir, "app"), 0o755)
}