feat: config

This commit is contained in:
Rob Watson 2025-01-23 22:19:33 +01:00 committed by Rob Watson
parent 8c12683a3c
commit 99caa31f2e
12 changed files with 283 additions and 30 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/config.yml
/termstream.log /termstream.log

80
config/config.go Normal file
View File

@ -0,0 +1,80 @@
package config
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"gopkg.in/yaml.v3"
)
const defaultLogFile = "termstream.log"
// Destination holds the configuration for a destination.
type Destination struct {
URL string `yaml:"url"`
}
// Config holds the configuration for the application.
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
}
}
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
}

85
config/config_test.go Normal file
View File

@ -0,0 +1,85 @@
package config_test
import (
"bytes"
_ "embed"
"io"
"testing"
"git.netflux.io/rob/termstream/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/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{
{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, "termstream.log", cfg.LogFile)
},
},
{
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, "termstream.log", cfg.LogFile)
assert.Empty(t, cfg.Destinations)
}

4
config/testdata/complete.yml vendored Normal file
View File

@ -0,0 +1,4 @@
---
logfile: test.log
destinations:
- url: rtmp://rtmp.example.com:1935/live

View File

@ -0,0 +1,4 @@
---
logfile: test.log
destinations:
- url: http://nope.example.com:443/live

View File

@ -0,0 +1,4 @@
---
destinations:
- url: http://nope1.example.com:443/live
- url: http://nope2.example.com:443/live

3
config/testdata/no-logfile.yml vendored Normal file
View File

@ -0,0 +1,3 @@
---
destinations:
- url: rtmp://rtmp.example.com:1935/live

View File

@ -5,4 +5,10 @@ type AppState struct {
ContainerRunning bool ContainerRunning bool
IngressLive bool IngressLive bool
IngressURL string IngressURL string
Destinations []Destination
}
// Destination is a single destination.
type Destination struct {
URL string
} }

2
go.mod
View File

@ -8,6 +8,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@ -43,6 +44,5 @@ require (
golang.org/x/term v0.17.0 // indirect golang.org/x/term v0.17.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.9.0 // indirect golang.org/x/time v0.9.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.1 // indirect gotest.tools/v3 v3.5.1 // indirect
) )

31
main.go
View File

