Files
iceBear67 a006483bbc
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s
init
2026-08-14 07:13:22 +00:00

479 lines
13 KiB
Go

// Package config loads, validates and resolves syncbot's TOML configuration.
//
// The on-disk document is deliberately forgiving: every knob has a default,
// [global] supplies fallbacks for all repositories, and each [[repo]] overrides
// only what it needs. Load flattens all of that into a []Job in which every
// value is already resolved, so the rest of the daemon never has to reason
// about defaults or inheritance — and so the hot-reload logic can decide
// whether a job changed with a plain reflect.DeepEqual.
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
)
// Duration is a time.Duration that decodes from a TOML string such as "5m".
type Duration time.Duration
// UnmarshalText implements encoding.TextUnmarshaler.
func (d *Duration) UnmarshalText(b []byte) error {
v, err := time.ParseDuration(string(b))
if err != nil {
return fmt.Errorf("invalid duration %q (want e.g. \"30s\", \"5m\", \"2h\")", b)
}
*d = Duration(v)
return nil
}
// D returns the underlying time.Duration.
func (d Duration) D() time.Duration { return time.Duration(d) }
// Endpoint is one side of a mirror. In TOML it may be written either as a bare
// URL string or as a table when it needs its own credentials:
//
// dst = "git@github.com:me/repo.git"
//
// [repo.dst]
// url = "git@github.com:me/repo.git"
// ssh_key = "/etc/syncbot/keys/repo"
type Endpoint struct {
URL string `json:"url"`
SSHKey string `json:"-"`
KnownHosts string `json:"-"`
StrictHostKey string `json:"-"`
}
// UnmarshalTOML accepts both the string and the table spelling of an endpoint.
func (e *Endpoint) UnmarshalTOML(v any) error {
switch t := v.(type) {
case string:
e.URL = t
return nil
case map[string]any:
for _, k := range sortedKeys(t) {
s, ok := t[k].(string)
if !ok {
return fmt.Errorf("key %q must be a string", k)
}
switch k {
case "url":
e.URL = s
case "ssh_key":
e.SSHKey = s
case "known_hosts":
e.KnownHosts = s
case "strict_host_key":
e.StrictHostKey = s
default:
return fmt.Errorf("unknown key %q (want url, ssh_key, known_hosts or strict_host_key)", k)
}
}
if e.URL == "" {
return fmt.Errorf("missing required key \"url\"")
}
return nil
default:
return fmt.Errorf("must be a URL string or a table, got %T", v)
}
}
// Global holds process-wide settings plus the defaults inherited by every repo.
type Global struct {
WorkDir string `toml:"work_dir"`
Listen string `toml:"listen"`
LogLevel string `toml:"log_level"`
LogFormat string `toml:"log_format"`
Concurrency int `toml:"concurrency"`
ReloadInterval Duration `toml:"reload_interval"`
// Inherited by every [[repo]] unless overridden there.
Interval Duration `toml:"interval"`
Timeout Duration `toml:"timeout"`
MaxBackoff Duration `toml:"max_backoff"`
Refs []string `toml:"refs"`
Prune *bool `toml:"prune"`
Force *bool `toml:"force"`
Atomic *bool `toml:"atomic"`
AllowEmpty *bool `toml:"allow_empty"`
SSHKey string `toml:"ssh_key"`
KnownHosts string `toml:"known_hosts"`
StrictHostKey string `toml:"strict_host_key"`
GitConfig []string `toml:"git_config"`
}
// Repo is one [[repo]] block as written by the user.
type Repo struct {
Name string `toml:"name"`
Src Endpoint `toml:"src"`
Dst Endpoint `toml:"dst"`
Enabled *bool `toml:"enabled"`
Interval Duration `toml:"interval"`
Timeout Duration `toml:"timeout"`
MaxBackoff Duration `toml:"max_backoff"`
Refs []string `toml:"refs"`
Prune *bool `toml:"prune"`
Force *bool `toml:"force"`
Atomic *bool `toml:"atomic"`
AllowEmpty *bool `toml:"allow_empty"`
SSHKey string `toml:"ssh_key"`
KnownHosts string `toml:"known_hosts"`
StrictHostKey string `toml:"strict_host_key"`
GitConfig []string `toml:"git_config"`
}
// file mirrors the TOML document itself.
type file struct {
Global Global `toml:"global"`
Repos []Repo `toml:"repo"`
}
// Job is a fully resolved sync unit: all defaults folded in, ready to run.
// Every field is comparable with reflect.DeepEqual, which is how the manager
// detects that a reloaded config actually changed something for this repo.
type Job struct {
Name string
Src Endpoint
Dst Endpoint
Dir string // local bare mirror
Interval time.Duration
Timeout time.Duration
MaxBackoff time.Duration
Refs []string
Prune bool
Force bool
Atomic bool
AllowEmpty bool
GitConfig []string
}
// Config is the resolved configuration the daemon runs on.
type Config struct {
WorkDir string
Listen string
LogLevel string
LogFormat string
Concurrency int
ReloadInterval time.Duration
Jobs []Job
}
// Defaults applied when the document leaves a value out.
const (
DefaultWorkDir = "/var/lib/syncbot"
DefaultInterval = 5 * time.Minute
DefaultTimeout = 30 * time.Minute
DefaultMaxBackoff = time.Hour
DefaultReloadInterval = 5 * time.Second
DefaultLogLevel = "info"
DefaultLogFormat = "text"
)
// DefaultRefs mirrors branches and tags, which is what almost everyone wants.
var DefaultRefs = []string{"refs/heads/*", "refs/tags/*"}
// nameRe keeps repo names usable as directory names and Prometheus labels.
var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// Load reads, validates and resolves the config file at path.
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return Parse(b)
}
// Parse resolves an in-memory TOML document. Load is the usual entry point;
// Parse exists so tests (and -check) can work without touching disk.
func Parse(b []byte) (*Config, error) {
var f file
md, err := toml.Decode(string(b), &f)
if err != nil {
return nil, err
}
if undec := unknownKeys(md); len(undec) > 0 {
return nil, fmt.Errorf("unknown config key(s): %s", strings.Join(undec, ", "))
}
return resolve(&f)
}
// unknownKeys reports keys the decoder did not recognise, so a typo like
// "intervall" fails loudly at startup instead of silently doing nothing.
func unknownKeys(md toml.MetaData) []string {
var out []string
for _, k := range md.Undecoded() {
s := k.String()
// Endpoint consumes its own subtree via UnmarshalTOML and validates the
// keys itself; the decoder cannot see into it, so ignore those paths.
if strings.Contains(s, ".src.") || strings.Contains(s, ".dst.") {
continue
}
out = append(out, s)
}
sort.Strings(out)
return out
}
func resolve(f *file) (*Config, error) {
g := f.Global
workDir, err := expandEnv(orString(g.WorkDir, DefaultWorkDir))
if err != nil {
return nil, fmt.Errorf("global.work_dir: %w", err)
}
if !filepath.IsAbs(workDir) {
if workDir, err = filepath.Abs(workDir); err != nil {
return nil, fmt.Errorf("global.work_dir: %w", err)
}
}
cfg := &Config{
WorkDir: workDir,
Listen: g.Listen,
LogLevel: strings.ToLower(orString(g.LogLevel, DefaultLogLevel)),
LogFormat: strings.ToLower(orString(g.LogFormat, DefaultLogFormat)),
Concurrency: g.Concurrency,
ReloadInterval: orDuration(g.ReloadInterval, DefaultReloadInterval),
}
if cfg.Concurrency <= 0 {
cfg.Concurrency = min(4, runtime.NumCPU())
}
switch cfg.LogLevel {
case "debug", "info", "warn", "error":
default:
return nil, fmt.Errorf("global.log_level: %q is not one of debug, info, warn, error", cfg.LogLevel)
}
switch cfg.LogFormat {
case "text", "json":
default:
return nil, fmt.Errorf("global.log_format: %q is not one of text, json", cfg.LogFormat)
}
if cfg.ReloadInterval < time.Second {
return nil, fmt.Errorf("global.reload_interval: must be at least 1s")
}
if len(f.Repos) == 0 {
return nil, fmt.Errorf("no [[repo]] blocks defined: nothing to sync")
}
seen := make(map[string]bool, len(f.Repos))
for i := range f.Repos {
r := &f.Repos[i]
where := fmt.Sprintf("repo[%d]", i)
if r.Name != "" {
where = fmt.Sprintf("repo %q", r.Name)
}
if !nameRe.MatchString(r.Name) {
return nil, fmt.Errorf("%s: name must match %s", where, nameRe)
}
if seen[r.Name] {
return nil, fmt.Errorf("%s: duplicate name", where)
}
seen[r.Name] = true
if enabled := r.Enabled; enabled != nil && !*enabled {
continue
}
job, err := resolveJob(r, &g, workDir)
if err != nil {
return nil, fmt.Errorf("%s: %w", where, err)
}
cfg.Jobs = append(cfg.Jobs, *job)
}
if len(cfg.Jobs) == 0 {
return nil, fmt.Errorf("every [[repo]] is disabled: nothing to sync")
}
sort.Slice(cfg.Jobs, func(i, j int) bool { return cfg.Jobs[i].Name < cfg.Jobs[j].Name })
return cfg, nil
}
func resolveJob(r *Repo, g *Global, workDir string) (*Job, error) {
j := &Job{
Name: r.Name,
Dir: filepath.Join(workDir, "mirrors", r.Name+".git"),
Interval: pick(r.Interval, g.Interval, DefaultInterval),
Timeout: pick(r.Timeout, g.Timeout, DefaultTimeout),
MaxBackoff: pick(r.MaxBackoff, g.MaxBackoff, DefaultMaxBackoff),
Refs: orSlice(r.Refs, g.Refs, DefaultRefs),
Prune: orBool(true, r.Prune, g.Prune),
Force: orBool(true, r.Force, g.Force),
Atomic: orBool(false, r.Atomic, g.Atomic),
AllowEmpty: orBool(false, r.AllowEmpty, g.AllowEmpty),
GitConfig: orSlice(r.GitConfig, g.GitConfig, nil),
}
var err error
// SSH settings cascade: endpoint table -> [[repo]] -> [global].
if j.Src, err = resolveEndpoint(r.Src, r, g); err != nil {
return nil, fmt.Errorf("src: %w", err)
}
if j.Dst, err = resolveEndpoint(r.Dst, r, g); err != nil {
return nil, fmt.Errorf("dst: %w", err)
}
if j.Src.URL == "" {
return nil, fmt.Errorf("src is required")
}
if j.Dst.URL == "" {
return nil, fmt.Errorf("dst is required")
}
if j.Src.URL == j.Dst.URL {
return nil, fmt.Errorf("src and dst are the same repository")
}
if j.Interval <= 0 {
return nil, fmt.Errorf("interval must be positive")
}
if j.Timeout <= 0 {
return nil, fmt.Errorf("timeout must be positive")
}
if j.MaxBackoff < j.Interval {
j.MaxBackoff = j.Interval
}
if len(j.Refs) == 0 {
return nil, fmt.Errorf("refs must not be empty")
}
for _, p := range j.Refs {
if !strings.HasPrefix(p, "refs/") {
return nil, fmt.Errorf("refs: %q must start with \"refs/\"", p)
}
if strings.Count(p, "*") > 1 {
return nil, fmt.Errorf("refs: %q may contain at most one \"*\"", p)
}
}
for _, kv := range j.GitConfig {
if !strings.Contains(kv, "=") {
return nil, fmt.Errorf("git_config: %q must be in key=value form", kv)
}
}
return j, nil
}
func resolveEndpoint(e Endpoint, r *Repo, g *Global) (Endpoint, error) {
out := Endpoint{
URL: e.URL,
SSHKey: orString(e.SSHKey, r.SSHKey, g.SSHKey),
KnownHosts: orString(e.KnownHosts, r.KnownHosts, g.KnownHosts),
StrictHostKey: orString(e.StrictHostKey, r.StrictHostKey, g.StrictHostKey),
}
var err error
for _, p := range []*string{&out.URL, &out.SSHKey, &out.KnownHosts} {
if *p, err = expandEnv(*p); err != nil {
return out, err
}
}
if out.StrictHostKey == "" {
// With a pinned known_hosts file we can afford to be strict; without
// one, trust-on-first-use is the only thing that can work unattended.
if out.KnownHosts != "" {
out.StrictHostKey = "yes"
} else {
out.StrictHostKey = "accept-new"
}
}
switch out.StrictHostKey {
case "yes", "no", "accept-new":
default:
return out, fmt.Errorf("strict_host_key: %q is not one of yes, no, accept-new", out.StrictHostKey)
}
if out.SSHKey != "" && !filepath.IsAbs(out.SSHKey) {
return out, fmt.Errorf("ssh_key: %q must be an absolute path", out.SSHKey)
}
return out, nil
}
// envRe matches ${VAR}. Bare $VAR is deliberately not expanded so that secrets
// containing a literal '$' survive unharmed.
var envRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// expandEnv substitutes ${VAR} references, failing loudly on undefined names —
// silently expanding to "" would produce a subtly broken URL instead.
func expandEnv(s string) (string, error) {
if !strings.Contains(s, "${") {
return s, nil
}
var missing []string
out := envRe.ReplaceAllStringFunc(s, func(m string) string {
name := m[2 : len(m)-1]
v, ok := os.LookupEnv(name)
if !ok {
missing = append(missing, name)
return ""
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf("undefined environment variable(s): %s", strings.Join(missing, ", "))
}
return out, nil
}
func orString(vs ...string) string {
for _, v := range vs {
if v != "" {
return v
}
}
return ""
}
func orBool(def bool, vs ...*bool) bool {
for _, v := range vs {
if v != nil {
return *v
}
}
return def
}
func orSlice(vs ...[]string) []string {
for _, v := range vs {
if len(v) > 0 {
return append([]string(nil), v...)
}
}
return nil
}
func orDuration(v Duration, def time.Duration) time.Duration {
if v != 0 {
return v.D()
}
return def
}
func pick(repo, global Duration, def time.Duration) time.Duration {
if repo != 0 {
return repo.D()
}
if global != 0 {
return global.D()
}
return def
}
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// orBool with a literal default needs a *bool; these make the call sites read
// naturally without sprinkling helper variables around.
func boolPtr(b bool) *bool { return &b }