feat(config): XDG paths

This commit is contained in:
Rob Watson 2025-02-25 21:51:14 +01:00
parent 69d964cf22
commit a6bc14ff99
8 changed files with 260 additions and 180 deletions

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
/config.yml
/octoplex.log /octoplex.log
/mediamtx.yml /mediamtx.yml

View File

@ -1,17 +1,8 @@
package config package config
import ( import "git.netflux.io/rob/octoplex/domain"
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"gopkg.in/yaml.v3" const defaultLogFile = domain.AppName + ".log"
)
const defaultLogFile = "octoplex.log"
// Destination holds the configuration for a destination. // Destination holds the configuration for a destination.
type Destination struct { type Destination struct {
@ -24,64 +15,3 @@ type Config struct {
LogFile string `yaml:"logfile"` LogFile string `yaml:"logfile"`
Destinations []Destination `yaml:"destinations"` Destinations []Destination `yaml:"destinations"`
} }
// FromFile returns a reader for the default configuration file.
func FromFile() io.Reader {
r, err := os.Open("config.yml")
if err != nil {
return bytes.NewReader([]byte{})
}
return r
}
// Default returns a reader for the default configuration.
func Default() io.Reader {
return bytes.NewReader([]byte(nil))
}
// Load loads the configuration from the given reader.
//
// Passing an empty reader will load the default configuration.
func Load(r io.Reader) (cfg Config, _ error) {
filePayload, err := io.ReadAll(r)
if err != nil {
return cfg, fmt.Errorf("read file: %w", err)
}
if err = yaml.Unmarshal(filePayload, &cfg); err != nil {
return cfg, fmt.Errorf("unmarshal: %w", err)
}
setDefaults(&cfg)
if err = validate(cfg); err != nil {
return cfg, err
}
return cfg, nil
}
func setDefaults(cfg *Config) {
if cfg.LogFile == "" {
cfg.LogFile = defaultLogFile
}
for i := range cfg.Destinations {
if strings.TrimSpace(cfg.Destinations[i].Name) == "" {
cfg.Destinations[i].Name = fmt.Sprintf("Stream %d", i+1)
}
}
}
func validate(cfg Config) error {
var err error
for _, dest := range cfg.Destinations {
if !strings.HasPrefix(dest.URL, "rtmp://") {
err = errors.Join(err, fmt.Errorf("destination URL must start with rtmp://"))
}
}
return err
}

View File

@ -1,98 +0,0 @@
package config_test
import (
"bytes"
_ "embed"
"io"
"testing"
"git.netflux.io/rob/octoplex/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
//go:embed testdata/complete.yml
var configComplete []byte
//go:embed testdata/no-logfile.yml
var configNoLogfile []byte
//go:embed testdata/no-name.yml
var configNoName []byte
//go:embed testdata/invalid-destination-url.yml
var configInvalidDestinationURL []byte
//go:embed testdata/multiple-invalid-destination-urls.yml
var configMultipleInvalidDestinationURLs []byte
func TestConfig(t *testing.T) {
testCases := []struct {
name string
r io.Reader
want func(*testing.T, config.Config)
wantErr string
}{
{
name: "complete",
r: bytes.NewReader(configComplete),
want: func(t *testing.T, cfg config.Config) {
require.Equal(
t,
config.Config{
LogFile: "test.log",
Destinations: []config.Destination{
{
Name: "my stream",
URL: "rtmp://rtmp.example.com:1935/live",
},
},
}, cfg)
},
},
{
name: "no logfile",
r: bytes.NewReader(configNoLogfile),
want: func(t *testing.T, cfg config.Config) {
assert.Equal(t, "octoplex.log", cfg.LogFile)
},
},
{
name: "no name",
r: bytes.NewReader(configNoName),
want: func(t *testing.T, cfg config.Config) {
assert.Equal(t, "Stream 1", cfg.Destinations[0].Name)
},
},
{
name: "invalid destination URL",
r: bytes.NewReader(configInvalidDestinationURL),
wantErr: "destination URL must start with rtmp://",
},
{
name: "multiple invalid destination URLs",
r: bytes.NewReader(configMultipleInvalidDestinationURLs),
wantErr: "destination URL must start with rtmp://\ndestination URL must start with rtmp://",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cfg, err := config.Load(tc.r)
if tc.wantErr == "" {
require.NoError(t, err)
tc.want(t, cfg)
} else {
assert.EqualError(t, err, tc.wantErr)
}
})
}
}
func TestConfigDefault(t *testing.T) {
cfg, err := config.Load(config.Default())
require.NoError(t, err)
assert.Equal(t, "octoplex.log", cfg.LogFile)
assert.Empty(t, cfg.Destinations)
}

116
config/service.go Normal file
View File

@ -0,0 +1,116 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.netflux.io/rob/octoplex/domain"
"gopkg.in/yaml.v3"
)
// Service provides configuration services.
type Service struct {
configDir string
}
// ConfigDirFunc is a function that returns the user configuration directory.
type ConfigDirFunc func() (string, error)
// NewDefaultService creates a new service with the default configuration file
// location.
func NewDefaultService() (*Service, error) {
return NewService(os.UserConfigDir)
}
// NewService creates a new service with provided ConfigDirFunc.
func NewService(configDirFunc ConfigDirFunc) (*Service, error) {
userConfigDir, err := configDirFunc()
if err != nil {
return nil, fmt.Errorf("user config dir: %w", err)
}
return &Service{
configDir: filepath.Join(userConfigDir, domain.AppName),
}, nil
}
// ReadOrCreateConfig reads the configuration from the file at the given path or
// creates it with default values.
func (s *Service) ReadOrCreateConfig() (cfg Config, _ error) {
if _, err := os.Stat(s.path()); os.IsNotExist(err) {
return s.createConfig()
} else if err != nil {
return cfg, fmt.Errorf("stat: %w", err)
}
return s.readConfig()
}
func (s *Service) readConfig() (cfg Config, _ error) {
contents, err := os.ReadFile(s.path())
if err != nil {
return cfg, fmt.Errorf("read file: %w", err)
}
if err = yaml.Unmarshal(contents, &cfg); err != nil {
return cfg, fmt.Errorf("unmarshal: %w", err)
}
setDefaults(&cfg)
if err = validate(cfg); err != nil {
return cfg, err
}
return cfg, nil
}
func (s *Service) createConfig() (cfg Config, _ error) {
if err := os.MkdirAll(s.configDir, 0744); err != nil {
return cfg, fmt.Errorf("mkdir: %w", err)
}
setDefaults(&cfg)
yamlBytes, err := yaml.Marshal(cfg)
if err != nil {
return cfg, fmt.Errorf("marshal: %w", err)
}
if err = os.WriteFile(s.path(), yamlBytes, 0644); err != nil {
return cfg, fmt.Errorf("write file: %w", err)
}
return cfg, nil
}
func (s *Service) path() string {
return filepath.Join(s.configDir, "config.yaml")
}
func setDefaults(cfg *Config) {
if cfg.LogFile == "" {
cfg.LogFile = defaultLogFile
}
for i := range cfg.Destinations {
if strings.TrimSpace(cfg.Destinations[i].Name) == "" {
cfg.Destinations[i].Name = fmt.Sprintf("Stream %d", i+1)
}
}
}
func validate(cfg Config) error {
var err error
for _, dest := range cfg.Destinations {
if !strings.HasPrefix(dest.URL, "rtmp://") {
err = errors.Join(err, fmt.Errorf("destination URL must start with rtmp://"))
}
}
return err
}

