diff --git a/README.md b/README.md index 9a6ee3e..c7fd855 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,20 @@ metadata: secret: !env_var DISCOGS_API_KEY ``` -The application will fail to start if a referenced environment variable is not set. +Secrets can also be loaded from a file instead — handy for Docker/Kubernetes +secrets, which are mounted as files. Use the `!env_file` tag: the named +environment variable holds the *path* to the file, and the file's contents +become the value: + +```yaml +metadata: + providers: + acoustid: + secret: !env_file ACOUSTID_CLIENT_KEY_FILE # e.g. ACOUSTID_CLIENT_KEY_FILE=/run/secrets/acoustid_client_key +``` + +The application will fail to start if a referenced environment variable is not +set, or (for `!env_file`) if the file it points to can't be read or is empty. ### Build and/or run it diff --git a/config.example.yaml b/config.example.yaml index 33894d1..b6e8f96 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,10 +1,15 @@ # Soulsolid Configuration Example -# +# # Environment variables can be referenced using the !env_var tag: # token: !env_var TELEGRAM_BOT_TOKEN # -# Missing environment variables will cause startup errors. -# To simplify setup, secrets are commented out in config.example.yaml so you can copy it to config.yaml without requiring unused variables. +# Secrets can also be loaded from a file (Docker/Kubernetes secrets convention) +# using the !env_file tag: the named environment variable holds a *path*, and +# the file's contents become the value: +# token: !env_file TELEGRAM_BOT_TOKEN_FILE # TELEGRAM_BOT_TOKEN_FILE=/run/secrets/telegram_token +# +# Missing environment variables (or unreadable/empty files) will cause startup errors. +# To simplify setup, secrets are commented out in config.example.yaml so you can copy it to config.yaml without requiring unused variables. # If no config is found, Soulsolid generates a default one automatically. libraryPath: ./music @@ -59,11 +64,13 @@ metadata: acoustid: enabled: true # secret: !env_var ACOUSTID_CLIENT_KEY # You can login with Musicbrainz -> https://acoustid.org/new-application + # secret: !env_file ACOUSTID_CLIENT_KEY_FILE # Or load it from a file instead, e.g. a Docker secret deezer: enabled: true discogs: enabled: true # secret: !env_var DISCOGS_API_KEY # You can get it here -> https://www.discogs.com/settings/developers + # secret: !env_file DISCOGS_API_KEY_FILE # Or load it from a file instead, e.g. a Docker secret musicbrainz: enabled: true lyrics: diff --git a/docs/features/config.md b/docs/features/config.md index 1c1131a..59ff9f3 100644 --- a/docs/features/config.md +++ b/docs/features/config.md @@ -66,6 +66,26 @@ metadata: secret: !env_var DISCOGS_SECRET ``` +A scalar tagged `!env_file` is resolved the same way, except the named environment +variable is expected to hold a *path* rather than the secret itself — the file at +that path is read and its (trimmed) contents become the value. This follows the +Docker/Kubernetes secrets convention of mounting a secret as a file and pointing to +it with a `_FILE`-suffixed variable, so the secret value never has to touch the +process environment or shell history: + +```yaml +telegram: + token: !env_file TELEGRAM_TOKEN_FILE # TELEGRAM_TOKEN_FILE=/run/secrets/telegram_token +metadata: + providers: + acoustid: + secret: !env_file ACOUSTID_CLIENT_KEY_FILE +``` + +The app **fails to start** if the referenced variable is unset, the file can't be +read, or the file is empty. `!env_var` and `!env_file` can be mixed freely across +different fields. + After expansion the struct is validated with `go-playground/validator`; `libraryPath`, `downloadPath`, and `database.path` are required. diff --git a/src/features/config/manager.go b/src/features/config/manager.go index 27506db..096fe38 100644 --- a/src/features/config/manager.go +++ b/src/features/config/manager.go @@ -49,6 +49,40 @@ func (m *Manager) processEnvVarNodes(node *yaml.Node, path []string) error { return nil } + // Check if this node has a !env_file tag: the named environment variable + // holds a *path* to a file (Docker/Kubernetes secrets convention, e.g. + // ACOUSTID_CLIENT_KEY_FILE=/run/secrets/acoustid_client_key), and the + // file's contents become the value. + if node.Tag == "!env_file" { + if node.Kind != yaml.ScalarNode { + return fmt.Errorf("!env_file tag can only be used with scalar values (line %d)", node.Line) + } + + envVarName := strings.TrimSpace(node.Value) + if envVarName == "" { + return fmt.Errorf("!env_file tag requires environment variable name (line %d)", node.Line) + } + + filePath := os.Getenv(envVarName) + if filePath == "" { + return fmt.Errorf("environment variable %s is not set or empty (referenced at line %d)", envVarName, node.Line) + } + + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file %q referenced by %s (line %d): %w", filePath, envVarName, node.Line, err) + } + + value := strings.TrimSpace(string(content)) + if value == "" { + return fmt.Errorf("file %q referenced by %s is empty (line %d)", filePath, envVarName, node.Line) + } + + node.Tag = "" + node.Value = value + return nil + } + // Recursively process child nodes // Start with DocumentNode content if present if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { diff --git a/src/features/config/manager_test.go b/src/features/config/manager_test.go new file mode 100644 index 0000000..d59fd4b --- /dev/null +++ b/src/features/config/manager_test.go @@ -0,0 +1,75 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig_EnvVarAndEnvFileTags(t *testing.T) { + t.Setenv("SOULSOLID_TEST_TOKEN", "token-from-env") + + secretFile := filepath.Join(t.TempDir(), "acoustid_secret") + if err := os.WriteFile(secretFile, []byte("secret-from-file\n"), 0o600); err != nil { + t.Fatalf("failed to write secret file: %v", err) + } + t.Setenv("SOULSOLID_TEST_SECRET_FILE", secretFile) + + configYAML := ` +libraryPath: ./music +downloadPath: ./downloads +database: + path: ./library.db +telegram: + token: !env_var SOULSOLID_TEST_TOKEN +metadata: + providers: + acoustid: + enabled: true + secret: !env_file SOULSOLID_TEST_SECRET_FILE +` + configPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + m := &Manager{} + cfg, err := m.loadConfig(configPath) + if err != nil { + t.Fatalf("loadConfig returned error: %v", err) + } + + if cfg.Telegram.Token != "token-from-env" { + t.Errorf("expected telegram token %q, got %q", "token-from-env", cfg.Telegram.Token) + } + + acoustid, ok := cfg.Metadata.Providers["acoustid"] + if !ok { + t.Fatalf("expected acoustid provider to be present") + } + if acoustid.Secret == nil || *acoustid.Secret != "secret-from-file" { + t.Errorf("expected acoustid secret %q, got %v", "secret-from-file", acoustid.Secret) + } +} + +func TestLoadConfig_EnvFileMissingFile(t *testing.T) { + t.Setenv("SOULSOLID_TEST_MISSING_FILE", filepath.Join(t.TempDir(), "does-not-exist")) + + configYAML := ` +libraryPath: ./music +downloadPath: ./downloads +database: + path: ./library.db +telegram: + token: !env_file SOULSOLID_TEST_MISSING_FILE +` + configPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + m := &Manager{} + if _, err := m.loadConfig(configPath); err == nil { + t.Fatal("expected error when the file referenced by !env_file does not exist") + } +}