mirror of
https://github.com/onedr0p/exportarr.git
synced 2026-02-06 10:57:32 +00:00
feat: add Sabnzbd INI config file support
- Implement custom INI parser following XML parser pattern - Add LoadSabnzbdConfig with koanf integration - Support loading from defaults, env vars (SAB_*), flags, and sabnzbd.ini - Extract API key and construct URL from host/port in INI file - Add comprehensive test suite matching Radarr config tests - Update sabnzbd command to register flags and use new config loader
This commit is contained in:
parent
7a3db16074
commit
5d44eebf81
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
# Project
|
||||
/exportarr
|
||||
*.xml
|
||||
sabnzbd.ini
|
||||
|
||||
# Go
|
||||
/vendor
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.RegisterSabnzbdFlags(sabnzbdCmd.PersistentFlags())
|
||||
rootCmd.AddCommand(sabnzbdCmd)
|
||||
}
|
||||
|
||||
@ -17,7 +18,7 @@ var sabnzbdCmd = &cobra.Command{
|
||||
Short: "Prometheus Exporter for Sabnzbd",
|
||||
Long: "Prometheus Exporter for Sabnzbd.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := config.LoadSabnzbdConfig(*conf)
|
||||
c, err := config.LoadSabnzbdConfig(*conf, cmd.PersistentFlags())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -1,25 +1,126 @@
|
||||
// Package config provides configuration loading for Sabnzbd exporter.
|
||||
// It supports loading configuration from defaults, environment variables,
|
||||
// command-line flags, and sabnzbd.ini files.
|
||||
//
|
||||
// The configuration is loaded in the following priority order:
|
||||
// 1. Defaults
|
||||
// 2. Environment variables (with SABNZBD__ prefix)
|
||||
// 3. Command-line flags
|
||||
// 4. sabnzbd.ini file (if --config flag is provided)
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
// config.RegisterSabnzbdFlags(flags)
|
||||
// conf := base_config.Config{
|
||||
// URL: "http://localhost:8080",
|
||||
// ApiKey: "default-api-key",
|
||||
// }
|
||||
// cfg, err := config.LoadSabnzbdConfig(conf, flags)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// if err := cfg.Validate(); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gookit/validate"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/env"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/providers/posflag"
|
||||
"github.com/knadh/koanf/v2"
|
||||
flag "github.com/spf13/pflag"
|
||||
|
||||
base_config "github.com/onedr0p/exportarr/internal/config"
|
||||
)
|
||||
|
||||
type SabnzbdConfig struct {
|
||||
URL string `validate:"required|url"`
|
||||
ApiKey string `validate:"required"`
|
||||
DisableSSLVerify bool
|
||||
// RegisterSabnzbdFlags registers command-line flags for Sabnzbd configuration.
|
||||
func RegisterSabnzbdFlags(flags *flag.FlagSet) {
|
||||
flags.StringP("config", "c", "", "sabnzbd.ini config file for parsing authentication information")
|
||||
}
|
||||
|
||||
func LoadSabnzbdConfig(conf base_config.Config) (*SabnzbdConfig, error) {
|
||||
ret := &SabnzbdConfig{
|
||||
// SabnzbdConfig holds the configuration for Sabnzbd exporter.
|
||||
type SabnzbdConfig struct {
|
||||
App string `koanf:"app"`
|
||||
INIConfig string `koanf:"config"`
|
||||
URL string `koanf:"url" validate:"required|url"`
|
||||
ApiKey string `koanf:"api-key" validate:"required|regex:(^[a-zA-Z0-9]{20,32}$)"`
|
||||
DisableSSLVerify bool `koanf:"disable-ssl-verify"`
|
||||
k *koanf.Koanf
|
||||
}
|
||||
|
||||
// LoadSabnzbdConfig loads Sabnzbd configuration from defaults, environment variables,
|
||||
// command-line flags, and optionally from a sabnzbd.ini file.
|
||||
//
|
||||
// The configuration is loaded in the following priority order:
|
||||
// 1. Defaults
|
||||
// 2. Environment variables (with SABNZBD__ prefix, converted to lowercase with dashes)
|
||||
// 3. Command-line flags
|
||||
// 4. sabnzbd.ini file (if --config flag is provided)
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
// config.RegisterSabnzbdFlags(flags)
|
||||
// conf := base_config.Config{
|
||||
// URL: "http://localhost:8080",
|
||||
// ApiKey: "default-api-key",
|
||||
// }
|
||||
// cfg, err := config.LoadSabnzbdConfig(conf, flags)
|
||||
func LoadSabnzbdConfig(conf base_config.Config, flags *flag.FlagSet) (*SabnzbdConfig, error) {
|
||||
k := koanf.New(".")
|
||||
|
||||
// Defaults
|
||||
err := k.Load(confmap.Provider(map[string]interface{}{}, "."), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Environment
|
||||
err = k.Load(env.Provider("SAB_", ".", func(s string) string {
|
||||
// Strip SAB_ prefix if present, then transform
|
||||
s = strings.TrimPrefix(strings.ToUpper(s), "SAB_")
|
||||
s = strings.ToLower(s)
|
||||
s = strings.ReplaceAll(s, "_", "-")
|
||||
return s
|
||||
}), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Flags
|
||||
if err := k.Load(posflag.Provider(flags, ".", k), nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// INIConfig
|
||||
iniConfig := k.String("config")
|
||||
if iniConfig != "" {
|
||||
err := k.Load(file.Provider(iniConfig), INIParser(), koanf.WithMergeFunc(INIParser().Merge(conf.URL)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out := &SabnzbdConfig{
|
||||
App: conf.App,
|
||||
URL: conf.URL,
|
||||
ApiKey: conf.ApiKey,
|
||||
DisableSSLVerify: conf.DisableSSLVerify,
|
||||
k: k,
|
||||
}
|
||||
return ret, nil
|
||||
if err = k.Unmarshal("", out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Validate validates the Sabnzbd configuration.
|
||||
func (c *SabnzbdConfig) Validate() error {
|
||||
v := validate.Struct(c)
|
||||
if !v.Validate() {
|
||||
@ -27,3 +128,18 @@ func (c *SabnzbdConfig) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Messages returns custom validation error messages.
|
||||
func (c SabnzbdConfig) Messages() map[string]string {
|
||||
return validate.MS{
|
||||
"ApiKey.regex": "api-key must be a 20-32 character alphanumeric string",
|
||||
}
|
||||
}
|
||||
|
||||
// Translates returns field name translations for validation.
|
||||
func (c SabnzbdConfig) Translates() map[string]string {
|
||||
return validate.MS{
|
||||
"INIConfig": "config",
|
||||
"ApiKey": "api-key",
|
||||
}
|
||||
}
|
||||
|
||||
213
internal/sabnzbd/config/config_test.go
Normal file
213
internal/sabnzbd/config/config_test.go
Normal file
@ -0,0 +1,213 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
base_config "github.com/onedr0p/exportarr/internal/config"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testFlagSet() *pflag.FlagSet {
|
||||
ret := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
RegisterSabnzbdFlags(ret)
|
||||
return ret
|
||||
}
|
||||
|
||||
func TestLoadConfig_Defaults(t *testing.T) {
|
||||
flags := testFlagSet()
|
||||
c := base_config.Config{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdef0123456789abcdef0123456789",
|
||||
DisableSSLVerify: true,
|
||||
}
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
config, err := LoadSabnzbdConfig(c, flags)
|
||||
require.NoError(err)
|
||||
|
||||
// base config values are not overwritten
|
||||
require.Equal("http://localhost:8080", config.URL)
|
||||
require.Equal("abcdef0123456789abcdef0123456789", config.ApiKey)
|
||||
require.True(config.DisableSSLVerify)
|
||||
}
|
||||
|
||||
func TestLoadConfig_Environment(t *testing.T) {
|
||||
flags := testFlagSet()
|
||||
c := base_config.Config{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdef0123456789abcdef0123456789",
|
||||
DisableSSLVerify: true,
|
||||
}
|
||||
|
||||
require := require.New(t)
|
||||
t.Setenv("SAB_CONFIG", "test_fixtures/sabnzbd.ini")
|
||||
|
||||
config, err := LoadSabnzbdConfig(c, flags)
|
||||
require.NoError(err)
|
||||
|
||||
require.Equal("test_fixtures/sabnzbd.ini", config.INIConfig)
|
||||
|
||||
// base config values are not overwritten
|
||||
require.Equal("http://localhost:8080", config.URL)
|
||||
require.Equal("abcdef0123456789abcdef0123456789", config.ApiKey)
|
||||
require.True(config.DisableSSLVerify)
|
||||
}
|
||||
|
||||
func TestLoadConfig_Flags(t *testing.T) {
|
||||
flags := testFlagSet()
|
||||
flags.Set("config", "test_fixtures/sabnzbd.ini")
|
||||
c := base_config.Config{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdef0123456789abcdef0123456789",
|
||||
}
|
||||
|
||||
// should be overridden by flags
|
||||
t.Setenv("SAB_CONFIG", "other.ini")
|
||||
|
||||
require := require.New(t)
|
||||
config, err := LoadSabnzbdConfig(c, flags)
|
||||
require.NoError(err)
|
||||
require.Equal("test_fixtures/sabnzbd.ini", config.INIConfig)
|
||||
|
||||
require.Equal("http://localhost:8080", config.URL)
|
||||
require.Equal("abcdef0123456789abcdef0123456789", config.ApiKey)
|
||||
}
|
||||
|
||||
func TestLoadConfig_INIConfig(t *testing.T) {
|
||||
flags := testFlagSet()
|
||||
flags.Set("config", "test_fixtures/sabnzbd.ini")
|
||||
c := base_config.Config{
|
||||
URL: "http://localhost",
|
||||
}
|
||||
|
||||
config, err := LoadSabnzbdConfig(c, flags)
|
||||
|
||||
require := require.New(t)
|
||||
require.NoError(err)
|
||||
|
||||
// URL should be constructed from INI host and port
|
||||
// host is "::" which should become "localhost", port is 8080
|
||||
require.Equal("http://localhost:8080", config.URL)
|
||||
require.Equal("abcdef0123456789abcdef0123456789", config.ApiKey)
|
||||
}
|
||||
|
||||
func TestLoadConfig_INIConfigEnv(t *testing.T) {
|
||||
flags := testFlagSet()
|
||||
t.Setenv("SAB_CONFIG", "test_fixtures/sabnzbd.ini")
|
||||
c := base_config.Config{
|
||||
URL: "http://localhost",
|
||||
}
|
||||
|
||||
config, err := LoadSabnzbdConfig(c, flags)
|
||||
|
||||
require := require.New(t)
|
||||
require.NoError(err)
|
||||
|
||||
// URL should be constructed from INI host and port
|
||||
require.Equal("http://localhost:8080", config.URL)
|
||||
require.Equal("abcdef0123456789abcdef0123456789", config.ApiKey)
|
||||
}
|
||||
|
||||
func TestLoadConfig_INIConfigWithBaseURL(t *testing.T) {
|
||||
flags := testFlagSet()
|
||||
flags.Set("config", "test_fixtures/sabnzbd.ini")
|
||||
c := base_config.Config{
|
||||
URL: "http://sabnzbd.example.com:9090",
|
||||
}
|
||||
|
||||
config, err := LoadSabnzbdConfig(c, flags)
|
||||
|
||||
require := require.New(t)
|
||||
require.NoError(err)
|
||||
|
||||
// When base URL is provided, INI port should override it
|
||||
require.Equal("http://localhost:8080", config.URL)
|
||||
require.Equal("abcdef0123456789abcdef0123456789", config.ApiKey)
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
params := []struct {
|
||||
name string
|
||||
config *SabnzbdConfig
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "good-config",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdef0123456789abcdef0123456789",
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "good-api-key-32-len",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdefABCDEF0123456789abcdef0123",
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "good-api-key-20-len",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdefABCDEF01234567",
|
||||
},
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "bad-api-key-too-short",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdef0123456789abc",
|
||||
},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "bad-api-key-too-long",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "bad-api-key-invalid-chars",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "abcdef0123456789abcdef01234567-",
|
||||
},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "missing-url",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "",
|
||||
ApiKey: "abcdef0123456789abcdef0123456789",
|
||||
},
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "missing-api-key",
|
||||
config: &SabnzbdConfig{
|
||||
URL: "http://localhost:8080",
|
||||
ApiKey: "",
|
||||
},
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
for _, p := range params {
|
||||
t.Run(p.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
err := p.config.Validate()
|
||||
if p.valid {
|
||||
require.NoError(err)
|
||||
} else {
|
||||
require.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
189
internal/sabnzbd/config/ini_parser.go
Normal file
189
internal/sabnzbd/config/ini_parser.go
Normal file
@ -0,0 +1,189 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// INI represents the INI parser helper for sabnzbd.ini files.
|
||||
type INI struct{}
|
||||
|
||||
// INIParser returns a new INI parser instance.
|
||||
func INIParser() *INI {
|
||||
return &INI{}
|
||||
}
|
||||
|
||||
// Unmarshal parses INI file content and returns a map of configuration values.
|
||||
// It handles sabnzbd.ini format with sections like [misc] and key-value pairs.
|
||||
func (p *INI) Unmarshal(b []byte) (map[string]interface{}, error) {
|
||||
result := make(map[string]interface{})
|
||||
currentSection := "misc" // Default section for sabnzbd.ini
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(b)))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip version and encoding headers
|
||||
if strings.Contains(line, "sabnzbd.ini_version__") || strings.Contains(line, "__encoding__") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for section header [section]
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
currentSection = strings.Trim(line, "[]")
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse key = value
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Remove quotes if present
|
||||
value = strings.Trim(value, `"`)
|
||||
|
||||
// Store as section.key
|
||||
fullKey := currentSection + "." + key
|
||||
result[fullKey] = value
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error scanning INI file: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Marshal is not implemented for INI parser (read-only).
|
||||
func (p *INI) Marshal(o map[string]interface{}) ([]byte, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
// Merge returns a merge function that constructs the URL from host/port fields
|
||||
// and extracts the API key from the INI configuration.
|
||||
// The INI parser will create nested keys like "misc.api_key", "misc.host", "misc.port".
|
||||
func (p *INI) Merge(baseURL string) func(src, dest map[string]interface{}) error {
|
||||
return func(src, dest map[string]interface{}) error {
|
||||
// Extract API key from misc.api_key
|
||||
if apiKey, ok := getStringFromMap(src, "misc.api_key"); ok && apiKey != "" {
|
||||
dest["api-key"] = apiKey
|
||||
}
|
||||
|
||||
// Extract host and port from misc section
|
||||
host, hostOk := getStringFromMap(src, "misc.host")
|
||||
port, portOk := getStringFromMap(src, "misc.port")
|
||||
enableHTTPS, httpsOk := getStringFromMap(src, "misc.enable_https")
|
||||
|
||||
// If we have host and port, construct/modify URL
|
||||
if hostOk && portOk && host != "" && port != "" {
|
||||
// Determine protocol based on enable_https
|
||||
protocol := "http"
|
||||
if httpsOk && enableHTTPS == "1" {
|
||||
protocol = "https"
|
||||
// Check if https_port is specified
|
||||
if httpsPort, ok := getStringFromMap(src, "misc.https_port"); ok && httpsPort != "" {
|
||||
port = httpsPort
|
||||
}
|
||||
}
|
||||
|
||||
// Handle IPv6 addresses (host might be "::" or "[::]")
|
||||
host = strings.TrimSpace(host)
|
||||
// Convert "::" or empty to localhost first, before IPv6 handling
|
||||
if host == "::" || host == "" {
|
||||
host = "localhost"
|
||||
} else if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
|
||||
// Wrap IPv6 addresses in brackets
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
|
||||
// Start with baseURL if provided, otherwise construct from scratch
|
||||
var u *url.URL
|
||||
var err error
|
||||
if baseURL != "" {
|
||||
u, err = url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse base URL: %w", err)
|
||||
}
|
||||
// Override host and port from INI
|
||||
u.Host = fmt.Sprintf("%s:%s", host, port)
|
||||
u.Scheme = protocol
|
||||
} else {
|
||||
// Construct URL from scratch
|
||||
constructedURL := fmt.Sprintf("%s://%s:%s", protocol, host, port)
|
||||
u, err = url.Parse(constructedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse constructed URL: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
dest["url"] = u.String()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// getStringFromMap retrieves a string value from a map, supporting both
|
||||
// flat dot-notation keys (e.g., "misc.api_key") and nested maps.
|
||||
func getStringFromMap(m map[string]interface{}, key string) (string, bool) {
|
||||
// First try direct access (koanf may flatten keys with dot notation)
|
||||
if val, exists := m[key]; exists {
|
||||
return convertToString(val), true
|
||||
}
|
||||
|
||||
// Fall back to nested map traversal
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var current interface{} = m
|
||||
for i, part := range parts {
|
||||
mm, ok := current.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
val, exists := mm[part]
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// If this is the last part, return the string value
|
||||
if i == len(parts)-1 {
|
||||
return convertToString(val), true
|
||||
}
|
||||
|
||||
current = val
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// convertToString converts various types to string.
|
||||
func convertToString(val interface{}) string {
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
return v
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user