feat: initial Netts energy orchestrator
This commit is contained in:
274
internal/config/config.go
Normal file
274
internal/config/config.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigFile = "config.yaml"
|
||||
envPrefix = "NETTS"
|
||||
)
|
||||
|
||||
// Config represents the full application configuration.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Netts NettsConfig `yaml:"netts"`
|
||||
Energy EnergyConfig `yaml:"energy"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
// ServerConfig controls HTTP server behaviour.
|
||||
type ServerConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
ReadTimeout Duration `yaml:"readTimeout"`
|
||||
WriteTimeout Duration `yaml:"writeTimeout"`
|
||||
IdleTimeout Duration `yaml:"idleTimeout"`
|
||||
ShutdownTimeout Duration `yaml:"shutdownTimeout"`
|
||||
}
|
||||
|
||||
// NettsConfig contains credentials and settings for the Netts API.
|
||||
type NettsConfig struct {
|
||||
APIKey string `yaml:"apiKey"`
|
||||
BaseURL string `yaml:"baseUrl"`
|
||||
RealIP string `yaml:"realIp"`
|
||||
CallbackURL string `yaml:"callbackUrl"`
|
||||
HTTPTimeout Duration `yaml:"httpTimeout"`
|
||||
Retry Retry `yaml:"retry"`
|
||||
}
|
||||
|
||||
// Retry controls API retry behaviour.
|
||||
type Retry struct {
|
||||
MaxAttempts int `yaml:"maxAttempts"`
|
||||
Backoff Duration `yaml:"backoff"`
|
||||
MaxBackoff Duration `yaml:"maxBackoff"`
|
||||
}
|
||||
|
||||
// EnergyConfig controls orchestration thresholds.
|
||||
type EnergyConfig struct {
|
||||
AutoAddHost bool `yaml:"autoAddHost"`
|
||||
MinCycles int `yaml:"minCycles"`
|
||||
TargetCycles int `yaml:"targetCycles"`
|
||||
MinEnergyThreshold int `yaml:"minEnergyThreshold"`
|
||||
PostOrderWait Duration `yaml:"postOrderWait"`
|
||||
DefaultAnalyzeValue string `yaml:"defaultAnalyzeValue"`
|
||||
}
|
||||
|
||||
// LoggingConfig controls logger behaviour.
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
}
|
||||
|
||||
// Load reads configuration from the provided path or default locations.
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := defaultConfig()
|
||||
|
||||
configFile, err := resolveConfigPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if configFile != "" {
|
||||
raw, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file %q: %w", configFile, err)
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config file %q: %w", configFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaults(&cfg)
|
||||
applyEnvOverrides(&cfg)
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// DefaultConfig returns a configuration populated with defaults.
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
Address: ":8080",
|
||||
ReadTimeout: Duration(10 * time.Second),
|
||||
WriteTimeout: Duration(10 * time.Second),
|
||||
IdleTimeout: Duration(60 * time.Second),
|
||||
ShutdownTimeout: Duration(15 * time.Second),
|
||||
},
|
||||
Netts: NettsConfig{
|
||||
BaseURL: "https://netts.io",
|
||||
HTTPTimeout: Duration(15 * time.Second),
|
||||
Retry: Retry{
|
||||
MaxAttempts: 3,
|
||||
Backoff: Duration(2 * time.Second),
|
||||
MaxBackoff: Duration(10 * time.Second),
|
||||
},
|
||||
},
|
||||
Energy: EnergyConfig{
|
||||
AutoAddHost: true,
|
||||
MinCycles: 3,
|
||||
TargetCycles: 10,
|
||||
MinEnergyThreshold: 32000,
|
||||
PostOrderWait: Duration(3 * time.Second),
|
||||
DefaultAnalyzeValue: "100.00",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "text",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config) {
|
||||
if strings.TrimSpace(cfg.Server.Address) == "" {
|
||||
cfg.Server.Address = ":8080"
|
||||
}
|
||||
|
||||
cfg.Server.ReadTimeout.SetDefault(10 * time.Second)
|
||||
cfg.Server.WriteTimeout.SetDefault(10 * time.Second)
|
||||
cfg.Server.IdleTimeout.SetDefault(60 * time.Second)
|
||||
cfg.Server.ShutdownTimeout.SetDefault(15 * time.Second)
|
||||
|
||||
if strings.TrimSpace(cfg.Netts.BaseURL) == "" {
|
||||
cfg.Netts.BaseURL = "https://netts.io"
|
||||
}
|
||||
cfg.Netts.HTTPTimeout.SetDefault(15 * time.Second)
|
||||
if cfg.Netts.Retry.MaxAttempts <= 0 {
|
||||
cfg.Netts.Retry.MaxAttempts = 3
|
||||
}
|
||||
cfg.Netts.Retry.Backoff.SetDefault(2 * time.Second)
|
||||
cfg.Netts.Retry.MaxBackoff.SetDefault(10 * time.Second)
|
||||
|
||||
if cfg.Energy.MinCycles <= 0 {
|
||||
cfg.Energy.MinCycles = 3
|
||||
}
|
||||
if cfg.Energy.TargetCycles <= 0 {
|
||||
cfg.Energy.TargetCycles = 10
|
||||
}
|
||||
if cfg.Energy.TargetCycles < cfg.Energy.MinCycles {
|
||||
cfg.Energy.TargetCycles = cfg.Energy.MinCycles
|
||||
}
|
||||
if cfg.Energy.MinEnergyThreshold <= 0 {
|
||||
cfg.Energy.MinEnergyThreshold = 32000
|
||||
}
|
||||
cfg.Energy.PostOrderWait.SetDefault(3 * time.Second)
|
||||
if cfg.Energy.DefaultAnalyzeValue == "" {
|
||||
cfg.Energy.DefaultAnalyzeValue = "100.00"
|
||||
}
|
||||
|
||||
if cfg.Logging.Level == "" {
|
||||
cfg.Logging.Level = "info"
|
||||
}
|
||||
if cfg.Logging.Format == "" {
|
||||
cfg.Logging.Format = "text"
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
if v := os.Getenv(envVar("API_KEY")); v != "" {
|
||||
cfg.Netts.APIKey = v
|
||||
}
|
||||
if v := os.Getenv(envVar("BASE_URL")); v != "" {
|
||||
cfg.Netts.BaseURL = v
|
||||
}
|
||||
if v := os.Getenv(envVar("REAL_IP")); v != "" {
|
||||
cfg.Netts.RealIP = v
|
||||
}
|
||||
if v := os.Getenv(envVar("CALLBACK_URL")); v != "" {
|
||||
cfg.Netts.CallbackURL = v
|
||||
}
|
||||
if v := os.Getenv(envVar("AUTO_ADD_HOST")); v != "" {
|
||||
cfg.Energy.AutoAddHost = strings.EqualFold(v, "true") || v == "1"
|
||||
}
|
||||
if v := os.Getenv(envVar("MIN_CYCLES")); v != "" {
|
||||
if parsed, err := parseInt(v); err == nil {
|
||||
cfg.Energy.MinCycles = parsed
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(envVar("TARGET_CYCLES")); v != "" {
|
||||
if parsed, err := parseInt(v); err == nil {
|
||||
cfg.Energy.TargetCycles = parsed
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(envVar("MIN_ENERGY_THRESHOLD")); v != "" {
|
||||
if parsed, err := parseInt(v); err == nil {
|
||||
cfg.Energy.MinEnergyThreshold = parsed
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(envVar("LOG_LEVEL")); v != "" {
|
||||
cfg.Logging.Level = v
|
||||
}
|
||||
if v := os.Getenv(envVar("LOG_FORMAT")); v != "" {
|
||||
cfg.Logging.Format = v
|
||||
}
|
||||
}
|
||||
|
||||
// Validate performs configuration validation.
|
||||
func (c *Config) Validate() error {
|
||||
var problems []string
|
||||
|
||||
if strings.TrimSpace(c.Netts.APIKey) == "" {
|
||||
problems = append(problems, "netts.apiKey is required (or NETTS_API_KEY env)")
|
||||
}
|
||||
if !strings.HasPrefix(c.Netts.BaseURL, "http") {
|
||||
problems = append(problems, "netts.baseUrl must be an absolute URL")
|
||||
}
|
||||
if c.Energy.MinCycles <= 0 {
|
||||
problems = append(problems, "energy.minCycles must be > 0")
|
||||
}
|
||||
if c.Energy.TargetCycles < c.Energy.MinCycles {
|
||||
problems = append(problems, "energy.targetCycles must be >= energy.minCycles")
|
||||
}
|
||||
if c.Energy.MinEnergyThreshold <= 0 {
|
||||
problems = append(problems, "energy.minEnergyThreshold must be > 0")
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return errors.New(strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveConfigPath(path string) (string, error) {
|
||||
if path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", fmt.Errorf("config file %q: %w", path, err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
searchPaths := []string{
|
||||
defaultConfigFile,
|
||||
filepath.Join("config", defaultConfigFile),
|
||||
filepath.Join("..", defaultConfigFile),
|
||||
}
|
||||
|
||||
for _, candidate := range searchPaths {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func envVar(key string) string {
|
||||
return fmt.Sprintf("%s_%s", envPrefix, strings.ToUpper(strings.ReplaceAll(key, ".", "_")))
|
||||
}
|
||||
|
||||
func parseInt(value string) (int, error) {
|
||||
var parsed int
|
||||
_, err := fmt.Sscanf(value, "%d", &parsed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse int %q: %w", value, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
53
internal/config/config_test.go
Normal file
53
internal/config/config_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadDefaultsWithEnv(t *testing.T) {
|
||||
t.Setenv("NETTS_API_KEY", "env-key")
|
||||
|
||||
cfg, err := Load("")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ":8080", cfg.Server.Address)
|
||||
assert.Equal(t, "https://netts.io", cfg.Netts.BaseURL)
|
||||
assert.Equal(t, 3, cfg.Energy.MinCycles)
|
||||
assert.Equal(t, 10, cfg.Energy.TargetCycles)
|
||||
assert.Equal(t, "env-key", cfg.Netts.APIKey)
|
||||
}
|
||||
|
||||
func TestLoadFromFileOverridesDefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "config.yaml")
|
||||
|
||||
body := `
|
||||
server:
|
||||
address: ":9090"
|
||||
readTimeout: 5s
|
||||
netts:
|
||||
apiKey: "file-key"
|
||||
baseUrl: "https://example.com"
|
||||
energy:
|
||||
minCycles: 5
|
||||
targetCycles: 8
|
||||
logging:
|
||||
level: debug
|
||||
`
|
||||
require.NoError(t, os.WriteFile(file, []byte(body), 0o600))
|
||||
|
||||
cfg, err := Load(file)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ":9090", cfg.Server.Address)
|
||||
assert.Equal(t, "https://example.com", cfg.Netts.BaseURL)
|
||||
assert.Equal(t, 5, cfg.Energy.MinCycles)
|
||||
assert.Equal(t, 8, cfg.Energy.TargetCycles)
|
||||
assert.Equal(t, "file-key", cfg.Netts.APIKey)
|
||||
assert.Equal(t, "debug", cfg.Logging.Level)
|
||||
}
|
||||
46
internal/config/types.go
Normal file
46
internal/config/types.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Duration wraps time.Duration to support YAML unmarshalling from strings.
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalYAML parses a duration string such as "5s" or "2m".
|
||||
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("duration must be a string, got %s", value.ShortTag())
|
||||
}
|
||||
parsed, err := time.ParseDuration(value.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse duration %q: %w", value.Value, err)
|
||||
}
|
||||
*d = Duration(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalYAML converts the duration to a string value.
|
||||
func (d Duration) MarshalYAML() (any, error) {
|
||||
return time.Duration(d).String(), nil
|
||||
}
|
||||
|
||||
// Duration returns the underlying time.Duration.
|
||||
func (d Duration) Duration() time.Duration {
|
||||
return time.Duration(d)
|
||||
}
|
||||
|
||||
// IsZero reports whether the duration has been set.
|
||||
func (d Duration) IsZero() bool {
|
||||
return time.Duration(d) == 0
|
||||
}
|
||||
|
||||
// SetDefault assigns the provided default if the duration is zero.
|
||||
func (d *Duration) SetDefault(def time.Duration) {
|
||||
if d.IsZero() {
|
||||
*d = Duration(def)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user