// workergroup.go — who may publish work into whose queue.
//
// A repository's CI spec lives in that repository, so the person who
// writes it chooses the `worker_group:` its steps are published to. That
// makes the spelling in the file an AUTHORIZATION decision, and the same
// shape as the hole dagnats closed in #695: a caller naming a boundary
// and being believed.
//
// The risk is not reading someone else's work — it is writing into it. A
// spec naming another repository's group publishes ITS steps onto THEIR
// machines, and their runners execute an author they never agreed to
// run. So a group must be absent, or exactly this repository's own.
package api
import (
"fmt"
"regexp"
"sort"
"strings"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/ops"
)
// workerGroupDecl finds a worker_group declaration anywhere in a spec —
// any nesting, quoted or bare, block style or inside a flow mapping.
//
// This is a TEXT scan, because quarry has no YAML parser: it delegates
// compilation to the engine (Deps.ValidateCIYAML), and pulling in a YAML
// dependency to read one key is a large answer to a small question.
//
// The trade that makes a text scan acceptable here is the direction of
// its mistakes. Missing a real declaration would leave the hole open;
// flagging a harmless string refuses a save and prints a diagnostic
// saying exactly why. So this matches broadly and errs toward refusing:
// a `worker_group:` inside a quoted string or a comment is caught too,
// and the author's fix is to say which group they meant.
//
// Anchors and merge keys (`<<: *defaults`), which dagnats honours, carry
// the literal key in the block they are defined in, so they are seen.
var workerGroupDecl = regexp.MustCompile(`["']?worker_group["']?\s*:\s*["']?([^"'\s,}#]*)`)
// foreignWorkerGroups names every group a spec publishes to that is not
// own — sorted and deduplicated, so a spec with the same mistake in six
// steps produces one diagnostic rather than six.
//
// An EMPTY value is foreign too. `worker_group:` with nothing after it
// is not "ungrouped": it is a declaration whose meaning the author did
// not state, and guessing which of the two they meant is how a step ends
// up on a queue nobody expected.
func foreignWorkerGroups(content, own string) []string {
seen := map[string]bool{}
for _, m := range workerGroupDecl.FindAllStringSubmatch(content, -1) {
got := strings.TrimSpace(m[1])
if got == own && own != "" {
continue
}
seen[got] = true
}
out := make([]string, 0, len(seen))
for g := range seen {
out = append(out, g)
}
sort.Strings(out)
return out
}
// refuseForeignWorkerGroup is the gate ci.pipeline.save applies before a
// spec is written or validated. Absent is fine — that is the ungrouped
// queue, and the forge's own runners.
func refuseForeignWorkerGroup(repo, content string) error {
own := ci.GroupFor(repo)
foreign := foreignWorkerGroups(content, own)
if len(foreign) == 0 {
return nil
}
named := make([]string, 0, len(foreign))
for _, g := range foreign {
if g == "" {
named = append(named, "an empty group")
continue
}
named = append(named, "%q")
named[len(named)-1] = fmt.Sprintf("%q", g)
}
return fmt.Errorf("this pipeline publishes to %s; %s may only use its own worker group %q, or none at all: %w",
strings.Join(named, ", "), repo, own, ops.ErrInvalid)
}