internal/config: загрузка YAML+env (подстановка ${VAR:-default}, defaults, env override, Validate)
All checks were successful
build-test / build (push) Successful in 1m23s
All checks were successful
build-test / build (push) Successful in 1m23s
This commit is contained in:
5
go.mod
5
go.mod
@@ -2,7 +2,10 @@ module github.com/kamelion/ratatoskr-go
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require modernc.org/sqlite v1.56.0
|
||||
require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -20,6 +20,10 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
211
internal/config/config_test.go
Normal file
211
internal/config/config_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeCfg(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(t.TempDir(), "cfg*.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("temp: %v", err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestLoad_FullConfig(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok123")
|
||||
t.Setenv("GITEA_URL", "http://gitea.home")
|
||||
t.Setenv("GITEA_TOKEN", "gtok")
|
||||
t.Setenv("GITEA_USER", "kamelion")
|
||||
|
||||
yaml := `
|
||||
telegram:
|
||||
token: "${TG_TOKEN}"
|
||||
chat_id: "123"
|
||||
|
||||
gitea:
|
||||
url: "${GITEA_URL}"
|
||||
token: "${GITEA_TOKEN}"
|
||||
user: "${GITEA_USER}"
|
||||
|
||||
opencode:
|
||||
bin: "my-opencode"
|
||||
hard_timeout: "10m"
|
||||
`
|
||||
path := writeCfg(t, yaml)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Telegram.Token != "tok123" {
|
||||
t.Errorf("tg token = %q", cfg.Telegram.Token)
|
||||
}
|
||||
if cfg.Telegram.ChatID != "123" {
|
||||
t.Errorf("tg chat_id = %q", cfg.Telegram.ChatID)
|
||||
}
|
||||
if cfg.Gitea.URL != "http://gitea.home" {
|
||||
t.Errorf("gitea url = %q", cfg.Gitea.URL)
|
||||
}
|
||||
if cfg.OpenCode.Bin != "my-opencode" {
|
||||
t.Errorf("opencode bin = %q", cfg.OpenCode.Bin)
|
||||
}
|
||||
if cfg.OpenCode.HardTimeout.Duration() != 10*time.Minute {
|
||||
t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
t.Setenv("GITEA_URL", "http://x")
|
||||
t.Setenv("GITEA_TOKEN", "secret")
|
||||
t.Setenv("GITEA_USER", "u")
|
||||
|
||||
yaml := `
|
||||
telegram:
|
||||
token: "${TG_TOKEN}"
|
||||
chat_id: "${TG_CHAT_ID}"
|
||||
|
||||
gitea:
|
||||
url: "${GITEA_URL}"
|
||||
token: "${GITEA_TOKEN}"
|
||||
user: "${GITEA_USER}"
|
||||
`
|
||||
path := writeCfg(t, yaml)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// default opencode.bin
|
||||
if cfg.OpenCode.Bin != "opencode" {
|
||||
t.Errorf("bin = %q, want opencode", cfg.OpenCode.Bin)
|
||||
}
|
||||
if cfg.OpenCode.HardTimeout.Duration() != 20*time.Minute {
|
||||
t.Errorf("hard timeout = %v", cfg.OpenCode.HardTimeout)
|
||||
}
|
||||
if cfg.OpenCode.IdleTimeout.Duration() != 2*time.Minute {
|
||||
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
|
||||
}
|
||||
if cfg.Paths.Worktree != "/opt/data/ratatoskr/worktrees" {
|
||||
t.Errorf("worktree = %q", cfg.Paths.Worktree)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOverride(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
t.Setenv("GITEA_URL", "http://x")
|
||||
t.Setenv("GITEA_TOKEN", "secret")
|
||||
t.Setenv("GITEA_USER", "u")
|
||||
// env override для opencode.timeout
|
||||
t.Setenv("OPENCODE_CONFIG", "/custom/opencode.json")
|
||||
|
||||
yaml := `telegram:
|
||||
token: "${TG_TOKEN}"
|
||||
chat_id: "${TG_CHAT_ID}"
|
||||
gitea:
|
||||
url: "${GITEA_URL}"
|
||||
token: "${GITEA_TOKEN}"
|
||||
user: "${GITEA_USER}"
|
||||
`
|
||||
path := writeCfg(t, yaml)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OpenCode.Config != "/custom/opencode.json" {
|
||||
t.Errorf("opencode config = %q", cfg.OpenCode.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingEnvVarUsesDefault(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
t.Setenv("GITEA_URL", "http://x")
|
||||
t.Setenv("GITEA_TOKEN", "secret")
|
||||
t.Setenv("GITEA_USER", "u")
|
||||
|
||||
yaml := `telegram:
|
||||
token: "${TG_TOKEN}"
|
||||
chat_id: "${TG_CHAT_ID}"
|
||||
gitea:
|
||||
url: "${GITEA_URL}"
|
||||
token: "${GITEA_TOKEN}"
|
||||
user: "${GITEA_USER}"
|
||||
opencode:
|
||||
db_path: "${OPENCODE_DB:-/default/db.sqlite}"
|
||||
`
|
||||
path := writeCfg(t, yaml)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OpenCode.DBPath != "/default/db.sqlite" {
|
||||
t.Errorf("db_path = %q, want /default/db.sqlite", cfg.OpenCode.DBPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_FileNotFound(t *testing.T) {
|
||||
_, err := Load("/nonexistent/config.yaml")
|
||||
if err == nil || !strings.Contains(err.Error(), "file not found") {
|
||||
t.Fatalf("ожидалась ошибка file not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingRequired(t *testing.T) {
|
||||
// пустой конфиг — все обязательные поля отсутствуют
|
||||
t.Setenv("GITEA_URL", "") // не ставим
|
||||
t.Setenv("GITEA_TOKEN", "")
|
||||
t.Setenv("TG_TOKEN", "")
|
||||
t.Setenv("TG_CHAT_ID", "")
|
||||
t.Setenv("GITEA_USER", "")
|
||||
|
||||
yaml := `telegram:
|
||||
token: ""
|
||||
chat_id: ""
|
||||
gitea:
|
||||
url: ""
|
||||
token: ""
|
||||
user: ""
|
||||
`
|
||||
path := writeCfg(t, yaml)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка валидации")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "telegram.token") {
|
||||
t.Errorf("ожидалось telegram.token в ошибке, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidDuration(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "x")
|
||||
t.Setenv("TG_CHAT_ID", "x")
|
||||
t.Setenv("GITEA_URL", "x")
|
||||
t.Setenv("GITEA_TOKEN", "x")
|
||||
t.Setenv("GITEA_USER", "x")
|
||||
|
||||
yaml := `telegram:
|
||||
token: "x"
|
||||
chat_id: "x"
|
||||
gitea:
|
||||
url: "x"
|
||||
token: "x"
|
||||
user: "x"
|
||||
opencode:
|
||||
hard_timeout: "не-длительность"
|
||||
`
|
||||
path := writeCfg(t, yaml)
|
||||
_, err := Load(path)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse duration") {
|
||||
t.Fatalf("ожидалась ошибка парсинга duration, got %v", err)
|
||||
}
|
||||
}
|
||||
15
internal/config/errors.go
Normal file
15
internal/config/errors.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package config
|
||||
|
||||
import "errors"
|
||||
|
||||
// Классы ошибок конфига (C1..C4).
|
||||
var (
|
||||
// C1 — обязательное поле пусто (токен, url и т.п.)
|
||||
ErrMissingField = errors.New("config: missing required field")
|
||||
|
||||
// C2 — невалидный формат (YAML-синтаксис, Duration, число)
|
||||
ErrInvalidFormat = errors.New("config: invalid format")
|
||||
|
||||
// C4 — файл конфига не найден (не fatal: Load ищет ./config.yaml, флаг --config)
|
||||
ErrFileNotFound = errors.New("config: file not found")
|
||||
)
|
||||
135
internal/config/load.go
Normal file
135
internal/config/load.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Load читает конфиг из yaml-файла, подставляет ${VAR:-default}, накладывает
|
||||
// env-переменные (тег env) и валидирует. Если path пустой — ищет config.yaml
|
||||
// в CWD. Ошибка C4 если файл не существует (но не fatal при path="").
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
path = "config.yaml"
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no such file") || strings.Contains(err.Error(), "cannot find") {
|
||||
return nil, fmt.Errorf("%w: %s", ErrFileNotFound, path)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: read %s: %v", ErrInvalidFormat, path, err)
|
||||
}
|
||||
// подстановка ${VAR:-default}
|
||||
expanded := os.Expand(string(raw), envLookup)
|
||||
cfg := &Config{}
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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.Struct {
|
||||
walk(fv, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
internal/config/types.go
Normal file
89
internal/config/types.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Package config — загрузка конфигурации Ratatoskr-go.
|
||||
//
|
||||
// Источники (каждый следующий имеет приоритет над предыдущим):
|
||||
// 1. Файл config.yaml (с подстановкой ${VAR:-default})
|
||||
// 2. Переменные окружения (env-теги в структуре)
|
||||
//
|
||||
// Порядок: Load("config.yaml") → YAML → os.Expand → env override → Validate.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Duration — time.Duration с YAML-сериализацией (строка "5s", "10m").
|
||||
type Duration time.Duration
|
||||
|
||||
func (d Duration) Duration() time.Duration { return time.Duration(d) }
|
||||
func (d Duration) String() string { return time.Duration(d).String() }
|
||||
|
||||
func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
var s string
|
||||
if err := unmarshal(&s); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidFormat, err)
|
||||
}
|
||||
td, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: parse duration %q: %v", ErrInvalidFormat, s, err)
|
||||
}
|
||||
*d = Duration(td)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config — полная конфигурация бинаря.
|
||||
type Config struct {
|
||||
Telegram TelegramCfg `yaml:"telegram"`
|
||||
Gitea GiteaCfg `yaml:"gitea"`
|
||||
OpenCode OpenCodeCfg `yaml:"opencode"`
|
||||
Chat ChatCfg `yaml:"chat"`
|
||||
Paths PathsCfg `yaml:"paths"`
|
||||
}
|
||||
|
||||
type TelegramCfg struct {
|
||||
Token string `yaml:"token" env:"TG_TOKEN"`
|
||||
ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"`
|
||||
}
|
||||
|
||||
type GiteaCfg struct {
|
||||
URL string `yaml:"url" env:"GITEA_URL"`
|
||||
Token string `yaml:"token" env:"GITEA_TOKEN"`
|
||||
User string `yaml:"user" env:"GITEA_USER"`
|
||||
}
|
||||
|
||||
type OpenCodeCfg struct {
|
||||
Bin string `yaml:"bin" default:"opencode"`
|
||||
DBPath string `yaml:"db_path" default:""`
|
||||
Config string `yaml:"config" env:"OPENCODE_CONFIG"`
|
||||
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
||||
IdleTimeout Duration `yaml:"idle_timeout" default:"2m"`
|
||||
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
||||
}
|
||||
|
||||
type ChatCfg struct {
|
||||
PollInterval Duration `yaml:"poll_interval" default:"30s"`
|
||||
}
|
||||
|
||||
type PathsCfg struct {
|
||||
Worktree string `yaml:"worktree" default:"/opt/data/ratatoskr/worktrees"`
|
||||
}
|
||||
|
||||
// Validate проверяет обязательные поля. Возвращает C1 MissingField
|
||||
// (склеенную, все ошибки сразу) или C2 InvalidFormat.
|
||||
func (c *Config) Validate() error {
|
||||
var errs []error
|
||||
if c.Telegram.Token == "" {
|
||||
errs = append(errs, fmt.Errorf("%w: telegram.token", ErrMissingField))
|
||||
}
|
||||
if c.Telegram.ChatID == "" {
|
||||
errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField))
|
||||
}
|
||||
if c.Gitea.URL == "" {
|
||||
errs = append(errs, fmt.Errorf("%w: gitea.url", ErrMissingField))
|
||||
}
|
||||
if c.Gitea.Token == "" {
|
||||
errs = append(errs, fmt.Errorf("%w: gitea.token", ErrMissingField))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
Reference in New Issue
Block a user