Skip to content
quarry Artifact [f5a71a8347]
Login

Artifact [f5a71a8347]

Artifact f5a71a83479f86d6d19ee2496c1e730b3a7c797fd5266395b446702cb79f092f:


package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"

	"craftdesign.group/quarry/internal/runner"
)

// runnerCmd is `quarry runner <forge>/r/<repo>`: a CI worker for one
// repository, run by its owner on their own machine (f24d78d9).
//
// The worker token comes from QUARRY_RUNNER_TOKEN, never a flag: a flag
// is visible to every user of the machine in the process list.
func runnerCmd(args []string) error {
	fs := flag.NewFlagSet("runner", flag.ContinueOnError)
	maxTasks := fs.Int("max-tasks", 2, "tasks run at once")
	work := fs.String("work", "", "workspace directory (default: the user cache dir)")
	zitBin := fs.String("zit", "zit", "the zit binary to clone with")
	tasks := fs.String("tasks", "", "task types to poll for, comma separated (default: the task names in ci/runner.json on trunk)")
	user := fs.String("user", "", "account to clone a PRIVATE repository as; its sync token goes in ZIT_PASSWORD")
	fs.Usage = func() {
		fmt.Fprintln(fs.Output(), `usage: QUARRY_RUNNER_TOKEN=<worker token> quarry runner [flags] https://<forge>/r/<repo>

Runs this repository's CI tasks on this machine. Each task checks out the
check-in under test, reads ci/runner.json AT that check-in, and runs the
argv it names for the task's type:

  {"tasks": {"test": ["go", "test", "./..."],
             "fmt":  {"argv": ["gofmt", "-l", "."], "fail_on_output": true}}}

It runs whatever the check-in's manifest says. Point it only at a
repository whose writers you trust with this machine.

Mint the token in the repository's Settings → Runners.`)
		fs.PrintDefaults()
	}
	if err := fs.Parse(args); err != nil {
		return err
	}
	if fs.NArg() != 1 {
		fs.Usage()
		return errors.New("name the repository's URL")
	}
	repoURL := strings.TrimSuffix(fs.Arg(0), "/")
	u, err := url.Parse(repoURL)
	if err != nil || u.Scheme == "" || u.Host == "" {
		return fmt.Errorf("%q is not a repository URL like https://forge.example/r/name", repoURL)
	}
	segs := strings.Split(strings.Trim(u.Path, "/"), "/")
	if len(segs) < 2 || segs[0] != "r" {
		return fmt.Errorf("%q is not a repository URL like https://forge.example/r/name", repoURL)
	}
	repo := segs[len(segs)-1] // /r/<owner>/<repo> and /r/<repo> both name <repo>
	token := strings.TrimSpace(os.Getenv("QUARRY_RUNNER_TOKEN"))
	if token == "" {
		return errors.New("set QUARRY_RUNNER_TOKEN to a worker token from the repository's Settings → Runners")
	}
	dir := *work
	if dir == "" {
		cache, err := os.UserCacheDir()
		if err != nil {
			return err
		}
		dir = filepath.Join(cache, "quarry-runner", repo)
	}
	checkout := runner.ZitCheckout{Zit: *zitBin, URL: repoURL, User: *user}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	names := splitList(*tasks)
	if len(names) == 0 {
		base := filepath.Join(dir, "trunk")
		if err := checkout.Checkout(ctx, base, "trunk"); err != nil {
			return fmt.Errorf("reading the task names from trunk: %w", err)
		}
		raw, err := os.ReadFile(filepath.Join(base, runner.ManifestPath))
		if err != nil {
			return fmt.Errorf("trunk has no %s to learn task names from; add one or pass --tasks", runner.ManifestPath)
		}
		m, err := runner.ParseManifest(raw)
		if err != nil {
			return err
		}
		names = m.Names()
	}
	r, err := runner.New(runner.Config{
		Bridge:   u.Scheme + "://" + u.Host + "/r/" + url.PathEscape(repo) + "/ci/bridge",
		Token:    token,
		Tasks:    names,
		MaxTasks: *maxTasks,
		WorkDir:  dir,
		Checkout: checkout,
	})
	if err != nil {
		return err
	}
	if err := r.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		return err
	}
	return nil
}

func splitList(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}