Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions docs/features/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
34 changes: 34 additions & 0 deletions src/features/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions src/features/config/manager_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading