Files
traefik-selectel/internal/config/config.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

287 lines
8.7 KiB
Go

// Package config загружает конфигурацию демона: YAML-файл + переопределения из env.
// Секреты Selectel читаются только из env (с поддержкой суффикса _FILE).
package config
import (
"bytes"
"errors"
"fmt"
"io"
"net"
"net/netip"
"os"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/realmanual/traefik-selectel/internal/hostparse"
"github.com/realmanual/traefik-selectel/internal/selectel"
)
// ManagedComment — метка владения записями.
const ManagedComment = "managed-by=traefik-selectel-dns"
const (
MinTTL = 60
MaxTTL = 604800
)
// Config — конфигурация демона.
type Config struct {
Zones []string `yaml:"zones"`
IP string `yaml:"ip"`
TTL int `yaml:"ttl"`
Interval time.Duration `yaml:"interval"`
DryRun bool `yaml:"dryRun"`
Traefik TraefikConfig `yaml:"traefik"`
Docker DockerConfig `yaml:"docker"`
Delete DeleteConfig `yaml:"delete"`
Selectel SelectelConfig `yaml:"selectel"`
Listen string `yaml:"listen"`
LogLevel string `yaml:"logLevel"`
LogFormat string `yaml:"logFormat"`
// Credentials заполняется только из env, не из YAML.
Credentials selectel.Credentials `yaml:"-"`
}
type TraefikConfig struct {
APIURL string `yaml:"apiURL"`
Timeout time.Duration `yaml:"timeout"`
}
type DockerConfig struct {
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
}
type DeleteConfig struct {
Enabled bool `yaml:"enabled"`
GracePeriod time.Duration `yaml:"gracePeriod"`
}
// SelectelConfig — несекретные настройки API.
type SelectelConfig struct {
BaseURL string `yaml:"baseURL"`
AuthURL string `yaml:"authURL"`
Timeout time.Duration `yaml:"timeout"`
}
// Default возвращает конфигурацию со значениями по умолчанию.
func Default() Config {
return Config{
TTL: 300,
Interval: 30 * time.Second,
Traefik: TraefikConfig{APIURL: "http://traefik:8080", Timeout: 10 * time.Second},
Docker: DockerConfig{Enabled: true, Host: "unix:///var/run/docker.sock"},
Delete: DeleteConfig{Enabled: false, GracePeriod: 10 * time.Minute},
Selectel: SelectelConfig{Timeout: 30 * time.Second},
Listen: ":9090", LogLevel: "info", LogFormat: "json",
}
}
// Load читает YAML (path может быть пустым), применяет env-переопределения и секреты, валидирует.
func Load(path string, getenv func(string) string, readFile func(string) ([]byte, error)) (Config, error) {
cfg := Default()
if path != "" {
data, err := readFile(path)
if err != nil {
return cfg, fmt.Errorf("config: чтение %s: %w", path, err)
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) {
return cfg, fmt.Errorf("config: разбор %s: %w", path, err)
}
}
if err := applyEnv(&cfg, getenv); err != nil {
return cfg, err
}
creds, err := LoadCredentials(getenv, readFile)
if err != nil {
return cfg, err
}
cfg.Credentials = creds
if err := cfg.Normalize(); err != nil {
return cfg, err
}
return cfg, cfg.Validate()
}
// LoadCredentials читает SELECTEL_* из env / *_FILE.
func LoadCredentials(getenv func(string) string, readFile func(string) ([]byte, error)) (selectel.Credentials, error) {
var c selectel.Credentials
var err error
for _, f := range []struct {
name string
dst *string
}{
{"SELECTEL_USERNAME", &c.Username},
{"SELECTEL_PASSWORD", &c.Password},
{"SELECTEL_ACCOUNT_ID", &c.AccountID},
{"SELECTEL_PROJECT_ID", &c.ProjectID},
} {
if *f.dst, err = Secret(f.name, getenv, readFile); err != nil {
return c, err
}
}
return c, nil
}
// Secret возвращает значение NAME либо содержимое файла из NAME_FILE.
// Одновременное задание обоих — ошибка.
func Secret(name string, getenv func(string) string, readFile func(string) ([]byte, error)) (string, error) {
direct, file := getenv(name), getenv(name+"_FILE")
if direct != "" && file != "" {
return "", fmt.Errorf("config: заданы и %s, и %s_FILE — оставьте что-то одно", name, name)
}
if file != "" {
b, err := readFile(file)
if err != nil {
return "", fmt.Errorf("config: чтение %s_FILE: %w", name, err)
}
return strings.TrimSpace(string(b)), nil
}
return strings.TrimSpace(direct), nil
}
func applyEnv(c *Config, getenv func(string) string) error {
var errs []error
str := func(key string, dst *string) {
if v := getenv(key); v != "" {
*dst = v
}
}
boolean := func(key string, dst *bool) {
if v := getenv(key); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
errs = append(errs, fmt.Errorf("config: %s: %w", key, err))
return
}
*dst = b
}
}
dur := func(key string, dst *time.Duration) {
if v := getenv(key); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
errs = append(errs, fmt.Errorf("config: %s: %w", key, err))
return
}
*dst = d
}
}
if v := getenv("TSD_ZONES"); v != "" {
c.Zones = nil
for _, z := range strings.Split(v, ",") {
c.Zones = append(c.Zones, z)
}
}
str("TSD_IP", &c.IP)
if v := getenv("TSD_TTL"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
errs = append(errs, fmt.Errorf("config: TSD_TTL: %w", err))
} else {
c.TTL = n
}
}
dur("TSD_INTERVAL", &c.Interval)
boolean("TSD_DRY_RUN", &c.DryRun)
str("TSD_TRAEFIK_API_URL", &c.Traefik.APIURL)
dur("TSD_TRAEFIK_TIMEOUT", &c.Traefik.Timeout)
boolean("TSD_DOCKER_ENABLED", &c.Docker.Enabled)
str("TSD_DOCKER_HOST", &c.Docker.Host)
boolean("TSD_DELETE_ENABLED", &c.Delete.Enabled)
dur("TSD_DELETE_GRACE_PERIOD", &c.Delete.GracePeriod)
str("TSD_SELECTEL_BASE_URL", &c.Selectel.BaseURL)
str("TSD_SELECTEL_AUTH_URL", &c.Selectel.AuthURL)
dur("TSD_SELECTEL_TIMEOUT", &c.Selectel.Timeout)
str("TSD_LISTEN", &c.Listen)
str("TSD_LOG_LEVEL", &c.LogLevel)
str("TSD_LOG_FORMAT", &c.LogFormat)
return errors.Join(errs...)
}
// Normalize приводит зоны к нормализованному виду и убирает дубликаты.
func (c *Config) Normalize() error {
seen := map[string]struct{}{}
var zones []string
for _, z := range c.Zones {
n := hostparse.Normalize(z)
if n == "" {
continue
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
zones = append(zones, n)
}
c.Zones = zones
c.IP = strings.TrimSpace(c.IP)
c.Traefik.APIURL = strings.TrimRight(strings.TrimSpace(c.Traefik.APIURL), "/")
c.LogLevel = strings.ToLower(c.LogLevel)
c.LogFormat = strings.ToLower(c.LogFormat)
return nil
}
// Validate проверяет конфигурацию.
func (c *Config) Validate() error {
var errs []error
if len(c.Zones) == 0 {
errs = append(errs, errors.New("zones: нужна хотя бы одна зона"))
}
for _, z := range c.Zones {
if !hostparse.ValidHostname(z) {
errs = append(errs, fmt.Errorf("zones: некорректная зона %q", z))
}
}
if addr, err := netip.ParseAddr(c.IP); err != nil || !addr.Is4() {
errs = append(errs, fmt.Errorf("ip: %q не является IPv4-адресом", c.IP))
}
if c.TTL < MinTTL || c.TTL > MaxTTL {
errs = append(errs, fmt.Errorf("ttl: %d вне диапазона %d..%d", c.TTL, MinTTL, MaxTTL))
}
if c.Interval < time.Second {
errs = append(errs, fmt.Errorf("interval: %s слишком мал (минимум 1s)", c.Interval))
}
if c.Traefik.APIURL == "" {
errs = append(errs, errors.New("traefik.apiURL: не задан"))
}
if c.Traefik.Timeout <= 0 || c.Selectel.Timeout <= 0 {
errs = append(errs, errors.New("timeout: должен быть положительным"))
}
if c.Delete.Enabled && c.Delete.GracePeriod < c.Interval {
errs = append(errs, fmt.Errorf("delete.gracePeriod: %s меньше interval %s", c.Delete.GracePeriod, c.Interval))
}
if c.Docker.Enabled && c.Docker.Host == "" {
errs = append(errs, errors.New("docker.host: не задан"))
}
if _, _, err := net.SplitHostPort(c.Listen); err != nil {
errs = append(errs, fmt.Errorf("listen: %q: %w", c.Listen, err))
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
errs = append(errs, fmt.Errorf("logLevel: %q (debug|info|warn|error)", c.LogLevel))
}
switch c.LogFormat {
case "json", "text":
default:
errs = append(errs, fmt.Errorf("logFormat: %q (json|text)", c.LogFormat))
}
if err := c.Credentials.Validate(); err != nil {
errs = append(errs, err)
}
return errors.Join(errs...)
}
// OSReadFile — os.ReadFile (для main).
var OSReadFile = os.ReadFile