Files
ratatoskr-go/internal/config/load.go
Hermes 2f26b6ae88
Some checks failed
CI / test (push) Successful in 54s
CI / build-and-package (amd64, linux) (push) Failing after 1h58m55s
CI / build-and-package (amd64, windows) (push) Successful in 59s
feat: opencode serve — постоянный сервер вместо разовых subprocess (вариант A)
Супервайзер Server spawn'ит opencode serve (или ходит на внешний URL),
Runner ходит к нему через 'opencode run --attach <url>'. По умолчанию
serve.enabled=false — историческая spawn-модель сохранена; наличие
serve.url переключает на внешний сервер.

- config: ServeCfg (enabled/hostname/port/url/password) + дефолты; лоадер
  научился int/bool (раньше только string/Duration/struct).
- opencode: Server (Start/Run/Close, /global/health, рестарт упавшего,
  reaper-горутина владеет Wait; .Exited() непригоден для SIGKILL).
- Runner.AttachURL: run --attach <url> при заданном URL, иначе как раньше.
- app: composition root — при включённом serve запускает супервайзер.
- тесты: TestRun_AttachMode, TestServer_{ExternalURL,OwnProcess,Restart}.
2026-08-18 08:35:16 +05:00

207 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Load читает конфиг из yaml-файла, подставляет ${VAR:-default}, накладывает
// env-переменные (тег env) и валидирует. Если path пустой — ищет config.yaml
// сначала в каталоге бинаря (exeDir), затем в CWD (fallback). Ошибка C4 если
// файл не существует (но не fatal при path="").
func Load(path string) (*Config, error) {
noFile := false
if path == "" {
// приоритет — рядом с .exe, фоллбэк на CWD
path = firstExistingPath(filepath.Join(ExeDir(), "config.yaml"), "config.yaml")
}
raw, err := os.ReadFile(path)
if err != nil {
if strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "cannot find") {
if path == "config.yaml" {
// Файл не указан и не найден — не фатально, соберём дефолты+env
noFile = true
} else {
return nil, fmt.Errorf("%w: %s", ErrFileNotFound, path)
}
} else {
return nil, fmt.Errorf("%w: read %s: %v", ErrInvalidFormat, path, err)
}
}
cfg := &Config{}
if !noFile {
expanded := os.Expand(string(raw), envLookup)
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
return nil, fmt.Errorf("%w: parse %s: %v", ErrInvalidFormat, path, err)
}
}
// дефолты для zero-значений
applyDefaults(cfg)
// env override (только если env-переменная непустая)
applyEnvOverrides(cfg)
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
// ExeDir возвращает каталог исполняемого файла (бинаря). Используется для
// поиска config.yaml и резолва относительных путей "рядом с .exe".
func ExeDir() string {
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
// firstExistingPath возвращает первый существующий путь из списка.
func firstExistingPath(paths ...string) string {
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p
}
}
return paths[len(paths)-1] // фоллбэк на последний
}
// ResolveExePaths подставляет относительные пути ПУТЕЙ (db, worktree) к каталогу
// бинаря, чтобы они были стабильны независимо от CWD запуска. Абсолютные пути
// не трогает. Вызывается после Load (в app.New), а не внутри Load, чтобы
// персист-тесты дефолтов оставались независимыми от окружения.
func (c *Config) ResolveExePaths() {
base := ExeDir()
if c.Paths.DB != "" && !filepath.IsAbs(c.Paths.DB) {
c.Paths.DB = filepath.Join(base, c.Paths.DB)
}
if c.Paths.Worktree != "" && !filepath.IsAbs(c.Paths.Worktree) {
c.Paths.Worktree = filepath.Join(base, c.Paths.Worktree)
}
}
// envLookup обрабатывает ${VAR:-default} в YAML.
func envLookup(key string) string {
parts := strings.SplitN(key, ":-", 2)
v := os.Getenv(parts[0])
if v != "" {
return v
}
if len(parts) > 1 {
return parts[1]
}
return ""
}
// ---- reflect-based defaults и env override ----
type fieldMeta struct {
defaultVal string
envKey string
}
func collectMeta(f reflect.StructField) fieldMeta {
return fieldMeta{
envKey: f.Tag.Get("env"),
defaultVal: f.Tag.Get("default"),
}
}
// applyDefaults проставляет default-значения для zero-полей.
func applyDefaults(cfg *Config) {
walk(reflect.ValueOf(cfg).Elem(), func(fv reflect.Value, meta fieldMeta) {
if meta.defaultVal == "" {
return
}
// только zero-поля
if !fv.IsZero() {
return
}
// Duration требует ручного Unmarshal
if dur, ok := fv.Addr().Interface().(*Duration); ok {
_ = dur.UnmarshalYAML(func(v any) error {
*(v.(*string)) = meta.defaultVal
return nil
})
return
}
// строка
if fv.Kind() == reflect.String {
fv.SetString(meta.defaultVal)
}
// int (значение по умолчанию, напр. port)
if fv.Kind() == reflect.Int {
if n, err := strconv.Atoi(meta.defaultVal); err == nil {
fv.SetInt(int64(n))
}
}
// bool (по умолчанию false/true)
if fv.Kind() == reflect.Bool {
if b, err := strconv.ParseBool(meta.defaultVal); err == nil {
fv.SetBool(b)
}
}
})
}
// applyEnvOverrides перезаписывает поля из env-переменных, если они непустые.
func applyEnvOverrides(cfg *Config) {
walk(reflect.ValueOf(cfg).Elem(), func(fv reflect.Value, meta fieldMeta) {
if meta.envKey == "" {
return
}
envVal := os.Getenv(meta.envKey)
if envVal == "" {
return
}
if dur, ok := fv.Addr().Interface().(*Duration); ok {
_ = dur.UnmarshalYAML(func(v any) error {
*(v.(*string)) = envVal
return nil
})
return
}
if fv.Kind() == reflect.String {
fv.SetString(envVal)
}
if fv.Kind() == reflect.Int {
if n, err := strconv.Atoi(envVal); err == nil {
fv.SetInt(int64(n))
}
}
if fv.Kind() == reflect.Bool {
if b, err := strconv.ParseBool(envVal); err == nil {
fv.SetBool(b)
}
}
})
}
// walk рекурсивно обходит все leaf-поля структуры (string, Duration) и зовёт fn.
func walk(v reflect.Value, fn func(reflect.Value, fieldMeta)) {
t := v.Type()
durType := reflect.TypeOf(Duration(0))
for i := range t.NumField() {
f := t.Field(i)
fv := v.Field(i)
if !fv.CanSet() {
continue
}
if fv.Kind() == reflect.String {
fn(fv, collectMeta(f))
} else if fv.Type() == durType {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Int {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Bool {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Struct {
walk(fv, fn)
}
}
}