125
config/service_test.go Normal file
View File

@ -0,0 +1,125 @@
package config_test
import (
_ "embed"
"os"
"path/filepath"
"testing"
"git.netflux.io/rob/octoplex/config"
"git.netflux.io/rob/octoplex/shortid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
//go:embed testdata/complete.yml
var configComplete []byte
//go:embed testdata/no-logfile.yml
var configNoLogfile []byte
//go:embed testdata/no-name.yml
var configNoName []byte
//go:embed testdata/invalid-destination-url.yml
var configInvalidDestinationURL []byte
//go:embed testdata/multiple-invalid-destination-urls.yml
var configMultipleInvalidDestinationURLs []byte
func TestConfigServiceCreateConfig(t *testing.T) {
suffix := "read_or_create_" + shortid.New().String()
service, err := config.NewService(configDirFunc(suffix))
require.NoError(t, err)
cfg, err := service.ReadOrCreateConfig()
require.NoError(t, err)
require.Equal(t, "octoplex.log", cfg.LogFile)
p := filepath.Join(configDir(suffix), "config.yaml")
_, err = os.Stat(p)
require.NoError(t, err, "config file was not created")
}
func TestConfigServiceReadConfig(t *testing.T) {
testCases := []struct {
name string
configBytes []byte
want func(*testing.T, config.Config)
wantErr string
}{
{
name: "complete",
configBytes: configComplete,
want: func(t *testing.T, cfg config.Config) {
require.Equal(
t,
config.Config{
LogFile: "test.log",
Destinations: []config.Destination{
{
Name: "my stream",
URL: "rtmp://rtmp.example.com:1935/live",
},
},
}, cfg)
},
},
{
name: "no logfile",
configBytes: configNoLogfile,
want: func(t *testing.T, cfg config.Config) {
assert.Equal(t, "octoplex.log", cfg.LogFile)
},
},
{
name: "no name",
configBytes: configNoName,
want: func(t *testing.T, cfg config.Config) {
assert.Equal(t, "Stream 1", cfg.Destinations[0].Name)
},
},
{
name: "invalid destination URL",
configBytes: configInvalidDestinationURL,
wantErr: "destination URL must start with rtmp://",
},
{
name: "multiple invalid destination URLs",
configBytes: configMultipleInvalidDestinationURLs,
wantErr: "destination URL must start with rtmp://\ndestination URL must start with rtmp://",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
suffix := "read_or_create_" + shortid.New().String()
dir := configDir(suffix)
require.NoError(t, os.MkdirAll(dir, 0744))
configPath := filepath.Join(dir, "config.yaml")
require.NoError(t, os.WriteFile(configPath, tc.configBytes, 0644))
service, err := config.NewService(configDirFunc(suffix))
require.NoError(t, err)
cfg, err := service.ReadOrCreateConfig()
if tc.wantErr == "" {
require.NoError(t, err)
tc.want(t, cfg)
} else {
require.EqualError(t, err, tc.wantErr)
}
})
}
}
func configDir(suffix string) string {
return filepath.Join(os.TempDir(), "config_test_"+suffix, "octoplex")
}
func configDirFunc(suffix string) func() (string, error) {
return func() (string, error) {
return filepath.Join(os.TempDir(), "config_test_"+suffix), nil
}
}

