diff --git a/.gitignore b/.gitignore index e4468a6..c5425e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ -/config.yml /octoplex.log /mediamtx.yml diff --git a/config/config.go b/config/config.go index 733e1e7..9dcee0b 100644 --- a/config/config.go +++ b/config/config.go @@ -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 -} diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index 3de34e4..0000000 --- a/config/config_test.go +++ /dev/null @@ -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) -} diff --git a/config/service.go b/config/service.go new file mode 100644 index 0000000..8b13d08 --- /dev/null +++ b/config/service.go @@ -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 +} diff --git a/config/service_test.go b/config/service_test.go new file mode 100644 index 0000000..11e5347 --- /dev/null +++ b/config/service_test.go @@ -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 + } +} diff --git a/container/container.go b/container/container.go index 19a590f..aa397aa 100644 --- a/container/container.go +++ b/container/container.go @@ -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 { diff --git a/domain/constants.go b/domain/constants.go new file mode 100644 index 0000000..8149aca --- /dev/null +++ b/domain/constants.go @@ -0,0 +1,4 @@ +package domain + +// AppName is the name of the app. +const AppName = "octoplex" diff --git a/main.go b/main.go index b5e8fdd..2afd8cf 100644 --- a/main.go +++ b/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)