diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-05 14:17:59 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-05 14:17:59 +0200 |
| commit | 71c39ed9f8ce3563130cdfc5099e9d7df60845a9 (patch) | |
| tree | e610b8360f6364e3ab7c5e817ac94bd51cc0dfd1 /config | |
| parent | bef31e1d35fe88f2698cf0e3191f26a79623f66f (diff) | |
refactor: Replace env config with atomic YAML
Diffstat (limited to 'config')
| -rw-r--r-- | config/config.go | 120 | ||||
| -rw-r--r-- | config/config_test.go | 38 | ||||
| -rw-r--r-- | config/env.go | 52 |
3 files changed, 158 insertions, 52 deletions
diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..98ccebc --- /dev/null +++ b/config/config.go @@ -0,0 +1,120 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Database DatabaseConfig `yaml:"database"` + Server ServerConfig `yaml:"server"` + Storage StorageConfig `yaml:"storage"` + Strava StravaConfig `yaml:"strava"` +} + +type DatabaseConfig struct { + Path string `yaml:"path"` +} + +type ServerConfig struct { + Address string `yaml:"address"` + PublicURL string `yaml:"public_url"` +} + +type StorageConfig struct { + GPXDir string `yaml:"gpx_dir"` +} + +type StravaConfig struct { + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + AccessToken string `yaml:"access_token"` + RefreshToken string `yaml:"refresh_token"` + ExpiresAt int64 `yaml:"expires_at"` +} + +func Default() Config { + return Config{ + Database: DatabaseConfig{Path: "biking_home.db"}, + Server: ServerConfig{ + Address: "127.0.0.1:8080", + PublicURL: "http://localhost:8080", + }, + Storage: StorageConfig{GPXDir: "rides"}, + } +} + +func Load(filename string) (Config, error) { + data, err := os.ReadFile(filename) + if err != nil { + return Config{}, fmt.Errorf("read config %q: %w", filename, err) + } + config := Default() + if err := yaml.Unmarshal(data, &config); err != nil { + return Config{}, fmt.Errorf("parse config %q: %w", filename, err) + } + if config.Database.Path == "" { + return Config{}, fmt.Errorf("config %q: database.path is required", filename) + } + if config.Server.Address == "" || config.Server.PublicURL == "" { + return Config{}, fmt.Errorf("config %q: server.address and server.public_url are required", filename) + } + if config.Storage.GPXDir == "" { + return Config{}, fmt.Errorf("config %q: storage.gpx_dir is required", filename) + } + return config, nil +} + +func Save(filename string, config Config) error { + data, err := yaml.Marshal(config) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + directory := filepath.Dir(filename) + temporary, err := os.CreateTemp(directory, "."+filepath.Base(filename)+".tmp-*") + if err != nil { + return fmt.Errorf("create temporary config: %w", err) + } + temporaryName := temporary.Name() + removeTemporary := true + defer func() { + if removeTemporary { + _ = os.Remove(temporaryName) + } + }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return fmt.Errorf("set config permissions: %w", err) + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return fmt.Errorf("write temporary config: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync temporary config: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("close temporary config: %w", err) + } + if err := os.Rename(temporaryName, filename); err != nil { + return fmt.Errorf("replace config: %w", err) + } + removeTemporary = false + return syncDirectory(directory) +} + +func syncDirectory(directory string) error { + dir, err := os.Open(directory) + if err != nil { + return fmt.Errorf("open config directory: %w", err) + } + defer dir.Close() + if err := dir.Sync(); err != nil { + return fmt.Errorf("sync config directory: %w", err) + } + return nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..6b99030 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,38 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSaveAndLoad(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yaml") + want := Default() + want.Database.Path = "test.db" + want.Strava.ClientID = "123" + want.Strava.ExpiresAt = 1_700_000_000 + + require.NoError(t, Save(filename, want)) + got, err := Load(filename) + require.NoError(t, err) + assert.Equal(t, want, got) + info, err := os.Stat(filename) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestSaveReplacesExistingConfigAtomically(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, Save(filename, Default())) + updated := Default() + updated.Server.Address = "127.0.0.1:9090" + require.NoError(t, Save(filename, updated)) + + got, err := Load(filename) + require.NoError(t, err) + assert.Equal(t, "127.0.0.1:9090", got.Server.Address) +} diff --git a/config/env.go b/config/env.go deleted file mode 100644 index b9d9f76..0000000 --- a/config/env.go +++ /dev/null @@ -1,52 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strings" -) - -func LoadEnv(filename string) (map[string]string, error) { - data, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - values := map[string]string{} - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, value, found := strings.Cut(line, "=") - if found { - values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`) - } - } - return values, nil -} - -func UpdateEnv(filename string, updates map[string]string) error { - data, err := os.ReadFile(filename) - if err != nil { - return err - } - lines := strings.Split(string(data), "\n") - for key, value := range updates { - found := false - for i, line := range lines { - existingKey, _, cut := strings.Cut(strings.TrimSpace(line), "=") - if cut && strings.TrimSpace(existingKey) == key { - lines[i] = key + "=" + value - found = true - break - } - } - if !found { - lines = append(lines, key+"="+value) - } - } - if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { - return fmt.Errorf("write env file: %w", err) - } - return nil -} |