feat(config): XDG paths
This commit is contained in:
parent
69d964cf22
commit
a6bc14ff99
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
/config.yml
|
||||
/octoplex.log
|
||||
/mediamtx.yml
|
||||
|
@ -1,17 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
import "git.netflux.io/rob/octoplex/domain"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultLogFile = "octoplex.log"
|
||||
const defaultLogFile = domain.AppName + ".log"
|
||||
|
||||
// Destination holds the configuration for a destination.
|
||||
type Destination struct {
|
||||
@ -24,64 +15,3 @@ type Config struct {
|
||||
LogFile string `yaml:"logfile"`
|
||||
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
|
||||
}
|
||||
|
@ -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
116
config/service.go
Normal 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
125
config/service_test.go
Normal 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
|
||||
}
|
||||
}
|
@ -63,7 +63,7 @@ type Client struct {
|
||||
// NewClient creates a new Client.
|
||||
func NewClient(ctx context.Context, apiClient DockerClient, logger *slog.Logger) (*Client, error) {
|
||||
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 {
|
||||
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.Labels = make(map[string]string)
|
||||
maps.Copy(containerConfig.Labels, params.ContainerConfig.Labels)
|
||||
containerConfig.Labels["app"] = "octoplex"
|
||||
containerConfig.Labels["app"] = domain.AppName
|
||||
containerConfig.Labels["app-id"] = a.id.String()
|
||||
|
||||
var name string
|
||||
if params.Name != "" {
|
||||
name = "octoplex-" + a.id.String() + "-" + params.Name
|
||||
name = domain.AppName + "-" + a.id.String() + "-" + params.Name
|
||||
}
|
||||
|
||||
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) {
|
||||
filterArgs := filters.NewArgs(
|
||||
filters.Arg("label", "app=octoplex"),
|
||||
filters.Arg("label", "app="+domain.AppName),
|
||||
filters.Arg("label", "app-id="+a.id.String()),
|
||||
)
|
||||
for k, v := range labels {
|
||||
|
4
domain/constants.go
Normal file
4
domain/constants.go
Normal file
@ -0,0 +1,4 @@
|
||||
package domain
|
||||
|
||||
// AppName is the name of the app.
|
||||
const AppName = "octoplex"
|
14
main.go
14
main.go
@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
@ -17,15 +16,20 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := run(ctx, config.FromFile()); err != nil {
|
||||
if err := run(ctx); err != nil {
|
||||
_, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfgReader io.Reader) error {
|
||||
cfg, err := config.Load(cfgReader)
|
||||
func run(ctx context.Context) error {
|
||||
configService, err := config.NewDefaultService()
|
||||
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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user