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
}