@ -3,9 +3,11 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"git.netflux.io/rob/termstream/config"
"git.netflux.io/rob/termstream/container" "git.netflux.io/rob/termstream/container"
"git.netflux.io/rob/termstream/domain" "git.netflux.io/rob/termstream/domain"
"git.netflux.io/rob/termstream/mediaserver" "git.netflux.io/rob/termstream/mediaserver"
@ -16,19 +18,26 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
if err := run(ctx); err != nil { if err := run(ctx, config.FromFile()); err != nil {
_, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n") _, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n")
} }
} }
func run(ctx context.Context) error { func run(ctx context.Context, cfgReader io.Reader) error {
cfg, err := config.Load(cfgReader)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
state := new(domain.AppState) state := new(domain.AppState)
applyConfig(cfg, state)
logFile, err := os.OpenFile("termstream.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) logFile, err := os.OpenFile("termstream.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil { if err != nil {
return fmt.Errorf("error opening log file: %w", err) return fmt.Errorf("error opening log file: %w", err)
} }
logger := slog.New(slog.NewTextHandler(logFile, nil)) logger := slog.New(slog.NewTextHandler(logFile, nil))
logger.Info("Starting termstream", slog.Any("initial_state", state))
runner, err := container.NewRunner(logger.With("component", "runner")) runner, err := container.NewRunner(logger.With("component", "runner"))
if err != nil { if err != nil {
@ -56,9 +65,12 @@ func run(ctx context.Context) error {
for { for {
select { select {
case <-ui.C(): case cmd, ok := <-ui.C():
logger.Info("UI closed") logger.Info("Command received", "cmd", cmd)
return nil if !ok {
logger.Info("UI closed")
return nil
}
case serverState, ok := <-srv.C(): case serverState, ok := <-srv.C():
if ok { if ok {
applyServerState(serverState, state) applyServerState(serverState, state)
@ -71,8 +83,17 @@ func run(ctx context.Context) error {
} }
} }
// applyServerState applies the current server state to the app state.
func applyServerState(serverState mediaserver.State, appState *domain.AppState) { func applyServerState(serverState mediaserver.State, appState *domain.AppState) {
appState.ContainerRunning = serverState.ContainerRunning appState.ContainerRunning = serverState.ContainerRunning
appState.IngressLive = serverState.IngressLive appState.IngressLive = serverState.IngressLive
appState.IngressURL = serverState.IngressURL appState.IngressURL = serverState.IngressURL
} }
// applyConfig applies the configuration to the app state.
func applyConfig(cfg config.Config, appState *domain.AppState) {
appState.Destinations = make([]domain.Destination, 0, len(cfg.Destinations))
for _, dest := range cfg.Destinations {
appState.Destinations = append(appState.Destinations, domain.Destination{URL: dest.URL})
}
}

View File

@ -11,19 +11,20 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
) )
const defaultChanSize = 64
type action func()
// Actor is responsible for managing the terminal user interface. // Actor is responsible for managing the terminal user interface.
type Actor struct { type Actor struct {
app *tview.Application app *tview.Application
ch chan action ch chan action
doneCh chan struct{} commandCh chan Command
logger *slog.Logger logger *slog.Logger
serverBox *tview.TextView serverBox *tview.TextView
destBox *tview.Table
} }
const defaultChanSize = 64
type action func()
// StartActorParams contains the parameters for starting a new terminal user // StartActorParams contains the parameters for starting a new terminal user
// interface. // interface.
type StartActorParams struct { type StartActorParams struct {
@ -34,6 +35,8 @@ type StartActorParams struct {
// StartActor starts the terminal user interface actor. // StartActor starts the terminal user interface actor.
func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) { func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
chanSize := cmp.Or(params.ChanSize, defaultChanSize) chanSize := cmp.Or(params.ChanSize, defaultChanSize)
ch := make(chan action, chanSize)
commandCh := make(chan Command, chanSize)
app := tview.NewApplication() app := tview.NewApplication()
serverBox := tview.NewTextView() serverBox := tview.NewTextView()
@ -42,9 +45,15 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
serverBox.SetTitle("media server") serverBox.SetTitle("media server")
serverBox.SetTextAlign(tview.AlignCenter) serverBox.SetTextAlign(tview.AlignCenter)
destBox := tview.NewBox(). destBox := tview.NewTable()
SetBorder(true). destBox.SetTitle("destinations")
SetTitle("destinations") destBox.SetBorder(true)
destBox.SetSelectable(true, false)
destBox.SetWrapSelection(true, false)
destBox.SetDoneFunc(func(key tcell.Key) {
row, _ := destBox.GetSelection()
commandCh <- CommandToggleDestination{URL: destBox.GetCell(row, 0).Text}
})
flex := tview.NewFlex(). flex := tview.NewFlex().
SetDirection(tview.FlexRow). SetDirection(tview.FlexRow).
@ -58,38 +67,43 @@ func StartActor(ctx context.Context, params StartActorParams) (*Actor, error) {
AddItem(nil, 0, 1, false) AddItem(nil, 0, 1, false)
app.SetRoot(container, true) app.SetRoot(container, true)
app.EnableMouse(true) app.SetFocus(destBox)
app.EnableMouse(false)
actor := &Actor{
ch: ch,
commandCh: commandCh,
logger: params.Logger,
app: app,
serverBox: serverBox,
destBox: destBox,
}
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyCtrlC { switch event.Key() {
case tcell.KeyCtrlC:
app.Stop() app.Stop()
return nil return nil
} }
return event return event
}) })
actor := &Actor{
ch: make(chan action, chanSize),
doneCh: make(chan struct{}, 1),
logger: params.Logger,
app: app,
serverBox: serverBox,
}
go actor.actorLoop(ctx) go actor.actorLoop(ctx)
return actor, nil return actor, nil
} }
// C returns a channel that is closed when the terminal user interface closes. // C returns a channel that receives commands from the user interface.
func (a *Actor) C() <-chan struct{} { func (a *Actor) C() <-chan Command {
return a.doneCh return a.commandCh
} }
func (a *Actor) actorLoop(ctx context.Context) { func (a *Actor) actorLoop(ctx context.Context) {
uiDone := make(chan struct{}) uiDone := make(chan struct{})
go func() { go func() {
defer close(uiDone) defer func() {
uiDone <- struct{}{}
}()
if err := a.app.Run(); err != nil { if err := a.app.Run(); err != nil {
a.logger.Error("tui application error", "err", err) a.logger.Error("tui application error", "err", err)
@ -101,7 +115,7 @@ func (a *Actor) actorLoop(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
a.logger.Info("Context done") a.logger.Info("Context done")
case <-uiDone: case <-uiDone:
a.doneCh <- struct{}{} close(a.commandCh)
case action, ok := <-a.ch: case action, ok := <-a.ch:
if !ok { if !ok {
return return
@ -120,6 +134,19 @@ func (a *Actor) SetState(state domain.AppState) {
func (a *Actor) redrawFromState(state domain.AppState) { func (a *Actor) redrawFromState(state domain.AppState) {
a.serverBox.SetText(generateServerStatus(state)) a.serverBox.SetText(generateServerStatus(state))
a.destBox.Clear()
a.destBox.SetCell(0, 0, tview.NewTableCell("[grey]URL").SetAlign(tview.AlignLeft).SetExpansion(7).SetSelectable(false))
a.destBox.SetCell(0, 1, tview.NewTableCell("[grey]Status").SetAlign(tview.AlignLeft).SetExpansion(1).SetSelectable(false))
a.destBox.SetCell(0, 2, tview.NewTableCell("[grey]Actions").SetAlign(tview.AlignLeft).SetExpansion(2).SetSelectable(false))
for i, dest := range state.Destinations {
a.destBox.SetCell(i+1, 0, tview.NewTableCell(dest.URL))
a.destBox.SetCell(i+1, 1, tview.NewTableCell("[yellow]off-air"))
a.destBox.SetCell(i+1, 2, tview.NewTableCell("[green]Tab to go live"))
}
a.app.Draw() a.app.Draw()
} }

18
terminal/command.go Normal file
View File

@ -0,0 +1,18 @@
package terminal
// CommandToggleDestination toggles a destination from on-air to off-air, or
// vice versa.
type CommandToggleDestination struct {
URL string
}
// Name implements the Command interface.
func (c CommandToggleDestination) Name() string {
return "toggle_destination"
}
// Command is an interface for commands that can be triggered by the terminal
// user interface.
type Command interface {
Name() string
}