Files
orchestra/internal/registry/registry.go
T
2026-07-30 14:57:25 +04:00

257 lines
7.5 KiB
Go

// Package registry contains the static project, machine, and herdr topology.
package registry
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"sort"
"strings"
"time"
)
var (
ErrUnknownProject = errors.New("unknown project")
ErrUnknownMachine = errors.New("unknown machine")
ErrNoAffinity = errors.New("project has no machine affinity")
)
type Project struct {
ID string `json:"id"`
MachineAffinity []string `json:"machine_affinity"`
// Repo and WorktreeRoot let each project resolve its own git checkout
// (spec §2.2 — a project is first-class; nothing about the model implies
// a single shared repo across all projects). Both optional: a project
// that omits them falls back to whatever global default the deployment
// wires (single-repo deployments keep working unchanged).
Repo string `json:"repo,omitempty"`
WorktreeRoot string `json:"worktree_root,omitempty"`
QualityGate string `json:"quality_gate,omitempty"`
// SafeOperations is an audited, deliberately small allow-list for work
// inside this project's task worktree. It documents what workers may
// perform without an operator grant; network, secrets, destructive Git,
// and paths outside the worktree are never represented here.
SafeOperations []string `json:"safe_operations,omitempty"`
}
type Machine struct {
ID string `json:"id"`
Address string `json:"address"`
}
type Herdr struct {
ID string `json:"id"`
MachineID string `json:"machine_id"`
Address string `json:"address,omitempty"`
Harness string `json:"harness,omitempty"`
Protocol string `json:"protocol,omitempty"`
Capabilities []string `json:"capabilities"`
Concurrency int `json:"concurrency"`
// QuotaLimit is deprecated in favor of QuotaLimit5h/QuotaLimitWeekly; if
// set and QuotaLimit5h is not, it is treated as the weekly limit only
// (its historical meaning), to avoid silently inventing a 5h cap for
// existing configuration.
QuotaLimit float64 `json:"quota_limit,omitempty"`
QuotaLimit5h float64 `json:"quota_limit_5h,omitempty"`
QuotaLimitWeekly float64 `json:"quota_limit_weekly,omitempty"`
}
const defaultHerdrPort = "9245"
type Config struct {
Projects []Project `json:"projects"`
Machines []Machine `json:"machines"`
Herdrs []Herdr `json:"herdrs"`
}
type Registry struct {
projects map[string]Project
machines map[string]Machine
herdrs map[string]Herdr
}
func Load(path string) (Registry, error) {
b, err := os.ReadFile(path)
if err != nil {
return Registry{}, err
}
var c Config
if err = json.Unmarshal(stripJSONComments(b), &c); err != nil {
return Registry{}, fmt.Errorf("registry config: %w", err)
}
return New(c)
}
// stripJSONComments removes // line comments and /* */ block comments from
// JSONC input, leaving valid JSON. Comment markers inside string literals
// (respecting backslash escapes) are left untouched. This lets deployments
// annotate config.json in place instead of keeping a separate undocumented
// copy (see deploy/config.example.jsonc).
func stripJSONComments(b []byte) []byte {
out := make([]byte, 0, len(b))
inString, escaped, inLineComment, inBlockComment := false, false, false, false
for i := 0; i < len(b); i++ {
c := b[i]
switch {
case inLineComment:
if c == '\n' {
inLineComment = false
out = append(out, c)
}
case inBlockComment:
if c == '*' && i+1 < len(b) && b[i+1] == '/' {
inBlockComment = false
i++
}
case inString:
out = append(out, c)
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '"' {
inString = false
}
case c == '"':
inString = true
out = append(out, c)
case c == '/' && i+1 < len(b) && b[i+1] == '/':
inLineComment = true
i++
case c == '/' && i+1 < len(b) && b[i+1] == '*':
inBlockComment = true
i++
default:
out = append(out, c)
}
}
return out
}
func New(c Config) (Registry, error) {
r := Registry{map[string]Project{}, map[string]Machine{}, map[string]Herdr{}}
for _, p := range c.Projects {
if err := putID(r.projects, p.ID, "project"); err != nil {
return Registry{}, err
}
if len(p.MachineAffinity) == 0 {
return Registry{}, fmt.Errorf("project %q: %w", p.ID, ErrNoAffinity)
}
for _, op := range p.SafeOperations {
switch op {
case "read", "edit", "test", "git":
default:
return Registry{}, fmt.Errorf("project %q: unsafe operation %q is not policy-configurable", p.ID, op)
}
}
r.projects[p.ID] = p
}
for _, m := range c.Machines {
if err := putID(r.machines, m.ID, "machine"); err != nil {
return Registry{}, err
}
if strings.TrimSpace(m.Address) == "" {
return Registry{}, fmt.Errorf("machine %q: address required", m.ID)
}
r.machines[m.ID] = m
}
for _, h := range c.Herdrs {
if err := putID(r.herdrs, h.ID, "herdr"); err != nil {
return Registry{}, err
}
if _, ok := r.machines[h.MachineID]; !ok {
return Registry{}, fmt.Errorf("herdr %q: %w %q", h.ID, ErrUnknownMachine, h.MachineID)
}
if h.Concurrency < 0 {
return Registry{}, fmt.Errorf("herdr %q: negative concurrency", h.ID)
}
r.herdrs[h.ID] = h
}
for _, p := range r.projects {
for _, m := range p.MachineAffinity {
if _, ok := r.machines[m]; !ok {
return Registry{}, fmt.Errorf("project %q: %w %q", p.ID, ErrUnknownMachine, m)
}
}
}
return r, nil
}
func putID[T any](m map[string]T, id, kind string) error {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("%s id required", kind)
}
if _, ok := m[id]; ok {
return fmt.Errorf("duplicate %s %q", kind, id)
}
return nil
}
func (r Registry) Project(id string) (Project, bool) { p, ok := r.projects[id]; return p, ok }
func (r Registry) Machine(id string) (Machine, bool) { m, ok := r.machines[id]; return m, ok }
func (r Registry) Herdr(id string) (Herdr, bool) { h, ok := r.herdrs[id]; return h, ok }
func (r Registry) Machines() []Machine {
out := make([]Machine, 0, len(r.machines))
for _, m := range r.machines {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (r Registry) Herdrs() []Herdr {
out := make([]Herdr, 0, len(r.herdrs))
for _, h := range r.herdrs {
out = append(out, h)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (r Registry) Projects() []Project { return projects(r.projects) }
func projects(m map[string]Project) []Project {
out := make([]Project, 0, len(m))
for _, v := range m {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
type Reachability interface {
Reachable(address string, timeout time.Duration) bool
}
type TCPReachability struct{}
func (TCPReachability) Reachable(address string, timeout time.Duration) bool {
c, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return false
}
_ = c.Close()
return true
}
func (r Registry) Candidates(project string, check Reachability, timeout time.Duration) ([]Herdr, error) {
p, ok := r.projects[project]
if !ok {
return nil, ErrUnknownProject
}
allowed := map[string]bool{}
for _, m := range p.MachineAffinity {
allowed[m] = true
}
out := []Herdr{}
for _, h := range r.herdrs {
if !allowed[h.MachineID] {
continue
}
addr := h.Address
if addr == "" {
addr = r.machines[h.MachineID].Address
if host, _, err := net.SplitHostPort(addr); err == nil {
addr = net.JoinHostPort(host, defaultHerdrPort)
}
}
if check == nil || check.Reachable(addr, timeout) {
out = append(out, h)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}