diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..a4bcc60 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,32 @@ +name: Test Pipeline + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.23' + cache: true + + - name: Get dependencies + run: go mod download + + - name: Run tests + run: make test + + - name: Run linter + uses: golangci/golangci-lint-action@v3 + with: + version: latest + args: --timeout=1m diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6f2826f..4e30739 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,14 +20,6 @@ jobs: run: | GOOS=linux GOARCH=amd64 go build -o dockermon-linux-amd64 - # - name: Build for macOS (amd64) - # run: | - # GOOS=darwin GOARCH=amd64 go build ./cmd/dockermon -o dockermon-darwin-amd64 - - # - name: Build for macOS (arm64) - # run: | - # GOOS=darwin GOARCH=arm64 go build ./cmd/dockermon -o dockermon-darwin-arm64 - - name: Create Release id: create_release uses: softprops/action-gh-release@v1 diff --git a/Makefile b/Makefile index e206a80..80c4d53 100644 --- a/Makefile +++ b/Makefile @@ -23,9 +23,12 @@ vet: test: go test ./... +lint: + golangci-lint run + fuzz: go test -fuzz=FuzzParseConfig -fuzztime=5m ./internal/config re: clean all -.PHONY: all clean re fmt \ No newline at end of file +.PHONY: all clean re fmt lint \ No newline at end of file diff --git a/TODO.md b/TODO.md index f4860ed..42efc8a 100644 --- a/TODO.md +++ b/TODO.md @@ -9,6 +9,7 @@ - [x] Build docker event filter - [x] Resilience on events listening fail - [ ] Add more tests +- [ ] CI/CD Pipeline, no push on main (tests, fmt and static analysis) - [ ] One file installer - [ ] Event Type wildcard - [ ] Windows compatibility \ No newline at end of file diff --git a/configs/dockermon.conf b/configs/dockermon.conf index 7b0fb0e..0e2774e 100644 --- a/configs/dockermon.conf +++ b/configs/dockermon.conf @@ -9,3 +9,4 @@ container::*::5::'/usr/bin/slack_notify','info' container::die::::'/usr/bin/slack_notify','error' # timeout can be unset network::*::::'/usr/bin/stuff' +container:start::5::'/usr/bin/cmd' \ No newline at end of file diff --git a/go.mod b/go.mod index 927145f..4df9fca 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require github.com/docker/docker v27.4.1+incompatible require ( github.com/Microsoft/go-winio v0.4.14 // indirect github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -22,6 +23,8 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect go.opentelemetry.io/otel v1.33.0 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index dc256e8..7a76c97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,8 +60,8 @@ func (c *Config) Dump() { var t uint = 0 if cmd.Timeout != 0 { t = cmd.Timeout - } else if cmd.Timeout != 0 { - t = cmd.Timeout + } else if c.timeout != 0 { + t = c.timeout } if t != 0 { timeout += strconv.FormatUint(uint64(t), 10) @@ -69,7 +69,6 @@ func (c *Config) Dump() { fmt.Printf("%s%s%s%s%s%s%s\n", eventType, delimiter, action, delimiter, timeout, delimiter, strings.Join(cmd.Args, ",")) } } - return } func (c *Config) SetCmd(typ string, action string, comd *cmd.Cmd) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dacd49a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,159 @@ +package config + +import ( + "testing" + + "github.com/malletgaetan/dockermon/internal/cmd" + "github.com/stretchr/testify/assert" +) + +func TestSetCmd(t *testing.T) { + tests := []struct { + name string + typ string + action string + cmd *cmd.Cmd + validate func(*testing.T, *Config) + }{ + { + name: "set new type and action", + typ: "container", + action: "start", + cmd: &cmd.Cmd{ + Args: []string{"/usr/bin/notify"}, + Timeout: 5, + }, + validate: func(t *testing.T, c *Config) { + assert.Len(t, c.map_, 1) + assert.Len(t, c.map_["container"], 1) + assert.Equal(t, uint(5), c.map_["container"]["start"].Timeout) + assert.Equal(t, []string{"/usr/bin/notify"}, c.map_["container"]["start"].Args) + }, + }, + { + name: "override existing command", + typ: "container", + action: "start", + cmd: &cmd.Cmd{ + Args: []string{"/usr/bin/new-notify"}, + Timeout: 10, + }, + validate: func(t *testing.T, c *Config) { + assert.Len(t, c.map_, 1) + assert.Len(t, c.map_["container"], 1) + assert.Equal(t, uint(10), c.map_["container"]["start"].Timeout) + assert.Equal(t, []string{"/usr/bin/new-notify"}, c.map_["container"]["start"].Args) + }, + }, + { + name: "add new action to existing type", + typ: "container", + action: "stop", + cmd: &cmd.Cmd{ + Args: []string{"/usr/bin/stop-notify"}, + Timeout: 3, + }, + validate: func(t *testing.T, c *Config) { + assert.Len(t, c.map_, 1) + assert.Len(t, c.map_["container"], 2) + assert.Equal(t, uint(3), c.map_["container"]["stop"].Timeout) + assert.Equal(t, []string{"/usr/bin/stop-notify"}, c.map_["container"]["stop"].Args) + }, + }, + { + name: "set wildcard action", + typ: "network", + action: "*", + cmd: &cmd.Cmd{ + Args: []string{"/usr/bin/network-monitor"}, + Timeout: 0, + }, + validate: func(t *testing.T, c *Config) { + assert.Contains(t, c.map_, "network") + assert.Contains(t, c.map_["network"], "*") + assert.Equal(t, uint(0), c.map_["network"]["*"].Timeout) + assert.Equal(t, []string{"/usr/bin/network-monitor"}, c.map_["network"]["*"].Args) + }, + }, + } + + config := &Config{ + map_: make(map[string]map[string]*cmd.Cmd), + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config.SetCmd(tt.typ, tt.action, tt.cmd) + tt.validate(t, config) + }) + } +} + +func TestGetCmd(t *testing.T) { + config := &Config{ + map_: make(map[string]map[string]*cmd.Cmd), + } + + specificCmd := &cmd.Cmd{Args: []string{"/usr/bin/specific"}, Timeout: 5} + wildcardActionCmd := &cmd.Cmd{Args: []string{"/usr/bin/wildcard-action"}, Timeout: 3} + + config.SetCmd("container", "start", specificCmd) + config.SetCmd("container", "*", wildcardActionCmd) + + tests := []struct { + name string + typ string + action string + expectedCmd *cmd.Cmd + expectError bool + errorType error + }{ + { + name: "get specific command", + typ: "container", + action: "start", + expectedCmd: specificCmd, + expectError: false, + }, + { + name: "fallback to wildcard action", + typ: "container", + action: "stop", + expectedCmd: wildcardActionCmd, + expectError: false, + }, + { + name: "unknown type", + typ: "unknown", + action: "start", + expectedCmd: nil, + expectError: true, + errorType: ErrUnimplemented, + }, + { + name: "unknown action without wildcard", + typ: "network", + action: "connect", + expectedCmd: nil, + expectError: true, + errorType: ErrUnimplemented, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, err := config.GetCmd(tt.typ, tt.action) + + if tt.expectError { + assert.Error(t, err) + if tt.errorType != nil { + assert.ErrorIs(t, err, tt.errorType) + } + assert.Nil(t, cmd) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedCmd, cmd) + } + }) + } +} diff --git a/internal/config/error.go b/internal/config/error.go index eb5f248..e6a78c2 100644 --- a/internal/config/error.go +++ b/internal/config/error.go @@ -22,7 +22,6 @@ type Error struct { } func (e *Error) Error() string { - return e.context return e.err.Error() + ": " + e.context } diff --git a/internal/config/parser.go b/internal/config/parser.go index f2d43b7..0439803 100644 --- a/internal/config/parser.go +++ b/internal/config/parser.go @@ -195,7 +195,7 @@ func ParseConfig(scanner *bufio.Scanner, hints map[string][]string) (*Config, er } parser := &parser{ - pos: Position{row: 1, col: 1}, + pos: Position{row: 1}, config: config, scanner: scanner, hints: hints, diff --git a/internal/config/parser_test.go b/internal/config/parser_test.go index 60fd713..f5275ac 100644 --- a/internal/config/parser_test.go +++ b/internal/config/parser_test.go @@ -4,7 +4,11 @@ import ( "bufio" "bytes" "os" + "strings" "testing" + + "github.com/malletgaetan/dockermon/internal/cmd" + "github.com/stretchr/testify/assert" ) func FuzzParseConfig(f *testing.F) { @@ -16,7 +20,7 @@ func FuzzParseConfig(f *testing.F) { f.Add(corpusBytes) f.Fuzz(func(t *testing.T, data []byte) { - hints, _ := configVersion["1.47"] + hints := configVersion["1.47"] scanner := bufio.NewScanner(bytes.NewReader(data)) config, err := ParseConfig(scanner, hints) @@ -32,3 +36,169 @@ func FuzzParseConfig(f *testing.F) { } }) } + +func TestParseLine(t *testing.T) { + hints := map[string][]string{ + "container": {"start", "die"}, + "network": {"connect", "disconnect"}, + } + + tests := []struct { + name string + input string + expectedErr string + validate func(*testing.T, *Config) + }{ + { + name: "empty line", + input: "", + validate: func(t *testing.T, c *Config) { + assert.Empty(t, c.map_) + }, + }, + { + name: "comment line", + input: "# This is a comment", + validate: func(t *testing.T, c *Config) { + assert.Empty(t, c.map_) + }, + }, + { + name: "valid container start handler", + input: "container::start::5::'/usr/bin/slack_notify','info'", + validate: func(t *testing.T, c *Config) { + cmd := c.map_["container"]["start"] + assert.NotNil(t, cmd) + assert.Equal(t, uint(5), cmd.Timeout) + assert.Equal(t, []string{"/usr/bin/slack_notify", "info"}, cmd.Args) + }, + }, + { + name: "valid wildcard action handler", + input: "container::*::5::'/usr/bin/log_event'", + validate: func(t *testing.T, c *Config) { + cmd := c.map_["container"]["*"] + assert.NotNil(t, cmd) + assert.Equal(t, uint(5), cmd.Timeout) + assert.Equal(t, []string{"/usr/bin/log_event"}, cmd.Args) + }, + }, + { + name: "valid handler with empty timeout", + input: "network::*::::'/usr/bin/network_monitor'", + validate: func(t *testing.T, c *Config) { + cmd := c.map_["network"]["*"] + assert.NotNil(t, cmd) + assert.Equal(t, uint(0), cmd.Timeout) + assert.Equal(t, []string{"/usr/bin/network_monitor"}, cmd.Args) + }, + }, + { + name: "valid handler with escaped quotes", + input: "container::die::5::'/usr/bin/alert','\\'error\\''", + validate: func(t *testing.T, c *Config) { + cmd := c.map_["container"]["die"] + assert.NotNil(t, cmd) + assert.Equal(t, []string{"/usr/bin/alert", "'error'"}, cmd.Args) + }, + }, + { + name: "invalid type", + input: "invalid_type::start::5::'/usr/bin/cmd'", + expectedErr: "invalid type `invalid_type`", + }, + { + name: "invalid action", + input: "container::invalid_action::5::'/usr/bin/cmd'", + expectedErr: "invalid action `invalid_action`", + }, + { + name: "wildcard type", + input: "*::start::5::'/usr/bin/cmd'", + expectedErr: "type can't be wildcard", + }, + { + name: "missing action delimiter", + input: "container:start::5::'/usr/bin/cmd'", + expectedErr: "invalid type `container:start`", + }, + { + name: "missing timeout delimiter", + input: "container::start:5::'/usr/bin/cmd'", + expectedErr: "invalid action `start:5`", + }, + { + name: "invalid timeout value", + input: "container::start::abc::'/usr/bin/cmd'", + expectedErr: "strconv.ParseUint", + }, + { + name: "missing command argument start quote", + input: "container::start::5::/usr/bin/cmd'", + expectedErr: "no start delimiter found for command argument", + }, + { + name: "missing command argument end quote", + input: "container::start::5::'/usr/bin/cmd", + expectedErr: "no end delimiter found for command argument", + }, + { + name: "missing delimiter event action", + input: "container::start:5:'/usr/bin/cmd';'arg'", + expectedErr: "no delimiter found after event action", + }, + { + name: "missing delimiter event type", + input: "container:start:5:'/usr/bin/cmd';'arg'", + expectedErr: "no delimiter found after event type", + }, + { + name: "invalid argument delimiter", + input: "container::start::5::'/usr/bin/cmd';'arg'", + expectedErr: "expected delimiter after argument", + }, + { + name: "valid global timeout setting", + input: "timeout=30", + validate: func(t *testing.T, c *Config) { + assert.Equal(t, uint(30), c.timeout) + }, + }, + { + name: "invalid global setting", + input: "invalid=value", + expectedErr: "unknown global setting", + }, + { + name: "invalid global timeout value", + input: "timeout=abc", + expectedErr: "strconv.ParseUint", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scanner := bufio.NewScanner(strings.NewReader(tt.input)) + config := &Config{map_: make(map[string]map[string]*cmd.Cmd)} + + parser := &parser{ + scanner: scanner, + config: config, + hints: hints, + pos: Position{row: 1}, + } + + scanner.Scan() + err := parser.parseLine() + + if tt.expectedErr != "" { + assert.ErrorContains(t, err, tt.expectedErr) + } else { + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, config) + } + } + }) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index fecd54a..51ba4c9 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -17,8 +17,7 @@ func Initialize(cfg Config) { Level: cfg.Level, } - var handler slog.Handler - handler = slog.NewTextHandler(os.Stdout, opts) + handler := slog.NewTextHandler(os.Stdout, opts) Log = slog.New(handler) slog.SetDefault(Log) diff --git a/main.go b/main.go index cfda622..53e05ac 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ import ( "syscall" "time" - "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/events" "github.com/docker/docker/client" "github.com/malletgaetan/dockermon/internal/config" "github.com/malletgaetan/dockermon/internal/logger" @@ -56,7 +56,7 @@ func handleEvents(client *client.Client, conf *config.Config) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - msgs, errs := client.Events(ctx, types.EventsOptions{ + msgs, errs := client.Events(ctx, events.ListOptions{ Filters: conf.Filters(), }) @@ -80,8 +80,6 @@ func handleEvents(client *client.Client, conf *config.Config) error { go cmd.Execute(&wg, msg) } } - - return nil } func main() {