Skip to content
quarry Artifact [c4a015ee9f]
Login

Artifact [c4a015ee9f]

Artifact c4a015ee9f4a320df173114a9dc7c7714c086a9d903935fc82865d506b39d386:


package runner

import (
	"encoding/json"
	"fmt"
	"path/filepath"
	"sort"
	"strings"
)

// ManifestPath is where a repository says what its runner tasks run.
// It is read AT the check-in under test, like a pipeline, so the
// command can never drift from the code it tests.
const ManifestPath = "ci/runner.json"

// Task is one entry in the manifest: the argv to exec in the check-in's
// tree, and whether any output at all means failure — which is how a
// formatter that lists the files it would change reports a failure
// while exiting zero.
type Task struct {
	Argv         []string `json:"argv"`
	FailOnOutput bool     `json:"fail_on_output,omitempty"`
	// Env adds to the runner's own environment for this task — a
	// virtualenv's PATH, a test database URL.
	Env map[string]string `json:"env,omitempty"`
	// Dir is where the command runs, relative to the tree's root; empty
	// is the root. It may not leave the tree.
	Dir string `json:"dir,omitempty"`
}

// EnvNames lists Env's keys, sorted, so a task's environment is built
// the same way every run.
func (t Task) EnvNames() []string {
	out := make([]string, 0, len(t.Env))
	for k := range t.Env {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

// Manifest maps task types to what runs for them.
//
//	{"tasks": {
//	  "quarry-test": ["go", "test", "./..."],
//	  "quarry-fmt":  {"argv": ["gofmt", "-l", "cmd", "internal"], "fail_on_output": true}
//	}}
//
// An entry is either an argv array or a Task object; the array form is
// the common case and reads as the command it is.
type Manifest struct {
	Tasks map[string]Task
}

// ParseManifest reads a manifest, refusing an empty one and any entry
// with no command, so a typo fails loudly rather than as a task that
// never matches.
func ParseManifest(b []byte) (Manifest, error) {
	var raw struct {
		Tasks map[string]json.RawMessage `json:"tasks"`
	}
	if err := json.Unmarshal(b, &raw); err != nil {
		return Manifest{}, fmt.Errorf("%s is not valid JSON: %v", ManifestPath, err)
	}
	if len(raw.Tasks) == 0 {
		return Manifest{}, fmt.Errorf("%s names no tasks", ManifestPath)
	}
	m := Manifest{Tasks: map[string]Task{}}
	for name, v := range raw.Tasks {
		var t Task
		var argv []string
		if err := json.Unmarshal(v, &argv); err == nil {
			t.Argv = argv
		} else if err := json.Unmarshal(v, &t); err != nil {
			return Manifest{}, fmt.Errorf("%s: task %q is neither an argv array nor an {\"argv\": [...]} object", ManifestPath, name)
		}
		if t.Dir != "" && !filepath.IsLocal(filepath.FromSlash(t.Dir)) {
			return Manifest{}, fmt.Errorf("%s: task %q has dir %q, which leaves the tree", ManifestPath, name, t.Dir)
		}
		for k := range t.Env {
			if k == "" || strings.ContainsAny(k, "=\x00") {
				return Manifest{}, fmt.Errorf("%s: task %q has an env name %q that is not a variable name", ManifestPath, name, k)
			}
		}
		if len(t.Argv) == 0 || t.Argv[0] == "" {
			return Manifest{}, fmt.Errorf("%s: task %q has no command", ManifestPath, name)
		}
		m.Tasks[name] = t
	}
	return m, nil
}

// Names lists the manifest's task types, sorted.
func (m Manifest) Names() []string {
	out := make([]string, 0, len(m.Tasks))
	for n := range m.Tasks {
		out = append(out, n)
	}
	sort.Strings(out)
	return out
}