Files
keepalive/config.go
T
tiennm99 42c8a871cf fix(runner): retry failed services after one minute and fail fast on bad config
A failed connect or tick now closes the connection and reconnects after one
minute, re-running initialization so a dropped table or row is recreated.
Before, a failing tick logged every interval forever and connect retries
logged every 10 seconds.

Connection URLs that fail to parse no longer appear in logs, since their
error repeats the password.

An unknown adapter or a missing required config key now stops keepalive at
startup instead of silently idling one service.
2026-10-09 11:10:45 +07:00

156 lines
3.9 KiB
Go

package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/tiennm99/keepalive/adapter"
"gopkg.in/yaml.v3"
)
const (
defaultInterval = time.Minute
defaultCounterKey = "counter"
)
var defaultConfigFiles = []string{
"config.yml",
"config.yaml",
"/config.yml",
"/config.yaml",
}
type appConfig struct {
Interval string `json:"interval" yaml:"interval"`
CounterKey string `json:"counter_key" yaml:"counter_key"`
Services []serviceFileConfig `json:"services" yaml:"services"`
}
type serviceFileConfig struct {
Name string `json:"name" yaml:"name"`
Adapter string `json:"adapter" yaml:"adapter"`
Interval string `json:"interval" yaml:"interval"`
CounterKey string `json:"counter_key" yaml:"counter_key"`
Config map[string]string `json:"config" yaml:"config"`
}
type serviceConfig struct {
Name string
AdapterType string
Interval time.Duration
Config adapter.Config
}
func loadConfigFile(path string) ([]serviceConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw appConfig
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return normalizeConfig(raw)
}
func defaultConfigFile() (string, error) {
return firstExistingConfigFile(defaultConfigFiles)
}
func firstExistingConfigFile(paths []string) (string, error) {
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
}
return "", fmt.Errorf("config file not found (looked for: %s)", strings.Join(paths, ", "))
}
func normalizeConfig(raw appConfig) ([]serviceConfig, error) {
if len(raw.Services) == 0 {
return nil, fmt.Errorf("services must contain at least one service")
}
globalInterval, err := parseConfigInterval("interval", raw.Interval, defaultInterval)
if err != nil {
return nil, err
}
globalCounterKey := valueOrDefault(raw.CounterKey, defaultCounterKey)
services := make([]serviceConfig, 0, len(raw.Services))
usedNames := map[string]int{}
for i, rawService := range raw.Services {
servicePath := fmt.Sprintf("services[%d]", i)
adapterType := strings.TrimSpace(rawService.Adapter)
if adapterType == "" {
return nil, fmt.Errorf("%s.adapter is required", servicePath)
}
interval, err := parseConfigInterval(servicePath+".interval", rawService.Interval, globalInterval)
if err != nil {
return nil, err
}
cfg := adapter.Config{}
for key, value := range rawService.Config {
cfg[key] = value
}
cfg["counter_key"] = valueOrDefault(rawService.CounterKey, globalCounterKey)
// Factories do no I/O, so building one here catches an unknown adapter
// or a missing config key at startup instead of silently idling.
if _, err := adapter.New(adapterType, cfg); err != nil {
return nil, fmt.Errorf("%s: %w", servicePath, err)
}
name, err := normalizeServiceName(rawService.Name, adapterType, cfg, usedNames, servicePath)
if err != nil {
return nil, err
}
services = append(services, serviceConfig{
Name: name,
AdapterType: adapterType,
Interval: interval,
Config: cfg,
})
}
return services, nil
}
func parseConfigInterval(field, value string, def time.Duration) (time.Duration, error) {
if strings.TrimSpace(value) == "" {
return def, nil
}
if d, err := time.ParseDuration(value); err == nil {
if d <= 0 {
return 0, fmt.Errorf("%s must be greater than zero", field)
}
return d, nil
}
if n, err := strconv.Atoi(value); err == nil {
d := time.Duration(n) * time.Second
if d <= 0 {
return 0, fmt.Errorf("%s must be greater than zero", field)
}
return d, nil
}
return 0, fmt.Errorf("%s must be a duration like 30s, 5m, or 1h30m, or an integer number of seconds", field)
}
func valueOrDefault(value, def string) string {
if strings.TrimSpace(value) == "" {
return def
}
return value
}