Files
incudal/agent/internal/config/config.go
T
2026-06-20 14:26:29 +08:00

159 lines
4.1 KiB
Go

package config
import (
"bufio"
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const (
DefaultHeartbeatIntervalSeconds = 30
MinHeartbeatIntervalSeconds = 5
MaxHeartbeatIntervalSeconds = 3600
)
type Config struct {
PanelURL string
AgentID string
AgentSecret string
HeartbeatInterval time.Duration
RequestTimeout time.Duration
HeartbeatIntervalSeconds int
RequestTimeoutSeconds int
}
func Load(path string) (Config, error) {
values := map[string]string{}
if path != "" {
fileValues, err := readKeyValueFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return Config{}, err
}
for key, value := range fileValues {
values[key] = value
}
}
overlayEnv(values, "panel_url", "INCUDAL_PANEL_URL")
overlayEnv(values, "agent_id", "INCUDAL_AGENT_ID")
overlayEnv(values, "agent_secret", "INCUDAL_AGENT_SECRET")
overlayEnv(values, "heartbeat_interval_seconds", "INCUDAL_HEARTBEAT_INTERVAL_SECONDS")
overlayEnv(values, "request_timeout_seconds", "INCUDAL_REQUEST_TIMEOUT_SECONDS")
heartbeatSeconds := clampInt(
parsePositiveInt(values["heartbeat_interval_seconds"], DefaultHeartbeatIntervalSeconds),
MinHeartbeatIntervalSeconds,
MaxHeartbeatIntervalSeconds,
)
timeoutSeconds := parsePositiveInt(values["request_timeout_seconds"], 10)
cfg := Config{
PanelURL: strings.TrimRight(values["panel_url"], "/"),
AgentID: values["agent_id"],
AgentSecret: values["agent_secret"],
HeartbeatIntervalSeconds: heartbeatSeconds,
RequestTimeoutSeconds: timeoutSeconds,
HeartbeatInterval: time.Duration(heartbeatSeconds) * time.Second,
RequestTimeout: time.Duration(timeoutSeconds) * time.Second,
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (cfg Config) Validate() error {
if cfg.PanelURL == "" {
return errors.New("panel_url is required")
}
parsed, err := url.Parse(cfg.PanelURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("panel_url is invalid: %s", cfg.PanelURL)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("panel_url scheme must be http or https: %s", parsed.Scheme)
}
if cfg.AgentID == "" {
return errors.New("agent_id is required")
}
if cfg.AgentSecret == "" {
return errors.New("agent_secret is required")
}
if cfg.HeartbeatInterval < time.Duration(MinHeartbeatIntervalSeconds)*time.Second {
return fmt.Errorf("heartbeat interval must be at least %d seconds", MinHeartbeatIntervalSeconds)
}
if cfg.RequestTimeout < time.Second {
return errors.New("request timeout must be at least 1 second")
}
return nil
}
func readKeyValueFile(path string) (map[string]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
values := map[string]string{}
scanner := bufio.NewScanner(file)
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
return nil, fmt.Errorf("invalid config line %d: expected key: value", lineNumber)
}
key = strings.TrimSpace(key)
value = trimConfigValue(value)
if key == "" {
return nil, fmt.Errorf("invalid config line %d: empty key", lineNumber)
}
values[key] = value
}
if err := scanner.Err(); err != nil {
return nil, err
}
return values, nil
}
func trimConfigValue(value string) string {
trimmed := strings.TrimSpace(value)
trimmed = strings.Trim(trimmed, `"`)
trimmed = strings.Trim(trimmed, `'`)
return trimmed
}
func overlayEnv(values map[string]string, key string, envName string) {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
values[key] = value
}
}
func parsePositiveInt(value string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func clampInt(value int, min int, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}