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:
ohayoun 2025-12-29 21:03:41 +02:00
parent 7a3db16074
commit 5d44eebf81
No known key found for this signature in database
GPG Key ID: 730DE846E69324B8
5 changed files with 528 additions and 8 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
# Project
/exportarr
*.xml
sabnzbd.ini
# Go
/vendor

View File

@ -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
}

View File

@ -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",
}
}

View 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)
}
})
}
}

View 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)
}
}