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
|
/octoplex.log
|
||||||
/mediamtx.yml
|
/mediamtx.yml
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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.
|
// 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
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 (
|
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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user