Skip to content
quarry Artifact [2206608e39]
Login

Artifact [2206608e39]

Artifact 2206608e394ec97de45fe86d38e1ce31bbc57dc84fd48951c48583569f2586ee:


package runner

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

// Main is the runner's command line, shared by the standalone
// quarry-runner program and `quarry runner`. prog names the command in
// usage and errors; version is printed by --version and at startup.
func Main(args []string, prog, version string) error {
	fs := flag.NewFlagSet(prog, flag.ContinueOnError)
	maxTasks := fs.Int("max-tasks", 2, "tasks run at once")
	work := fs.String("work", "", "workspace directory (default: the user cache dir)")
	tasks := fs.String("tasks", "", "task types to poll for, comma separated (default: the task names in ci/runner.json on trunk)")
	mode := fs.String("checkout", "archive", `how a task gets its check-in: "archive" downloads the tree from the forge (needs nothing installed); "zit" clones it (needs zit, gives a real repository)`)
	zitBin := fs.String("zit", "zit", `the zit binary, with --checkout zit`)
	user := fs.String("user", "", "account to fetch a PRIVATE repository as; its sync token goes in QUARRY_RUNNER_PASSWORD")
	showVersion := fs.Bool("version", false, "print the version and exit")
	fs.Usage = func() {
		fmt.Fprintf(fs.Output(), `usage: QUARRY_RUNNER_TOKEN=<worker token> %s [flags] https://<forge>/r/<repo>

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

  {"tasks": {"test": ["go", "test", "./..."],
             "lint": {"argv": ["ruff", "check", "."], "fail_on_output": true,
                      "env": {"PYTHONPATH": "src"}, "dir": "app"}}}

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 → Worker tokens.

`, prog)
		fs.PrintDefaults()
	}
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *showVersion {
		fmt.Println(prog, version)
		return nil
	}
	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 → Worker tokens")
	}
	password := os.Getenv("QUARRY_RUNNER_PASSWORD")
	if password == "" {
		password = os.Getenv("ZIT_PASSWORD") // the name `quarry runner` first shipped with
	}
	var checkout Checkout
	switch *mode {
	case "archive":
		checkout = ArchiveCheckout{URL: repoURL, User: *user, Password: password}
	case "zit":
		checkout = ZitCheckout{Zit: *zitBin, URL: repoURL, User: *user}
	default:
		return fmt.Errorf(`--checkout is "archive" or "zit", not %q`, *mode)
	}
	dir := *work
	if dir == "" {
		cache, err := os.UserCacheDir()
		if err != nil {
			return err
		}
		dir = filepath.Join(cache, "quarry-runner", repo)
	}
	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, ManifestPath))
		if err != nil {
			return fmt.Errorf("trunk has no %s to learn task names from; add one or pass --tasks", ManifestPath)
		}
		m, err := ParseManifest(raw)
		if err != nil {
			return err
		}
		names = m.Names()
	}
	r, err := New(Config{
		Bridge:   u.Scheme + "://" + u.Host + "/r/" + url.PathEscape(repo) + "/ci/bridge",
		Token:    token,
		Tasks:    names,
		MaxTasks: *maxTasks,
		WorkDir:  dir,
		Checkout: checkout,
		Version:  version,
	})
	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
}