mirror of
https://github.com/amir20/dozzle.git
synced 2026-01-02 11:07:26 +01:00
chore(refactor): refactors go code (#2443)
This commit is contained in:
17
internal/docker/calculation.go
Normal file
17
internal/docker/calculation.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package docker
|
||||
|
||||
import "github.com/docker/docker/api/types"
|
||||
|
||||
func calculateMemUsageUnixNoCache(mem types.MemoryStats) float64 {
|
||||
// re implementation of the docker calculation
|
||||
// https://github.com/docker/cli/blob/53f8ed4bec07084db4208f55987a2ea94b7f01d6/cli/command/container/stats_helpers.go#L227-L249
|
||||
// cgroup v1
|
||||
if v, isCGroup := mem.Stats["total_inactive_file"]; isCGroup && v < mem.Usage {
|
||||
return float64(mem.Usage - v)
|
||||
}
|
||||
// cgroup v2
|
||||
if v := mem.Stats["inactive_file"]; v < mem.Usage {
|
||||
return float64(mem.Usage - v)
|
||||
}
|
||||
return float64(mem.Usage)
|
||||
}
|
||||
57
internal/docker/calculation_test.go
Normal file
57
internal/docker/calculation_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_calculateMemUsageUnixNoCache(t *testing.T) {
|
||||
type args struct {
|
||||
mem types.MemoryStats
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "with cgroup v1",
|
||||
args: args{
|
||||
mem: types.MemoryStats{
|
||||
Usage: 100,
|
||||
Stats: map[string]uint64{
|
||||
"total_inactive_file": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 99,
|
||||
},
|
||||
{
|
||||
name: "with cgroup v2",
|
||||
args: args{
|
||||
mem: types.MemoryStats{
|
||||
Usage: 100,
|
||||
Stats: map[string]uint64{
|
||||
"inactive_file": 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 98,
|
||||
},
|
||||
{
|
||||
name: "without cgroup",
|
||||
args: args{
|
||||
mem: types.MemoryStats{
|
||||
Usage: 100,
|
||||
},
|
||||
},
|
||||
want: 100,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, calculateMemUsageUnixNoCache(tt.args.mem), "calculateMemUsageUnixNoCache(%v)", tt.args.mem)
|
||||
})
|
||||
}
|
||||
}
|
||||
329
internal/docker/client.go
Normal file
329
internal/docker/client.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/events"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type StdType int
|
||||
|
||||
const (
|
||||
UNKNOWN StdType = 1 << iota
|
||||
STDOUT
|
||||
STDERR
|
||||
)
|
||||
const STDALL = STDOUT | STDERR
|
||||
|
||||
func (s StdType) String() string {
|
||||
switch s {
|
||||
case STDOUT:
|
||||
return "stdout"
|
||||
case STDERR:
|
||||
return "stderr"
|
||||
case STDALL:
|
||||
return "all"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
type DockerCLI interface {
|
||||
ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error)
|
||||
ContainerLogs(context.Context, string, types.ContainerLogsOptions) (io.ReadCloser, error)
|
||||
Events(context.Context, types.EventsOptions) (<-chan events.Message, <-chan error)
|
||||
ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error)
|
||||
ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error)
|
||||
Ping(ctx context.Context) (types.Ping, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cli DockerCLI
|
||||
filters filters.Args
|
||||
host *Host
|
||||
}
|
||||
|
||||
func NewClient(cli DockerCLI, filters filters.Args, host *Host) *Client {
|
||||
return &Client{cli, filters, host}
|
||||
}
|
||||
|
||||
// NewClientWithFilters creates a new instance of Client with docker filters
|
||||
func NewClientWithFilters(f map[string][]string) (*Client, error) {
|
||||
filterArgs := filters.NewArgs()
|
||||
for key, values := range f {
|
||||
for _, value := range values {
|
||||
filterArgs.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("filterArgs = %v", filterArgs)
|
||||
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewClient(cli, filterArgs, &Host{Name: "localhost", ID: "localhost"}), nil
|
||||
}
|
||||
|
||||
func NewClientWithTlsAndFilter(f map[string][]string, host Host) (*Client, error) {
|
||||
filterArgs := filters.NewArgs()
|
||||
for key, values := range f {
|
||||
for _, value := range values {
|
||||
filterArgs.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("filterArgs = %v", filterArgs)
|
||||
|
||||
if host.URL.Scheme != "tcp" {
|
||||
log.Fatal("Only tcp scheme is supported")
|
||||
}
|
||||
|
||||
opts := []client.Opt{
|
||||
client.WithHost(host.URL.String()),
|
||||
}
|
||||
|
||||
if host.ValidCerts {
|
||||
log.Debugf("Using TLS client config with certs at: %s", filepath.Dir(host.CertPath))
|
||||
opts = append(opts, client.WithTLSClientConfig(host.CACertPath, host.CertPath, host.KeyPath))
|
||||
} else {
|
||||
log.Debugf("No valid certs found, using plain TCP")
|
||||
}
|
||||
|
||||
opts = append(opts, client.WithAPIVersionNegotiation())
|
||||
|
||||
cli, err := client.NewClientWithOpts(opts...)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewClient(cli, filterArgs, &host), nil
|
||||
}
|
||||
|
||||
func (d *Client) FindContainer(id string) (Container, error) {
|
||||
var container Container
|
||||
containers, err := d.ListContainers()
|
||||
if err != nil {
|
||||
return container, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, c := range containers {
|
||||
if c.ID == id {
|
||||
container = c
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return container, fmt.Errorf("unable to find container with id: %s", id)
|
||||
}
|
||||
|
||||
if json, err := d.cli.ContainerInspect(context.Background(), container.ID); err == nil {
|
||||
container.Tty = json.Config.Tty
|
||||
} else {
|
||||
return container, err
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
func (d *Client) ListContainers() ([]Container, error) {
|
||||
containerListOptions := types.ContainerListOptions{
|
||||
Filters: d.filters,
|
||||
All: true,
|
||||
}
|
||||
list, err := d.cli.ContainerList(context.Background(), containerListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var containers = make([]Container, 0, len(list))
|
||||
for _, c := range list {
|
||||
name := "no name"
|
||||
if len(c.Names) > 0 {
|
||||
name = strings.TrimPrefix(c.Names[0], "/")
|
||||
}
|
||||
container := Container{
|
||||
ID: c.ID[:12],
|
||||
Names: c.Names,
|
||||
Name: name,
|
||||
Image: c.Image,
|
||||
ImageID: c.ImageID,
|
||||
Command: c.Command,
|
||||
Created: c.Created,
|
||||
State: c.State,
|
||||
Status: c.Status,
|
||||
Host: d.host.ID,
|
||||
Health: findBetweenParentheses(c.Status),
|
||||
}
|
||||
containers = append(containers, container)
|
||||
}
|
||||
|
||||
sort.Slice(containers, func(i, j int) bool {
|
||||
return strings.ToLower(containers[i].Name) < strings.ToLower(containers[j].Name)
|
||||
})
|
||||
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func (d *Client) ContainerStats(ctx context.Context, id string, stats chan<- ContainerStat) error {
|
||||
response, err := d.cli.ContainerStats(ctx, id, true)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Debugf("starting to stream stats for: %s", id)
|
||||
defer response.Body.Close()
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
var v *types.StatsJSON
|
||||
for {
|
||||
if err := decoder.Decode(&v); err != nil {
|
||||
if err == context.Canceled || err == io.EOF {
|
||||
log.Debugf("stopping stats streaming for container %s", id)
|
||||
return
|
||||
}
|
||||
log.Errorf("decoder for stats api returned an unknown error %v", err)
|
||||
}
|
||||
|
||||
ncpus := uint8(v.CPUStats.OnlineCPUs)
|
||||
if ncpus == 0 {
|
||||
ncpus = uint8(len(v.CPUStats.CPUUsage.PercpuUsage))
|
||||
}
|
||||
|
||||
var (
|
||||
cpuDelta = float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage)
|
||||
systemDelta = float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage)
|
||||
cpuPercent = int64((cpuDelta / systemDelta) * float64(ncpus) * 100)
|
||||
memUsage = int64(calculateMemUsageUnixNoCache(v.MemoryStats))
|
||||
memPercent = int64(float64(memUsage) / float64(v.MemoryStats.Limit) * 100)
|
||||
)
|
||||
|
||||
if cpuPercent > 0 || memUsage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case stats <- ContainerStat{
|
||||
ID: id,
|
||||
CPUPercent: cpuPercent,
|
||||
MemoryPercent: memPercent,
|
||||
MemoryUsage: memUsage,
|
||||
}:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Client) ContainerLogs(ctx context.Context, id string, since string, stdType StdType) (io.ReadCloser, error) {
|
||||
log.WithField("id", id).WithField("since", since).WithField("stdType", stdType).Debug("streaming logs for container")
|
||||
|
||||
if since != "" {
|
||||
if millis, err := strconv.ParseInt(since, 10, 64); err == nil {
|
||||
since = time.UnixMicro(millis).Add(time.Millisecond).Format(time.RFC3339Nano)
|
||||
} else {
|
||||
log.WithError(err).Debug("unable to parse since")
|
||||
}
|
||||
}
|
||||
|
||||
options := types.ContainerLogsOptions{
|
||||
ShowStdout: stdType&STDOUT != 0,
|
||||
ShowStderr: stdType&STDERR != 0,
|
||||
Follow: true,
|
||||
Tail: "300",
|
||||
Timestamps: true,
|
||||
Since: since,
|
||||
}
|
||||
|
||||
reader, err := d.cli.ContainerLogs(ctx, id, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
func (d *Client) Events(ctx context.Context, messages chan<- ContainerEvent) <-chan error {
|
||||
dockerMessages, errors := d.cli.Events(ctx, types.EventsOptions{})
|
||||
|
||||
go func() {
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case message, ok := <-dockerMessages:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if message.Type == "container" && len(message.Actor.ID) > 0 {
|
||||
messages <- ContainerEvent{
|
||||
ActorID: message.Actor.ID[:12],
|
||||
Name: message.Action,
|
||||
Host: d.host.ID,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func (d *Client) ContainerLogsBetweenDates(ctx context.Context, id string, from time.Time, to time.Time, stdType StdType) (io.ReadCloser, error) {
|
||||
options := types.ContainerLogsOptions{
|
||||
ShowStdout: stdType&STDOUT != 0,
|
||||
ShowStderr: stdType&STDERR != 0,
|
||||
Timestamps: true,
|
||||
Since: from.Format(time.RFC3339),
|
||||
Until: to.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
log.Debugf("fetching logs from Docker with option: %+v", options)
|
||||
|
||||
reader, err := d.cli.ContainerLogs(ctx, id, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
func (d *Client) Ping(ctx context.Context) (types.Ping, error) {
|
||||
return d.cli.Ping(ctx)
|
||||
}
|
||||
|
||||
func (d *Client) Host() *Host {
|
||||
return d.host
|
||||
}
|
||||
|
||||
var PARENTHESIS_RE = regexp.MustCompile(`\(([a-zA-Z]+)\)`)
|
||||
|
||||
func findBetweenParentheses(s string) string {
|
||||
if results := PARENTHESIS_RE.FindStringSubmatch(s); results != nil {
|
||||
return results[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
204
internal/docker/client_test.go
Normal file
204
internal/docker/client_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockedProxy struct {
|
||||
mock.Mock
|
||||
DockerCLI
|
||||
}
|
||||
|
||||
func (m *mockedProxy) ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error) {
|
||||
args := m.Called()
|
||||
containers, ok := args.Get(0).([]types.Container)
|
||||
if !ok && args.Get(0) != nil {
|
||||
panic("containers is not of type []types.Container")
|
||||
}
|
||||
return containers, args.Error(1)
|
||||
|
||||
}
|
||||
|
||||
func (m *mockedProxy) ContainerLogs(ctx context.Context, id string, options types.ContainerLogsOptions) (io.ReadCloser, error) {
|
||||
args := m.Called(ctx, id, options)
|
||||
reader, ok := args.Get(0).(io.ReadCloser)
|
||||
if !ok && args.Get(0) != nil {
|
||||
panic("reader is not of type io.ReadCloser")
|
||||
}
|
||||
return reader, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockedProxy) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) {
|
||||
args := m.Called(ctx, containerID)
|
||||
return args.Get(0).(types.ContainerJSON), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockedProxy) ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error) {
|
||||
return types.ContainerStats{}, nil
|
||||
}
|
||||
|
||||
func Test_dockerClient_ListContainers_null(t *testing.T) {
|
||||
proxy := new(mockedProxy)
|
||||
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, nil)
|
||||
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
|
||||
|
||||
list, err := client.ListContainers()
|
||||
assert.Empty(t, list, "list should be empty")
|
||||
require.NoError(t, err, "error should not return an error.")
|
||||
|
||||
proxy.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func Test_dockerClient_ListContainers_error(t *testing.T) {
|
||||
proxy := new(mockedProxy)
|
||||
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, errors.New("test"))
|
||||
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
|
||||
|
||||
list, err := client.ListContainers()
|
||||
assert.Nil(t, list, "list should be nil")
|
||||
require.Error(t, err, "test.")
|
||||
|
||||
proxy.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func Test_dockerClient_ListContainers_happy(t *testing.T) {
|
||||
containers := []types.Container{
|
||||
{
|
||||
ID: "abcdefghijklmnopqrst",
|
||||
Names: []string{"/z_test_container"},
|
||||
},
|
||||
{
|
||||
ID: "1234567890_abcxyzdef",
|
||||
Names: []string{"/a_test_container"},
|
||||
},
|
||||
}
|
||||
|
||||
proxy := new(mockedProxy)
|
||||
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
|
||||
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
|
||||
|
||||
list, err := client.ListContainers()
|
||||
require.NoError(t, err, "error should not return an error.")
|
||||
|
||||
assert.Equal(t, list, []Container{
|
||||
{
|
||||
ID: "1234567890_a",
|
||||
Name: "a_test_container",
|
||||
Names: []string{"/a_test_container"},
|
||||
Host: "localhost",
|
||||
},
|
||||
{
|
||||
ID: "abcdefghijkl",
|
||||
Name: "z_test_container",
|
||||
Names: []string{"/z_test_container"},
|
||||
Host: "localhost",
|
||||
},
|
||||
})
|
||||
|
||||
proxy.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func Test_dockerClient_ContainerLogs_happy(t *testing.T) {
|
||||
id := "123456"
|
||||
|
||||
proxy := new(mockedProxy)
|
||||
expected := "INFO Testing logs..."
|
||||
b := make([]byte, 8)
|
||||
|
||||
binary.BigEndian.PutUint32(b[4:], uint32(len(expected)))
|
||||
b = append(b, []byte(expected)...)
|
||||
|
||||
reader := io.NopCloser(bytes.NewReader(b))
|
||||
options := types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "300", Timestamps: true, Since: "since"}
|
||||
proxy.On("ContainerLogs", mock.Anything, id, options).Return(reader, nil)
|
||||
|
||||
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
|
||||
logReader, _ := client.ContainerLogs(context.Background(), id, "since", STDALL)
|
||||
|
||||
actual, _ := io.ReadAll(logReader)
|
||||
assert.Equal(t, string(b), string(actual), "message doesn't match expected")
|
||||
proxy.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func Test_dockerClient_ContainerLogs_error(t *testing.T) {
|
||||
id := "123456"
|
||||
proxy := new(mockedProxy)
|
||||
|
||||
proxy.On("ContainerLogs", mock.Anything, id, mock.Anything).Return(nil, errors.New("test"))
|
||||
|
||||
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
|
||||
|
||||
reader, err := client.ContainerLogs(context.Background(), id, "", STDALL)
|
||||
|
||||
assert.Nil(t, reader, "reader should be nil")
|
||||
assert.Error(t, err, "error should have been returned")
|
||||
proxy.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func Test_dockerClient_FindContainer_happy(t *testing.T) {
|
||||
containers := []types.Container{
|
||||
{
|
||||
ID: "abcdefghijklmnopqrst",
|
||||
Names: []string{"/z_test_container"},
|
||||
},
|
||||
{
|
||||
ID: "1234567890_abcxyzdef",
|
||||
Names: []string{"/a_test_container"},
|
||||
},
|
||||
}
|
||||
|
||||
proxy := new(mockedProxy)
|
||||
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
|
||||
|
||||
json := types.ContainerJSON{Config: &container.Config{Tty: false}}
|
||||
proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil)
|
||||
|
||||
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
|
||||
|
||||
container, err := client.FindContainer("abcdefghijkl")
|
||||
require.NoError(t, err, "error should not be thrown")
|
||||
|
||||
assert.Equal(t, container, Container{
|
||||
ID: "abcdefghijkl",
|
||||
Name: "z_test_container",
|
||||
Names: []string{"/z_test_container"},
|
||||
Host: "localhost",
|
||||
Tty: false,
|
||||
})
|
||||
|
||||
proxy.AssertExpectations(t)
|
||||
}
|
||||
func Test_dockerClient_FindContainer_error(t *testing.T) {
|
||||
containers := []types.Container{
|
||||
{
|
||||
ID: "abcdefghijklmnopqrst",
|
||||
Names: []string{"/z_test_container"},
|
||||
},
|
||||
{
|
||||
ID: "1234567890_abcxyzdef",
|
||||
Names: []string{"/a_test_container"},
|
||||
},
|
||||
}
|
||||
|
||||
proxy := new(mockedProxy)
|
||||
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
|
||||
client := &Client{proxy, filters.NewArgs(), &Host{ID: "localhost"}}
|
||||
|
||||
_, err := client.FindContainer("not_valid")
|
||||
require.Error(t, err, "error should be thrown")
|
||||
|
||||
proxy.AssertExpectations(t)
|
||||
}
|
||||
201
internal/docker/event_generator.go
Normal file
201
internal/docker/event_generator.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type EventGenerator struct {
|
||||
Events chan *LogEvent
|
||||
Errors chan error
|
||||
reader *bufio.Reader
|
||||
next *LogEvent
|
||||
buffer chan *LogEvent
|
||||
tty bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
var ErrBadHeader = fmt.Errorf("dozzle/docker: unable to read header")
|
||||
|
||||
func NewEventGenerator(reader io.Reader, tty bool) *EventGenerator {
|
||||
generator := &EventGenerator{
|
||||
reader: bufio.NewReader(reader),
|
||||
buffer: make(chan *LogEvent, 100),
|
||||
Errors: make(chan error, 1),
|
||||
Events: make(chan *LogEvent),
|
||||
tty: tty,
|
||||
}
|
||||
generator.wg.Add(2)
|
||||
go generator.consumeReader()
|
||||
go generator.processBuffer()
|
||||
return generator
|
||||
}
|
||||
|
||||
func (g *EventGenerator) processBuffer() {
|
||||
var current, next *LogEvent
|
||||
|
||||
for {
|
||||
if g.next != nil {
|
||||
current = g.next
|
||||
g.next = nil
|
||||
next = g.peek()
|
||||
} else {
|
||||
event, ok := <-g.buffer
|
||||
if !ok {
|
||||
close(g.Events)
|
||||
break
|
||||
}
|
||||
|
||||
current = event
|
||||
next = g.peek()
|
||||
}
|
||||
|
||||
checkPosition(current, next)
|
||||
|
||||
g.Events <- current
|
||||
}
|
||||
g.wg.Done()
|
||||
}
|
||||
|
||||
func (g *EventGenerator) consumeReader() {
|
||||
for {
|
||||
message, streamType, readerError := readEvent(g.reader, g.tty)
|
||||
if message != "" {
|
||||
logEvent := createEvent(message, streamType)
|
||||
logEvent.Level = guessLogLevel(logEvent)
|
||||
g.buffer <- logEvent
|
||||
}
|
||||
|
||||
if readerError != nil {
|
||||
if readerError != ErrBadHeader {
|
||||
log.Debugf("reader error: %v", readerError)
|
||||
g.Errors <- readerError
|
||||
close(g.buffer)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
g.wg.Done()
|
||||
}
|
||||
|
||||
func (g *EventGenerator) peek() *LogEvent {
|
||||
if g.next != nil {
|
||||
return g.next
|
||||
}
|
||||
select {
|
||||
case event := <-g.buffer:
|
||||
g.next = event
|
||||
return g.next
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readEvent(reader *bufio.Reader, tty bool) (string, StdType, error) {
|
||||
header := []byte{0, 0, 0, 0, 0, 0, 0, 0}
|
||||
buffer := bufPool.Get().(*bytes.Buffer)
|
||||
buffer.Reset()
|
||||
defer bufPool.Put(buffer)
|
||||
var streamType StdType = STDOUT
|
||||
if tty {
|
||||
message, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return message, streamType, err
|
||||
}
|
||||
return message, streamType, nil
|
||||
} else {
|
||||
n, err := io.ReadFull(reader, header)
|
||||
if err != nil {
|
||||
return "", streamType, err
|
||||
}
|
||||
if n != 8 {
|
||||
log.Warnf("unable to read header: %v", header)
|
||||
message, _ := reader.ReadString('\n')
|
||||
return message, streamType, ErrBadHeader
|
||||
}
|
||||
|
||||
switch header[0] {
|
||||
case 1:
|
||||
streamType = STDOUT
|
||||
case 2:
|
||||
streamType = STDERR
|
||||
default:
|
||||
log.Warnf("unknown stream type: %v", header[0])
|
||||
}
|
||||
|
||||
count := binary.BigEndian.Uint32(header[4:])
|
||||
if count == 0 {
|
||||
return "", streamType, nil
|
||||
}
|
||||
_, err = io.CopyN(buffer, reader, int64(count))
|
||||
if err != nil {
|
||||
return "", streamType, err
|
||||
}
|
||||
return buffer.String(), streamType, nil
|
||||
}
|
||||
}
|
||||
|
||||
func createEvent(message string, streamType StdType) *LogEvent {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(message))
|
||||
logEvent := &LogEvent{Id: h.Sum32(), Message: message, Stream: streamType.String()}
|
||||
if index := strings.IndexAny(message, " "); index != -1 {
|
||||
logId := message[:index]
|
||||
if timestamp, err := time.Parse(time.RFC3339Nano, logId); err == nil {
|
||||
logEvent.Timestamp = timestamp.UnixMilli()
|
||||
message = strings.TrimSuffix(message[index+1:], "\n")
|
||||
logEvent.Message = message
|
||||
if json.Valid([]byte(message)) {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(message), &data); err != nil {
|
||||
log.Warnf("unable to parse json logs - error was \"%v\" while trying unmarshal \"%v\"", err.Error(), message)
|
||||
} else {
|
||||
logEvent.Message = data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return logEvent
|
||||
}
|
||||
|
||||
func checkPosition(currentEvent *LogEvent, nextEvent *LogEvent) {
|
||||
currentLevel := guessLogLevel(currentEvent)
|
||||
if nextEvent != nil {
|
||||
if currentEvent.IsCloseToTime(nextEvent) && currentLevel != "" && !nextEvent.HasLevel() {
|
||||
currentEvent.Position = START
|
||||
nextEvent.Position = MIDDLE
|
||||
}
|
||||
|
||||
// If next item is not close to current item or has level, set current item position to end
|
||||
if currentEvent.Position == MIDDLE && (nextEvent.HasLevel() || !currentEvent.IsCloseToTime(nextEvent)) {
|
||||
currentEvent.Position = END
|
||||
}
|
||||
|
||||
// If next item is close to current item and has no level, set next item position to middle
|
||||
if currentEvent.Position == MIDDLE && !nextEvent.HasLevel() && currentEvent.IsCloseToTime(nextEvent) {
|
||||
nextEvent.Position = MIDDLE
|
||||
}
|
||||
// Set next item level to current item level
|
||||
if currentEvent.Position == START || currentEvent.Position == MIDDLE {
|
||||
nextEvent.Level = currentEvent.Level
|
||||
}
|
||||
} else if currentEvent.Position == MIDDLE {
|
||||
currentEvent.Position = END
|
||||
}
|
||||
}
|
||||
149
internal/docker/event_generator_test.go
Normal file
149
internal/docker/event_generator_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEventGenerator_Events_tty(t *testing.T) {
|
||||
input := "example input"
|
||||
reader := bufio.NewReader(strings.NewReader(input))
|
||||
|
||||
g := NewEventGenerator(reader, true)
|
||||
event := <-g.Events
|
||||
|
||||
require.NotNil(t, event, "Expected event to not be nil, but got nil")
|
||||
assert.Equal(t, input, event.Message)
|
||||
}
|
||||
|
||||
func TestEventGenerator_Events_non_tty(t *testing.T) {
|
||||
input := "example input"
|
||||
reader := bytes.NewReader(makeMessage(input, STDOUT))
|
||||
|
||||
g := NewEventGenerator(reader, false)
|
||||
event := <-g.Events
|
||||
|
||||
require.NotNil(t, event, "Expected event to not be nil, but got nil")
|
||||
assert.Equal(t, input, event.Message)
|
||||
}
|
||||
|
||||
func TestEventGenerator_Events_non_tty_close_channel(t *testing.T) {
|
||||
input := "example input"
|
||||
reader := bytes.NewReader(makeMessage(input, STDOUT))
|
||||
|
||||
g := NewEventGenerator(reader, false)
|
||||
<-g.Events
|
||||
_, ok := <-g.Events
|
||||
|
||||
assert.False(t, ok, "Expected channel to be closed")
|
||||
}
|
||||
|
||||
func TestEventGenerator_Events_routines_done(t *testing.T) {
|
||||
input := "example input"
|
||||
reader := bytes.NewReader(makeMessage(input, STDOUT))
|
||||
|
||||
g := NewEventGenerator(reader, false)
|
||||
<-g.Events
|
||||
assert.False(t, waitTimeout(&g.wg, 1*time.Second), "Expected routines to be done")
|
||||
}
|
||||
|
||||
func makeMessage(message string, stream StdType) []byte {
|
||||
data := make([]byte, 8)
|
||||
binary.BigEndian.PutUint32(data[4:], uint32(len(message)))
|
||||
data[0] = byte(stream / 2)
|
||||
data = append(data, []byte(message)...)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
|
||||
c := make(chan struct{})
|
||||
go func() {
|
||||
defer close(c)
|
||||
wg.Wait()
|
||||
}()
|
||||
select {
|
||||
case <-c:
|
||||
return false // completed normally
|
||||
case <-time.After(timeout):
|
||||
return true // timed out
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createEvent(t *testing.T) {
|
||||
type args struct {
|
||||
message string
|
||||
streamType StdType
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *LogEvent
|
||||
}{
|
||||
{
|
||||
name: "empty message",
|
||||
args: args{
|
||||
message: "",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: "",
|
||||
},
|
||||
}, {
|
||||
name: "simple json message",
|
||||
args: args{
|
||||
message: "2020-05-13T18:55:37.772853839Z {\"key\": \"value\"}",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid json message",
|
||||
args: args{
|
||||
message: "2020-05-13T18:55:37.772853839Z {\"key\"}",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: "{\"key\"}",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := createEvent(tt.args.message, STDOUT); !reflect.DeepEqual(got.Message, tt.want.Message) {
|
||||
t.Errorf("createEvent() = %v, want %v", got.Message, tt.want.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockReadCloser struct {
|
||||
bytes []byte
|
||||
}
|
||||
|
||||
func (m mockReadCloser) Read(p []byte) (int, error) {
|
||||
return copy(p, m.bytes), nil
|
||||
}
|
||||
|
||||
func Benchmark_readEvent(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
|
||||
data := makeMessage("2020-05-13T18:55:37.772853839Z {\"key\": \"value\"}\n", STDOUT)
|
||||
|
||||
reader := bufio.NewReader(mockReadCloser{bytes: data})
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
readEvent(reader, true)
|
||||
// println(message, stream)
|
||||
}
|
||||
}
|
||||
68
internal/docker/host.go
Normal file
68
internal/docker/host.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Host struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
URL *url.URL `json:"-"`
|
||||
CertPath string `json:"-"`
|
||||
CACertPath string `json:"-"`
|
||||
KeyPath string `json:"-"`
|
||||
ValidCerts bool `json:"-"`
|
||||
}
|
||||
|
||||
func ParseConnection(connection string) (Host, error) {
|
||||
parts := strings.Split(connection, "|")
|
||||
if len(parts) > 2 {
|
||||
return Host{}, fmt.Errorf("invalid connection string: %s", connection)
|
||||
}
|
||||
|
||||
remoteUrl, err := url.Parse(parts[0])
|
||||
if err != nil {
|
||||
return Host{}, err
|
||||
}
|
||||
|
||||
name := remoteUrl.Hostname()
|
||||
if len(parts) == 2 {
|
||||
name = parts[1]
|
||||
}
|
||||
|
||||
basePath, err := filepath.Abs("./certs")
|
||||
if err != nil {
|
||||
log.Fatalf("error converting certs path to absolute: %s", err)
|
||||
}
|
||||
|
||||
host := remoteUrl.Hostname()
|
||||
if _, err := os.Stat(filepath.Join(basePath, host)); !os.IsNotExist(err) {
|
||||
basePath = filepath.Join(basePath, host)
|
||||
}
|
||||
|
||||
cacertPath := filepath.Join(basePath, "ca.pem")
|
||||
certPath := filepath.Join(basePath, "cert.pem")
|
||||
keyPath := filepath.Join(basePath, "key.pem")
|
||||
|
||||
hasCerts := true
|
||||
if _, err := os.Stat(cacertPath); os.IsNotExist(err) {
|
||||
cacertPath = ""
|
||||
hasCerts = false
|
||||
}
|
||||
|
||||
return Host{
|
||||
ID: strings.ReplaceAll(remoteUrl.String(), "/", ""),
|
||||
Name: name,
|
||||
URL: remoteUrl,
|
||||
CertPath: certPath,
|
||||
CACertPath: cacertPath,
|
||||
KeyPath: keyPath,
|
||||
ValidCerts: hasCerts,
|
||||
}, nil
|
||||
|
||||
}
|
||||
53
internal/docker/level_guesser.go
Normal file
53
internal/docker/level_guesser.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var KEY_VALUE_REGEX = regexp.MustCompile(`level=(\w+)`)
|
||||
var ANSI_COLOR_REGEX = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
var LOG_LEVELS = []string{"error", "warn", "warning", "info", "debug", "trace", "fatal"}
|
||||
var LOG_LEVELS_PLAIN = map[string]*regexp.Regexp{}
|
||||
var LOG_LEVEL_BRACKET = map[string]*regexp.Regexp{}
|
||||
|
||||
func init() {
|
||||
for _, level := range LOG_LEVELS {
|
||||
LOG_LEVELS_PLAIN[level] = regexp.MustCompile("(?i)^" + level + "[^a-z]")
|
||||
}
|
||||
|
||||
for _, level := range LOG_LEVELS {
|
||||
LOG_LEVEL_BRACKET[level] = regexp.MustCompile("(?i)\\[ ?" + level + " ?\\]")
|
||||
}
|
||||
}
|
||||
|
||||
func guessLogLevel(logEvent *LogEvent) string {
|
||||
switch value := logEvent.Message.(type) {
|
||||
case string:
|
||||
stripped := ANSI_COLOR_REGEX.ReplaceAllString(value, "") // remove ansi color codes
|
||||
for _, level := range LOG_LEVELS {
|
||||
if LOG_LEVELS_PLAIN[level].MatchString(stripped) {
|
||||
return level
|
||||
}
|
||||
|
||||
if LOG_LEVEL_BRACKET[level].MatchString(stripped) {
|
||||
return level
|
||||
}
|
||||
|
||||
if strings.Contains(value, " "+strings.ToUpper(level)+" ") {
|
||||
return level
|
||||
}
|
||||
}
|
||||
|
||||
if matches := KEY_VALUE_REGEX.FindStringSubmatch(value); matches != nil {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
case map[string]interface{}:
|
||||
if level, ok := value["level"].(string); ok {
|
||||
return level
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
39
internal/docker/level_guesser_test.go
Normal file
39
internal/docker/level_guesser_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGuessLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"ERROR: Something went wrong", "error"},
|
||||
{"WARN: Something might be wrong", "warn"},
|
||||
{"INFO: Something happened", "info"},
|
||||
{"debug: Something happened", "debug"},
|
||||
{"debug Something happened", "debug"},
|
||||
{"TRACE: Something happened", "trace"},
|
||||
{"FATAL: Something happened", "fatal"},
|
||||
{"level=error Something went wrong", "error"},
|
||||
{"[ERROR] Something went wrong", "error"},
|
||||
{"[error] Something went wrong", "error"},
|
||||
{"[ ERROR ] Something went wrong", "error"},
|
||||
{"[error] Something went wrong", "error"},
|
||||
{"[test] [error] Something went wrong", "error"},
|
||||
{"[foo] [ ERROR] Something went wrong", "error"},
|
||||
{"123 ERROR Something went wrong", "error"},
|
||||
{"123 Something went wrong", ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
logEvent := &LogEvent{
|
||||
Message: test.input,
|
||||
}
|
||||
if level := guessLogLevel(logEvent); level != test.expected {
|
||||
t.Errorf("guessLogLevel(%s) = %s, want %s", test.input, level, test.expected)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
61
internal/docker/types.go
Normal file
61
internal/docker/types.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"math"
|
||||
)
|
||||
|
||||
// Container represents an internal representation of docker containers
|
||||
type Container struct {
|
||||
ID string `json:"id"`
|
||||
Names []string `json:"names"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
ImageID string `json:"imageId"`
|
||||
Command string `json:"command"`
|
||||
Created int64 `json:"created"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Tty bool `json:"-"`
|
||||
}
|
||||
|
||||
// ContainerStat represent stats instant for a container
|
||||
type ContainerStat struct {
|
||||
ID string `json:"id"`
|
||||
CPUPercent int64 `json:"cpu"`
|
||||
MemoryPercent int64 `json:"memory"`
|
||||
MemoryUsage int64 `json:"memoryUsage"`
|
||||
}
|
||||
|
||||
// ContainerEvent represents events that are triggered
|
||||
type ContainerEvent struct {
|
||||
ActorID string `json:"actorId"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type LogPosition string
|
||||
|
||||
const (
|
||||
START LogPosition = "start"
|
||||
MIDDLE LogPosition = "middle"
|
||||
END LogPosition = "end"
|
||||
)
|
||||
|
||||
type LogEvent struct {
|
||||
Message any `json:"m,omitempty"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Id uint32 `json:"id,omitempty"`
|
||||
Level string `json:"l,omitempty"`
|
||||
Position LogPosition `json:"p,omitempty"`
|
||||
Stream string `json:"s,omitempty"`
|
||||
}
|
||||
|
||||
func (l *LogEvent) HasLevel() bool {
|
||||
return l.Level != ""
|
||||
}
|
||||
|
||||
func (l *LogEvent) IsCloseToTime(other *LogEvent) bool {
|
||||
return math.Abs(float64(l.Timestamp-other.Timestamp)) < 10
|
||||
}
|
||||
Reference in New Issue
Block a user