View File

@ -63,7 +63,7 @@ type Client struct {
// NewClient creates a new Client. // NewClient creates a new Client.
func NewClient(ctx context.Context, apiClient DockerClient, logger *slog.Logger) (*Client, error) { func NewClient(ctx context.Context, apiClient DockerClient, logger *slog.Logger) (*Client, error) {
id := shortid.New() id := shortid.New()
network, err := apiClient.NetworkCreate(ctx, "octoplex-"+id.String(), network.CreateOptions{Driver: "bridge"}) network, err := apiClient.NetworkCreate(ctx, domain.AppName+"-"+id.String(), network.CreateOptions{Driver: "bridge"})
if err != nil { if err != nil {
return nil, fmt.Errorf("network create: %w", err) return nil, fmt.Errorf("network create: %w", err)
} }
@ -159,12 +159,12 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<
containerConfig := *params.ContainerConfig containerConfig := *params.ContainerConfig
containerConfig.Labels = make(map[string]string) containerConfig.Labels = make(map[string]string)
maps.Copy(containerConfig.Labels, params.ContainerConfig.Labels) maps.Copy(containerConfig.Labels, params.ContainerConfig.Labels)
containerConfig.Labels["app"] = "octoplex" containerConfig.Labels["app"] = domain.AppName
containerConfig.Labels["app-id"] = a.id.String() containerConfig.Labels["app-id"] = a.id.String()
var name string var name string
if params.Name != "" { if params.Name != "" {
name = "octoplex-" + a.id.String() + "-" + params.Name name = domain.AppName + "-" + a.id.String() + "-" + params.Name
} }
createResp, err := a.apiClient.ContainerCreate( createResp, err := a.apiClient.ContainerCreate(
@ -415,7 +415,7 @@ func (a *Client) RemoveContainers(ctx context.Context, labels map[string]string)
func (a *Client) containersMatchingLabels(ctx context.Context, labels map[string]string) ([]container.Summary, error) { func (a *Client) containersMatchingLabels(ctx context.Context, labels map[string]string) ([]container.Summary, error) {
filterArgs := filters.NewArgs( filterArgs := filters.NewArgs(
filters.Arg("label", "app=octoplex"), filters.Arg("label", "app="+domain.AppName),
filters.Arg("label", "app-id="+a.id.String()), filters.Arg("label", "app-id="+a.id.String()),
) )
for k, v := range labels { for k, v := range labels {

4
domain/constants.go Normal file
View File

@ -0,0 +1,4 @@
package domain
// AppName is the name of the app.
const AppName = "octoplex"

14
main.go
View File

@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
@ -17,15 +16,20 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
if err := run(ctx, config.FromFile()); err != nil { if err := run(ctx); err != nil {
_, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n") _, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n")
} }
} }
func run(ctx context.Context, cfgReader io.Reader) error { func run(ctx context.Context) error {
cfg, err := config.Load(cfgReader) configService, err := config.NewDefaultService()
if err != nil { if err != nil {
return fmt.Errorf("load config: %w", err) return fmt.Errorf("build config service: %w", err)
}
cfg, err := configService.ReadOrCreateConfig()
if err != nil {
return fmt.Errorf("read or create config: %w", err)
} }
logFile, err := os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) logFile, err := os.OpenFile(cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)