mirror of
https://github.com/amir20/dozzle.git
synced 2025-12-21 13:23:07 +01:00
chore: refactors to be more generic (#3594)
This commit is contained in:
42
internal/container/client.go
Normal file
42
internal/container/client.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 Client interface {
|
||||
ListContainers(context.Context, ContainerFilter) ([]Container, error)
|
||||
FindContainer(context.Context, string) (Container, error)
|
||||
ContainerLogs(context.Context, string, time.Time, StdType) (io.ReadCloser, error)
|
||||
ContainerEvents(context.Context, chan<- ContainerEvent) error
|
||||
ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error)
|
||||
ContainerStats(context.Context, string, chan<- ContainerStat) error
|
||||
Ping(context.Context) error
|
||||
Host() Host
|
||||
ContainerActions(ctx context.Context, action ContainerAction, containerID string) error
|
||||
IsSwarmMode() bool
|
||||
}
|
||||
323
internal/container/container_store.go
Normal file
323
internal/container/container_store.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
type ContainerStore struct {
|
||||
containers *xsync.MapOf[string, *Container]
|
||||
subscribers *xsync.MapOf[context.Context, chan<- ContainerEvent]
|
||||
newContainerSubscribers *xsync.MapOf[context.Context, chan<- Container]
|
||||
client Client
|
||||
statsCollector *StatsCollector
|
||||
wg sync.WaitGroup
|
||||
connected atomic.Bool
|
||||
events chan ContainerEvent
|
||||
ctx context.Context
|
||||
filter ContainerFilter
|
||||
}
|
||||
|
||||
func NewContainerStore(ctx context.Context, client Client, filter ContainerFilter) *ContainerStore {
|
||||
log.Debug().Str("host", client.Host().Name).Interface("filter", filter).Msg("initializing container store")
|
||||
|
||||
s := &ContainerStore{
|
||||
containers: xsync.NewMapOf[string, *Container](),
|
||||
client: client,
|
||||
subscribers: xsync.NewMapOf[context.Context, chan<- ContainerEvent](),
|
||||
newContainerSubscribers: xsync.NewMapOf[context.Context, chan<- Container](),
|
||||
statsCollector: NewStatsCollector(client, filter),
|
||||
wg: sync.WaitGroup{},
|
||||
events: make(chan ContainerEvent),
|
||||
ctx: ctx,
|
||||
filter: filter,
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
|
||||
go s.init()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
ErrContainerNotFound = errors.New("container not found")
|
||||
maxFetchParallelism = int64(30)
|
||||
)
|
||||
|
||||
func (s *ContainerStore) checkConnectivity() error {
|
||||
if s.connected.CompareAndSwap(false, true) {
|
||||
go func() {
|
||||
log.Debug().Str("host", s.client.Host().Name).Msg("docker store subscribing docker events")
|
||||
err := s.client.ContainerEvents(s.ctx, s.events)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
log.Error().Err(err).Str("host", s.client.Host().Name).Msg("docker store unexpectedly disconnected from docker events")
|
||||
}
|
||||
s.connected.Store(false)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) // 3s is enough to fetch all containers
|
||||
defer cancel()
|
||||
if containers, err := s.client.ListContainers(ctx, s.filter); err != nil {
|
||||
return err
|
||||
} else {
|
||||
s.containers.Clear()
|
||||
|
||||
for _, c := range containers {
|
||||
s.containers.Store(c.ID, &c)
|
||||
}
|
||||
|
||||
running := lo.Filter(containers, func(item Container, index int) bool {
|
||||
return item.State != "exited"
|
||||
})
|
||||
|
||||
sem := semaphore.NewWeighted(maxFetchParallelism)
|
||||
|
||||
for i, c := range running {
|
||||
if err := sem.Acquire(s.ctx, 1); err != nil {
|
||||
log.Error().Err(err).Msg("failed to acquire semaphore")
|
||||
break
|
||||
}
|
||||
go func(c Container, i int) {
|
||||
defer sem.Release(1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // 2s is hardcoded timeout for fetching container
|
||||
defer cancel()
|
||||
if container, err := s.client.FindContainer(ctx, c.ID); err == nil {
|
||||
s.containers.Store(c.ID, &container)
|
||||
}
|
||||
}(c, i)
|
||||
}
|
||||
|
||||
if err := sem.Acquire(s.ctx, maxFetchParallelism); err != nil {
|
||||
log.Error().Err(err).Msg("failed to acquire semaphore")
|
||||
}
|
||||
|
||||
log.Debug().Int("containers", len(containers)).Msg("finished initializing container store")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ContainerStore) ListContainers(filter ContainerFilter) ([]Container, error) {
|
||||
s.wg.Wait()
|
||||
|
||||
if err := s.checkConnectivity(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
validContainers, err := s.client.ListContainers(s.ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
validIDMap := lo.KeyBy(validContainers, func(item Container) string {
|
||||
return item.ID
|
||||
})
|
||||
|
||||
containers := make([]Container, 0)
|
||||
s.containers.Range(func(_ string, c *Container) bool {
|
||||
if _, ok := validIDMap[c.ID]; ok {
|
||||
containers = append(containers, *c)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func (s *ContainerStore) FindContainer(id string, filter ContainerFilter) (Container, error) {
|
||||
s.wg.Wait()
|
||||
|
||||
if filter.Exists() {
|
||||
validContainers, err := s.client.ListContainers(s.ctx, filter)
|
||||
if err != nil {
|
||||
return Container{}, err
|
||||
}
|
||||
|
||||
validIDMap := lo.KeyBy(validContainers, func(item Container) string {
|
||||
return item.ID
|
||||
})
|
||||
|
||||
if _, ok := validIDMap[id]; !ok {
|
||||
log.Warn().Str("id", id).Msg("user doesn't have access to container")
|
||||
return Container{}, ErrContainerNotFound
|
||||
}
|
||||
}
|
||||
|
||||
if container, ok := s.containers.Load(id); ok {
|
||||
if container.StartedAt.IsZero() {
|
||||
log.Debug().Str("id", id).Msg("container doesn't have detailed information, fetching it")
|
||||
if newContainer, ok := s.containers.Compute(id, func(c *Container, loaded bool) (*Container, bool) {
|
||||
if loaded {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if newContainer, err := s.client.FindContainer(ctx, id); err == nil {
|
||||
return &newContainer, false
|
||||
}
|
||||
}
|
||||
return c, false
|
||||
}); ok {
|
||||
event := ContainerEvent{
|
||||
Name: "update",
|
||||
Host: s.client.Host().ID,
|
||||
ActorID: id,
|
||||
}
|
||||
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
|
||||
select {
|
||||
case events <- event:
|
||||
case <-c.Done():
|
||||
s.subscribers.Delete(c)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return *newContainer, nil
|
||||
}
|
||||
}
|
||||
return *container, nil
|
||||
} else {
|
||||
log.Warn().Str("id", id).Msg("container not found")
|
||||
return Container{}, ErrContainerNotFound
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ContainerStore) Client() Client {
|
||||
return s.client
|
||||
}
|
||||
|
||||
func (s *ContainerStore) SubscribeEvents(ctx context.Context, events chan<- ContainerEvent) {
|
||||
go func() {
|
||||
if s.statsCollector.Start(s.ctx) {
|
||||
s.containers.Range(func(_ string, c *Container) bool {
|
||||
c.Stats.Clear()
|
||||
return true
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
s.subscribers.Store(ctx, events)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.subscribers.Delete(ctx)
|
||||
s.statsCollector.Stop()
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ContainerStore) SubscribeStats(ctx context.Context, stats chan<- ContainerStat) {
|
||||
s.statsCollector.Subscribe(ctx, stats)
|
||||
}
|
||||
|
||||
func (s *ContainerStore) SubscribeNewContainers(ctx context.Context, containers chan<- Container) {
|
||||
s.newContainerSubscribers.Store(ctx, containers)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.newContainerSubscribers.Delete(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ContainerStore) init() {
|
||||
stats := make(chan ContainerStat)
|
||||
s.statsCollector.Subscribe(s.ctx, stats)
|
||||
|
||||
s.checkConnectivity()
|
||||
|
||||
s.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event := <-s.events:
|
||||
log.Trace().Str("event", event.Name).Str("id", event.ActorID).Msg("received container event")
|
||||
switch event.Name {
|
||||
case "start":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
|
||||
if container, err := s.client.FindContainer(ctx, event.ActorID); err == nil {
|
||||
list, _ := s.client.ListContainers(ctx, s.filter)
|
||||
|
||||
// make sure the container is in the list of containers when using filter
|
||||
valid := lo.ContainsBy(list, func(item Container) bool {
|
||||
return item.ID == container.ID
|
||||
})
|
||||
|
||||
if valid {
|
||||
log.Debug().Str("id", container.ID).Msg("container started")
|
||||
s.containers.Store(container.ID, &container)
|
||||
s.newContainerSubscribers.Range(func(c context.Context, containers chan<- Container) bool {
|
||||
select {
|
||||
case containers <- container:
|
||||
case <-c.Done():
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
case "destroy":
|
||||
log.Debug().Str("id", event.ActorID).Msg("container destroyed")
|
||||
s.containers.Delete(event.ActorID)
|
||||
|
||||
case "die":
|
||||
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, bool) {
|
||||
if loaded {
|
||||
log.Debug().Str("id", c.ID).Msg("container died")
|
||||
c.State = "exited"
|
||||
c.FinishedAt = time.Now()
|
||||
return c, false
|
||||
} else {
|
||||
return c, true
|
||||
}
|
||||
})
|
||||
case "health_status: healthy", "health_status: unhealthy":
|
||||
healthy := "unhealthy"
|
||||
if event.Name == "health_status: healthy" {
|
||||
healthy = "healthy"
|
||||
}
|
||||
|
||||
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, bool) {
|
||||
if loaded {
|
||||
log.Debug().Str("id", c.ID).Str("health", healthy).Msg("container health status changed")
|
||||
c.Health = healthy
|
||||
return c, false
|
||||
} else {
|
||||
return c, true
|
||||
}
|
||||
})
|
||||
|
||||
case "rename":
|
||||
s.containers.Compute(event.ActorID, func(c *Container, loaded bool) (*Container, bool) {
|
||||
if loaded {
|
||||
log.Debug().Str("id", event.ActorID).Str("name", event.ActorAttributes["name"]).Msg("container renamed")
|
||||
c.Name = event.ActorAttributes["name"]
|
||||
return c, false
|
||||
} else {
|
||||
return c, true
|
||||
}
|
||||
})
|
||||
}
|
||||
s.subscribers.Range(func(c context.Context, events chan<- ContainerEvent) bool {
|
||||
select {
|
||||
case events <- event:
|
||||
case <-c.Done():
|
||||
s.subscribers.Delete(c)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
case stat := <-stats:
|
||||
if container, ok := s.containers.Load(stat.ID); ok {
|
||||
stat.ID = ""
|
||||
container.Stats.Push(stat)
|
||||
}
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
121
internal/container/container_store_test.go
Normal file
121
internal/container/container_store_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/amir20/dozzle/internal/utils"
|
||||
"github.com/magiconair/properties/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type mockedClient struct {
|
||||
mock.Mock
|
||||
Client
|
||||
}
|
||||
|
||||
func (m *mockedClient) ListContainers(ctx context.Context, filter ContainerFilter) ([]Container, error) {
|
||||
args := m.Called(ctx, filter)
|
||||
return args.Get(0).([]Container), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockedClient) FindContainer(ctx context.Context, id string) (Container, error) {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Get(0).(Container), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockedClient) ContainerEvents(ctx context.Context, events chan<- ContainerEvent) error {
|
||||
args := m.Called(ctx, events)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *mockedClient) ContainerStats(ctx context.Context, id string, stats chan<- ContainerStat) error {
|
||||
args := m.Called(ctx, id, stats)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *mockedClient) Host() Host {
|
||||
args := m.Called()
|
||||
return args.Get(0).(Host)
|
||||
}
|
||||
|
||||
func TestContainerStore_List(t *testing.T) {
|
||||
|
||||
client := new(mockedClient)
|
||||
client.On("ListContainers", mock.Anything, mock.Anything).Return([]Container{
|
||||
{
|
||||
ID: "1234",
|
||||
Name: "test",
|
||||
},
|
||||
}, nil)
|
||||
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).Run(func(args mock.Arguments) {
|
||||
ctx := args.Get(0).(context.Context)
|
||||
<-ctx.Done()
|
||||
})
|
||||
client.On("Host").Return(Host{
|
||||
ID: "localhost",
|
||||
})
|
||||
|
||||
client.On("FindContainer", mock.Anything, "1234").Return(Container{
|
||||
ID: "1234",
|
||||
Name: "test",
|
||||
Image: "test",
|
||||
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||
}, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
store := NewContainerStore(ctx, client, ContainerFilter{})
|
||||
containers, _ := store.ListContainers(ContainerFilter{})
|
||||
|
||||
assert.Equal(t, containers[0].ID, "1234")
|
||||
}
|
||||
|
||||
func TestContainerStore_die(t *testing.T) {
|
||||
client := new(mockedClient)
|
||||
client.On("ListContainers", mock.Anything, mock.Anything).Return([]Container{
|
||||
{
|
||||
ID: "1234",
|
||||
Name: "test",
|
||||
State: "running",
|
||||
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||
},
|
||||
}, nil)
|
||||
|
||||
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil).
|
||||
Run(func(args mock.Arguments) {
|
||||
ctx := args.Get(0).(context.Context)
|
||||
events := args.Get(1).(chan<- ContainerEvent)
|
||||
events <- ContainerEvent{
|
||||
Name: "die",
|
||||
ActorID: "1234",
|
||||
Host: "localhost",
|
||||
}
|
||||
<-ctx.Done()
|
||||
})
|
||||
client.On("Host").Return(Host{
|
||||
ID: "localhost",
|
||||
})
|
||||
|
||||
client.On("ContainerStats", mock.Anything, "1234", mock.AnythingOfType("chan<- container.ContainerStat")).Return(nil)
|
||||
|
||||
client.On("FindContainer", mock.Anything, "1234").Return(Container{
|
||||
ID: "1234",
|
||||
Name: "test",
|
||||
Image: "test",
|
||||
Stats: utils.NewRingBuffer[ContainerStat](300),
|
||||
}, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
store := NewContainerStore(ctx, client, ContainerFilter{})
|
||||
|
||||
// Wait until we get the event
|
||||
events := make(chan ContainerEvent)
|
||||
store.SubscribeEvents(ctx, events)
|
||||
<-events
|
||||
|
||||
containers, _ := store.ListContainers(ContainerFilter{})
|
||||
assert.Equal(t, containers[0].State, "exited")
|
||||
}
|
||||
50
internal/container/escape.go
Normal file
50
internal/container/escape.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"html"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
func escape(logEvent *LogEvent) {
|
||||
switch value := logEvent.Message.(type) {
|
||||
case string:
|
||||
logEvent.Message = html.EscapeString(value)
|
||||
|
||||
case *orderedmap.OrderedMap[string, any]:
|
||||
escapeAnyMap(value)
|
||||
|
||||
case *orderedmap.OrderedMap[string, string]:
|
||||
escapeStringMap(value)
|
||||
|
||||
case map[string]interface{}:
|
||||
panic("not implemented")
|
||||
|
||||
case map[string]string:
|
||||
panic("not implemented")
|
||||
|
||||
default:
|
||||
log.Debug().Type("type", value).Msg("unknown logEvent type")
|
||||
}
|
||||
}
|
||||
|
||||
func escapeAnyMap(orderedMap *orderedmap.OrderedMap[string, any]) {
|
||||
for pair := orderedMap.Oldest(); pair != nil; pair = pair.Next() {
|
||||
switch value := pair.Value.(type) {
|
||||
case string:
|
||||
orderedMap.Set(pair.Key, html.EscapeString(value))
|
||||
case *orderedmap.OrderedMap[string, any]:
|
||||
escapeAnyMap(value)
|
||||
case *orderedmap.OrderedMap[string, string]:
|
||||
escapeStringMap(value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func escapeStringMap(orderedMap *orderedmap.OrderedMap[string, string]) {
|
||||
for pair := orderedMap.Oldest(); pair != nil; pair = pair.Next() {
|
||||
orderedMap.Set(pair.Key, html.EscapeString(pair.Value))
|
||||
}
|
||||
}
|
||||
230
internal/container/event_generator.go
Normal file
230
internal/container/event_generator.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"encoding/json"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type EventGenerator struct {
|
||||
Events chan *LogEvent
|
||||
Errors chan error
|
||||
reader *bufio.Reader
|
||||
next *LogEvent
|
||||
buffer chan *LogEvent
|
||||
tty bool
|
||||
wg sync.WaitGroup
|
||||
containerID string
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
var ErrBadHeader = fmt.Errorf("dozzle/docker: unable to read header")
|
||||
|
||||
func NewEventGenerator(ctx context.Context, reader io.Reader, container Container) *EventGenerator {
|
||||
generator := &EventGenerator{
|
||||
reader: bufio.NewReader(reader),
|
||||
buffer: make(chan *LogEvent, 100),
|
||||
Errors: make(chan error, 1),
|
||||
Events: make(chan *LogEvent),
|
||||
tty: container.Tty,
|
||||
containerID: container.ID,
|
||||
ctx: ctx,
|
||||
}
|
||||
generator.wg.Add(2)
|
||||
go generator.consumeReader()
|
||||
go generator.processBuffer()
|
||||
return generator
|
||||
}
|
||||
|
||||
func (g *EventGenerator) processBuffer() {
|
||||
var current, next *LogEvent
|
||||
|
||||
loop:
|
||||
for {
|
||||
if g.next != nil {
|
||||
current = g.next
|
||||
g.next = nil
|
||||
next = g.peek()
|
||||
} else {
|
||||
event, ok := <-g.buffer
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
current = event
|
||||
next = g.peek()
|
||||
}
|
||||
|
||||
checkPosition(current, next)
|
||||
|
||||
select {
|
||||
case g.Events <- current:
|
||||
case <-g.ctx.Done():
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
close(g.Events)
|
||||
|
||||
g.wg.Done()
|
||||
}
|
||||
|
||||
func (g *EventGenerator) consumeReader() {
|
||||
for {
|
||||
message, streamType, readerError := readEvent(g.reader, g.tty)
|
||||
if message != "" {
|
||||
logEvent := createEvent(message, streamType)
|
||||
logEvent.ContainerID = g.containerID
|
||||
logEvent.Level = guessLogLevel(logEvent)
|
||||
escape(logEvent)
|
||||
g.buffer <- logEvent
|
||||
}
|
||||
|
||||
if readerError != nil {
|
||||
if readerError != ErrBadHeader {
|
||||
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.Warn().Bytes("header", header).Msg("short read")
|
||||
message, _ := reader.ReadString('\n')
|
||||
return message, streamType, ErrBadHeader
|
||||
}
|
||||
|
||||
switch header[0] {
|
||||
case 1:
|
||||
streamType = STDOUT
|
||||
case 2:
|
||||
streamType = STDERR
|
||||
default:
|
||||
log.Warn().Bytes("header", header).Msg("unknown stream type")
|
||||
}
|
||||
|
||||
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 message == "" {
|
||||
logEvent.Message = "" // empty message so do nothing
|
||||
} else if json.Valid([]byte(message)) {
|
||||
data := orderedmap.New[string, any]()
|
||||
if err := json.Unmarshal([]byte(message), &data); err != nil {
|
||||
var jsonErr *json.UnmarshalTypeError
|
||||
if errors.As(err, &jsonErr) {
|
||||
if jsonErr.Value == "string" {
|
||||
log.Warn().Err(err).Str("value", jsonErr.Value).Msg("failed to unmarshal json")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if data == nil {
|
||||
logEvent.Message = ""
|
||||
} else {
|
||||
logEvent.Message = data
|
||||
}
|
||||
}
|
||||
} else if data, err := ParseLogFmt(message); err == nil {
|
||||
logEvent.Message = data
|
||||
}
|
||||
}
|
||||
}
|
||||
return logEvent
|
||||
}
|
||||
|
||||
func checkPosition(currentEvent *LogEvent, nextEvent *LogEvent) {
|
||||
currentLevel := guessLogLevel(currentEvent)
|
||||
if nextEvent != nil {
|
||||
if currentEvent.IsCloseToTime(nextEvent) && currentLevel != "unknown" && !nextEvent.HasLevel() {
|
||||
currentEvent.Position = Beginning
|
||||
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 == Beginning || currentEvent.Position == Middle {
|
||||
nextEvent.Level = currentEvent.Level
|
||||
}
|
||||
} else if currentEvent.Position == Middle {
|
||||
currentEvent.Position = End
|
||||
}
|
||||
}
|
||||
178
internal/container/event_generator_test.go
Normal file
178
internal/container/event_generator_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
func TestEventGenerator_Events_tty(t *testing.T) {
|
||||
input := "example input"
|
||||
reader := bufio.NewReader(strings.NewReader(input))
|
||||
|
||||
g := NewEventGenerator(context.Background(), reader, Container{Tty: 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(context.Background(), reader, Container{Tty: 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(context.Background(), reader, Container{Tty: 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(context.Background(), reader, Container{Tty: 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) {
|
||||
data := orderedmap.New[string, any]()
|
||||
data.Set("xyz", "value")
|
||||
data.Set("abc", "value2")
|
||||
type args struct {
|
||||
message string
|
||||
}
|
||||
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 {\"xyz\": \"value\", \"abc\": \"value2\"}",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: data,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid json message",
|
||||
args: args{
|
||||
message: "2020-05-13T18:55:37.772853839Z {\"key\"}",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: "{\"key\"}",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid json message",
|
||||
args: args{
|
||||
message: "2020-05-13T18:55:37.772853839Z 123",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: "123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid logfmt message",
|
||||
args: args{
|
||||
message: "2020-05-13T18:55:37.772853839Z sample text with=equal sign",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: "sample text with=equal sign",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "null message",
|
||||
args: args{
|
||||
message: "2020-05-13T18:55:37.772853839Z null",
|
||||
},
|
||||
want: &LogEvent{
|
||||
Message: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
84
internal/container/host.go
Normal file
84
internal/container/host.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
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:"-"`
|
||||
NCPU int `json:"nCPU"`
|
||||
MemTotal int64 `json:"memTotal"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
DockerVersion string `json:"dockerVersion"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Available bool `json:"available"`
|
||||
Swarm bool `json:"-"`
|
||||
}
|
||||
|
||||
func (h Host) String() string {
|
||||
return fmt.Sprintf("ID: %s, Endpoint: %s, nCPU: %d, memTotal: %d", h.ID, h.Endpoint, h.NCPU, h.MemTotal)
|
||||
}
|
||||
|
||||
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 {
|
||||
return Host{}, err
|
||||
}
|
||||
|
||||
host := remoteUrl.Hostname()
|
||||
if _, err := os.Stat(filepath.Join(basePath, host)); !os.IsNotExist(err) {
|
||||
basePath = filepath.Join(basePath, host)
|
||||
} else {
|
||||
log.Debug().Msgf("Remote host certificate path does not exist %s, falling back to default: %s", filepath.Join(basePath, host), basePath)
|
||||
}
|
||||
|
||||
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,
|
||||
Endpoint: remoteUrl.String(),
|
||||
}, nil
|
||||
|
||||
}
|
||||
129
internal/container/level_guesser.go
Normal file
129
internal/container/level_guesser.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
var SupportedLogLevels map[string]struct{}
|
||||
|
||||
// Changing this also needs to change the logContext.ts file
|
||||
var logLevels = [][]string{
|
||||
{"error", "err"},
|
||||
{"warn", "warning"},
|
||||
{"info", "inf"},
|
||||
{"debug", "dbg"},
|
||||
{"trace"},
|
||||
{"fatal", "sev", "severe", "crit", "critical"},
|
||||
}
|
||||
|
||||
var plainLevels = map[string][]*regexp.Regexp{}
|
||||
var bracketLevels = map[string][]*regexp.Regexp{}
|
||||
var timestampRegex = regexp.MustCompile(`^(?:\d{4}[-/]\d{2}[-/]\d{2}(?:[T ](?:\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?|\d{2}:\d{2}(?:AM|PM)))?\s+)`)
|
||||
|
||||
func init() {
|
||||
for _, levelGroup := range logLevels {
|
||||
first := levelGroup[0]
|
||||
for _, level := range levelGroup {
|
||||
plainLevels[first] = append(plainLevels[first], regexp.MustCompile("(?i)^"+level+"[^a-z]"))
|
||||
}
|
||||
}
|
||||
|
||||
for _, levelGroup := range logLevels {
|
||||
first := levelGroup[0]
|
||||
for _, level := range levelGroup {
|
||||
bracketLevels[first] = append(bracketLevels[first], regexp.MustCompile("(?i)\\[ ?"+level+" ?\\]"))
|
||||
}
|
||||
}
|
||||
|
||||
SupportedLogLevels = make(map[string]struct{}, len(logLevels)+1)
|
||||
for _, levelGroup := range logLevels {
|
||||
SupportedLogLevels[levelGroup[0]] = struct{}{}
|
||||
}
|
||||
SupportedLogLevels["unknown"] = struct{}{}
|
||||
}
|
||||
|
||||
func guessLogLevel(logEvent *LogEvent) string {
|
||||
switch value := logEvent.Message.(type) {
|
||||
case string:
|
||||
value = stripANSI(value)
|
||||
value = timestampRegex.ReplaceAllString(value, "")
|
||||
for _, levelGroup := range logLevels {
|
||||
first := levelGroup[0]
|
||||
// Look for the level at the beginning of the message
|
||||
for _, regex := range plainLevels[first] {
|
||||
if regex.MatchString(value) {
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
// Look for the level in brackets
|
||||
for _, regex := range bracketLevels[first] {
|
||||
if regex.MatchString(value) {
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
// Look for the level in the middle of the message that are uppercase and surrounded by quotes
|
||||
if strings.Contains(value, "\""+strings.ToUpper(first)+"\"") {
|
||||
return first
|
||||
}
|
||||
|
||||
// Look for the level in the middle of the message that are uppercase
|
||||
if strings.Contains(value, " "+strings.ToUpper(first)+" ") {
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
|
||||
case *orderedmap.OrderedMap[string, any]:
|
||||
if value == nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
if level, ok := value.Get("level"); ok {
|
||||
if level, ok := level.(string); ok {
|
||||
return normalizeLogLevel(level)
|
||||
}
|
||||
} else if severity, ok := value.Get("severity"); ok {
|
||||
if severity, ok := severity.(string); ok {
|
||||
return normalizeLogLevel(severity)
|
||||
}
|
||||
}
|
||||
|
||||
case *orderedmap.OrderedMap[string, string]:
|
||||
if value == nil {
|
||||
return "unknown"
|
||||
}
|
||||
if level, ok := value.Get("level"); ok {
|
||||
return normalizeLogLevel(level)
|
||||
} else if severity, ok := value.Get("severity"); ok {
|
||||
return normalizeLogLevel(severity)
|
||||
}
|
||||
|
||||
case map[string]interface{}:
|
||||
panic("not implemented")
|
||||
|
||||
case map[string]string:
|
||||
panic("not implemented")
|
||||
|
||||
default:
|
||||
log.Debug().Type("type", value).Msg("unknown logEvent type")
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func normalizeLogLevel(level string) string {
|
||||
level = stripANSI(level)
|
||||
level = strings.ToLower(level)
|
||||
if _, ok := SupportedLogLevels[level]; ok {
|
||||
return level
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
77
internal/container/level_guesser_test.go
Normal file
77
internal/container/level_guesser_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
func TestGuessLogLevel(t *testing.T) {
|
||||
var nilOrderedMap *orderedmap.OrderedMap[string, any]
|
||||
tests := []struct {
|
||||
input any
|
||||
expected string
|
||||
}{
|
||||
{"2024/12/30 12:21AM INF this is a test", "info"},
|
||||
{"2024-12-30T17:43:16Z DBG loggging debug from here", "debug"},
|
||||
{"2025-01-07 15:40:15,784 LL=\"ERROR\" some message", "error"},
|
||||
{"2025-01-07 15:40:15,784 LL=\"WARN\" some message", "warn"},
|
||||
{"2025-01-07 15:40:15,784 LL=\"INFO\" some message", "info"},
|
||||
{"2025-01-07 15:40:15,784 LL=\"DEBUG\" some message", "debug"},
|
||||
{"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"},
|
||||
{"[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", "unknown"},
|
||||
{"DBG Something went wrong", "debug"},
|
||||
{"inf Something went wrong", "info"},
|
||||
{"crit: Something went wrong", "fatal"},
|
||||
{orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "key", Value: "value"},
|
||||
orderedmap.Pair[string, string]{Key: "level", Value: "info"},
|
||||
),
|
||||
), "info"},
|
||||
{orderedmap.New[string, any](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, any]{Key: "key", Value: "value"},
|
||||
orderedmap.Pair[string, any]{Key: "level", Value: "info"},
|
||||
),
|
||||
), "info"},
|
||||
{orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "key", Value: "value"},
|
||||
orderedmap.Pair[string, string]{Key: "severity", Value: "info"},
|
||||
),
|
||||
), "info"},
|
||||
{orderedmap.New[string, any](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, any]{Key: "key", Value: "value"},
|
||||
orderedmap.Pair[string, any]{Key: "severity", Value: "info"},
|
||||
),
|
||||
), "info"},
|
||||
{nilOrderedMap, "unknown"},
|
||||
{nil, "unknown"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
name, _ := json.Marshal(test.input)
|
||||
t.Run(string(name), func(t *testing.T) {
|
||||
actual := guessLogLevel(&LogEvent{Message: test.input})
|
||||
if actual != test.expected {
|
||||
t.Errorf("Expected %s, got %s", test.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
79
internal/container/logfmt.go
Normal file
79
internal/container/logfmt.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
// ParseLogFmt parses a log entry in logfmt format and returns a map of key-value pairs.
|
||||
func ParseLogFmt(log string) (*orderedmap.OrderedMap[string, string], error) {
|
||||
result := orderedmap.New[string, string]()
|
||||
var key, value string
|
||||
inQuotes, escaping, isKey := false, false, true
|
||||
start := 0
|
||||
|
||||
for i := 0; i < len(log); i++ {
|
||||
char := log[i]
|
||||
if isKey {
|
||||
if char == '=' {
|
||||
if start >= i {
|
||||
return nil, errors.New("invalid format: key is empty")
|
||||
}
|
||||
key = log[start:i]
|
||||
isKey = false
|
||||
start = i + 1
|
||||
} else if char == ' ' {
|
||||
if i > start {
|
||||
return nil, errors.New("invalid format: unexpected space in key")
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
if inQuotes {
|
||||
if escaping {
|
||||
escaping = false
|
||||
} else if char == '\\' {
|
||||
escaping = true
|
||||
} else if char == '"' {
|
||||
value = log[start:i]
|
||||
result.Set(key, value)
|
||||
inQuotes = false
|
||||
isKey = true
|
||||
start = i + 2
|
||||
}
|
||||
} else {
|
||||
if char == '"' {
|
||||
inQuotes = true
|
||||
start = i + 1
|
||||
} else if char == ' ' {
|
||||
value = log[start:i]
|
||||
result.Set(key, value)
|
||||
isKey = true
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the last key-value pair if there is no trailing space
|
||||
if !isKey && start < len(log) {
|
||||
if inQuotes {
|
||||
return nil, errors.New("invalid format: unclosed quotes")
|
||||
}
|
||||
value = log[start:]
|
||||
result.Set(key, value)
|
||||
} else if isKey && start < len(log) {
|
||||
return nil, errors.New("invalid format: unexpected key without value")
|
||||
}
|
||||
|
||||
if !isKey {
|
||||
if inQuotes {
|
||||
return nil, errors.New("invalid format: unclosed quotes")
|
||||
}
|
||||
value = log[start:]
|
||||
result.Set(key, value)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
104
internal/container/logfmt_test.go
Normal file
104
internal/container/logfmt_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
)
|
||||
|
||||
func TestParseLog(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
log string
|
||||
want *orderedmap.OrderedMap[string, string]
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid logfmt log",
|
||||
log: `time="2024-06-02T14:30:42Z" level=debug msg="container e23e04da2cb9 started"`,
|
||||
want: orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "time", Value: "2024-06-02T14:30:42Z"},
|
||||
orderedmap.Pair[string, string]{Key: "level", Value: "debug"},
|
||||
orderedmap.Pair[string, string]{Key: "msg", Value: "container e23e04da2cb9 started"},
|
||||
),
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Random test with equal sign",
|
||||
log: "foo bar=baz",
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Valid log with key and trailing no value",
|
||||
log: "key1=value1 key2=",
|
||||
want: orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "key1", Value: "value1"},
|
||||
orderedmap.Pair[string, string]{Key: "key2", Value: ""},
|
||||
),
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid log with key and no values",
|
||||
log: "key1=value1 key2= key3=bar",
|
||||
want: orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "key1", Value: "value1"},
|
||||
orderedmap.Pair[string, string]{Key: "key2", Value: ""},
|
||||
orderedmap.Pair[string, string]{Key: "key3", Value: "bar"},
|
||||
),
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid log",
|
||||
log: "key1=value1 key2=value2",
|
||||
want: orderedmap.New[string, string](
|
||||
orderedmap.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "key1", Value: "value1"},
|
||||
orderedmap.Pair[string, string]{Key: "key2", Value: "value2"},
|
||||
),
|
||||
),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Broken format with unexpected quotes",
|
||||
log: `key1=value"1"= key2="value2"`,
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid log with unclosed quotes",
|
||||
log: "key1=\"value1 key2=value2",
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Plain text log",
|
||||
log: "foo bar baz",
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseLogFmt(tt.log)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseLogFmt() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
jsonGot, _ := json.MarshalIndent(got, "", " ")
|
||||
jsonWant, _ := json.MarshalIndent(tt.want, "", " ")
|
||||
t.Errorf("ParseLogFmt() = %v, want %v", string(jsonGot), string(jsonWant))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
155
internal/container/stats_collector.go
Normal file
155
internal/container/stats_collector.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type StatsCollector struct {
|
||||
stream chan ContainerStat
|
||||
subscribers *xsync.MapOf[context.Context, chan<- ContainerStat]
|
||||
client Client
|
||||
cancelers *xsync.MapOf[string, context.CancelFunc]
|
||||
stopper context.CancelFunc
|
||||
timer *time.Timer
|
||||
mu sync.Mutex
|
||||
totalStarted atomic.Int32
|
||||
filter ContainerFilter
|
||||
}
|
||||
|
||||
var timeToStop = 6 * time.Hour
|
||||
|
||||
func NewStatsCollector(client Client, filter ContainerFilter) *StatsCollector {
|
||||
return &StatsCollector{
|
||||
stream: make(chan ContainerStat),
|
||||
subscribers: xsync.NewMapOf[context.Context, chan<- ContainerStat](),
|
||||
client: client,
|
||||
cancelers: xsync.NewMapOf[string, context.CancelFunc](),
|
||||
filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StatsCollector) Subscribe(ctx context.Context, stats chan<- ContainerStat) {
|
||||
c.subscribers.Store(ctx, stats)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c.subscribers.Delete(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *StatsCollector) forceStop() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.stopper != nil {
|
||||
c.stopper()
|
||||
c.stopper = nil
|
||||
log.Debug().Str("host", c.client.Host().ID).Msg("stopped container stats collector")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StatsCollector) Stop() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.totalStarted.Add(-1) == 0 {
|
||||
c.timer = time.AfterFunc(timeToStop, func() {
|
||||
c.forceStop()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StatsCollector) reset() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.timer != nil {
|
||||
c.timer.Stop()
|
||||
}
|
||||
c.timer = nil
|
||||
}
|
||||
|
||||
func streamStats(parent context.Context, sc *StatsCollector, id string) {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
sc.cancelers.Store(id, cancel)
|
||||
log.Debug().Str("container", id).Str("host", sc.client.Host().Name).Msg("starting to stream stats")
|
||||
if err := sc.client.ContainerStats(ctx, id, sc.stream); err != nil {
|
||||
log.Debug().Str("container", id).Str("host", sc.client.Host().Name).Err(err).Msg("stopping to stream stats")
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
|
||||
log.Error().Str("container", id).Str("host", sc.client.Host().Name).Err(err).Msg("unexpected error while streaming stats")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the stats collector and blocks until it's stopped. It returns true if the collector was stopped, false if it was already running
|
||||
func (sc *StatsCollector) Start(parentCtx context.Context) bool {
|
||||
sc.reset()
|
||||
sc.totalStarted.Add(1)
|
||||
|
||||
sc.mu.Lock()
|
||||
if sc.stopper != nil {
|
||||
sc.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
var ctx context.Context
|
||||
ctx, sc.stopper = context.WithCancel(parentCtx)
|
||||
sc.mu.Unlock()
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(parentCtx, 3*time.Second) // 3 seconds to list containers is hard limit
|
||||
if containers, err := sc.client.ListContainers(timeoutCtx, sc.filter); err == nil {
|
||||
for _, c := range containers {
|
||||
if c.State == "running" {
|
||||
go streamStats(ctx, sc, c.ID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Error().Str("host", sc.client.Host().Name).Err(err).Msg("failed to list containers")
|
||||
}
|
||||
cancel()
|
||||
|
||||
events := make(chan ContainerEvent)
|
||||
|
||||
go func() {
|
||||
log.Debug().Str("host", sc.client.Host().Name).Msg("starting to listen to docker events")
|
||||
err := sc.client.ContainerEvents(context.Background(), events)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
log.Error().Str("host", sc.client.Host().Name).Err(err).Msg("unexpected error while listening to docker events")
|
||||
}
|
||||
sc.forceStop()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for event := range events {
|
||||
switch event.Name {
|
||||
case "start":
|
||||
go streamStats(ctx, sc, event.ActorID)
|
||||
|
||||
case "die":
|
||||
if cancel, ok := sc.cancelers.LoadAndDelete(event.ActorID); ok {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Str("host", sc.client.Host().Name).Msg("stopped container stats collector")
|
||||
return true
|
||||
case stat := <-sc.stream:
|
||||
sc.subscribers.Range(func(c context.Context, stats chan<- ContainerStat) bool {
|
||||
select {
|
||||
case stats <- stat:
|
||||
case <-c.Done():
|
||||
sc.subscribers.Delete(c)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
84
internal/container/stats_collector_test.go
Normal file
84
internal/container/stats_collector_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func startedCollector(ctx context.Context) *StatsCollector {
|
||||
client := new(mockedClient)
|
||||
client.On("ListContainers", mock.Anything, mock.Anything).Return([]Container{
|
||||
{
|
||||
ID: "1234",
|
||||
Name: "test",
|
||||
State: "running",
|
||||
},
|
||||
}, nil)
|
||||
client.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).
|
||||
Return(nil).
|
||||
Run(func(args mock.Arguments) {
|
||||
ctx := args.Get(0).(context.Context)
|
||||
<-ctx.Done()
|
||||
})
|
||||
client.On("ContainerStats", mock.Anything, mock.Anything, mock.AnythingOfType("chan<- container.ContainerStat")).
|
||||
Return(nil).
|
||||
Run(func(args mock.Arguments) {
|
||||
stats := args.Get(2).(chan<- ContainerStat)
|
||||
stats <- ContainerStat{
|
||||
ID: "1234",
|
||||
}
|
||||
})
|
||||
client.On("Host").Return(Host{
|
||||
ID: "localhost",
|
||||
})
|
||||
|
||||
collector := NewStatsCollector(client, ContainerFilter{})
|
||||
stats := make(chan ContainerStat)
|
||||
|
||||
collector.Subscribe(ctx, stats)
|
||||
|
||||
go collector.Start(ctx)
|
||||
|
||||
<-stats
|
||||
|
||||
return collector
|
||||
}
|
||||
|
||||
func TestCancelers(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
collector := startedCollector(ctx)
|
||||
|
||||
_, ok := collector.cancelers.Load("1234")
|
||||
assert.True(t, ok, "canceler should be stored")
|
||||
|
||||
assert.False(t, collector.Start(ctx), "second start should return false")
|
||||
assert.Equal(t, int32(2), collector.totalStarted.Load(), "total started should be 2")
|
||||
|
||||
collector.Stop()
|
||||
|
||||
assert.Equal(t, int32(1), collector.totalStarted.Load(), "total started should be 1")
|
||||
}
|
||||
|
||||
func TestSecondStart(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
collector := startedCollector(ctx)
|
||||
|
||||
assert.False(t, collector.Start(ctx), "second start should return false")
|
||||
assert.Equal(t, int32(2), collector.totalStarted.Load(), "total started should be 2")
|
||||
|
||||
collector.Stop()
|
||||
assert.Equal(t, int32(1), collector.totalStarted.Load(), "total started should be 1")
|
||||
}
|
||||
|
||||
func TestStop(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
collector := startedCollector(ctx)
|
||||
collector.Stop()
|
||||
assert.Equal(t, int32(0), collector.totalStarted.Load(), "total started should be 1")
|
||||
}
|
||||
13
internal/container/stripansi.go
Normal file
13
internal/container/stripansi.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
|
||||
|
||||
var re = regexp.MustCompile(ansi)
|
||||
|
||||
func stripANSI(str string) string {
|
||||
return re.ReplaceAllString(str, "")
|
||||
}
|
||||
118
internal/container/types.go
Normal file
118
internal/container/types.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/utils"
|
||||
)
|
||||
|
||||
// Container represents an internal representation of docker containers
|
||||
type Container struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Command string `json:"command"`
|
||||
Created time.Time `json:"created"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
State string `json:"state"`
|
||||
Health string `json:"health,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Tty bool `json:"-"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Stats *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"`
|
||||
Group string `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
// ContainerStat represent stats instant for a container
|
||||
type ContainerStat struct {
|
||||
ID string `json:"id"`
|
||||
CPUPercent float64 `json:"cpu"`
|
||||
MemoryPercent float64 `json:"memory"`
|
||||
MemoryUsage float64 `json:"memoryUsage"`
|
||||
}
|
||||
|
||||
// ContainerEvent represents events that are triggered
|
||||
type ContainerEvent struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
ActorID string `json:"actorId"`
|
||||
ActorAttributes map[string]string `json:"actorAttributes,omitempty"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
type ContainerFilter map[string][]string
|
||||
|
||||
func ParseContainerFilter(commaValues string) (ContainerFilter, error) {
|
||||
filter := make(ContainerFilter)
|
||||
if commaValues == "" {
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
for _, val := range strings.Split(commaValues, ",") {
|
||||
pos := strings.Index(val, "=")
|
||||
if pos == -1 {
|
||||
return nil, fmt.Errorf("invalid filter: %s", filter)
|
||||
}
|
||||
key := val[:pos]
|
||||
val := val[pos+1:]
|
||||
filter[key] = append(filter[key], val)
|
||||
}
|
||||
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func (f ContainerFilter) Exists() bool {
|
||||
return len(f) > 0
|
||||
}
|
||||
|
||||
type LogPosition string
|
||||
|
||||
const (
|
||||
Beginning LogPosition = "start"
|
||||
Middle LogPosition = "middle"
|
||||
End LogPosition = "end"
|
||||
)
|
||||
|
||||
type ContainerAction string
|
||||
|
||||
const (
|
||||
Start ContainerAction = "start"
|
||||
Stop ContainerAction = "stop"
|
||||
Restart ContainerAction = "restart"
|
||||
)
|
||||
|
||||
func ParseContainerAction(input string) (ContainerAction, error) {
|
||||
action := ContainerAction(input)
|
||||
switch action {
|
||||
case Start, Stop, Restart:
|
||||
return action, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action: %s", input)
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
ContainerID string `json:"c,omitempty"`
|
||||
}
|
||||
|
||||
func (l *LogEvent) HasLevel() bool {
|
||||
return l.Level != "unknown"
|
||||
}
|
||||
|
||||
func (l *LogEvent) IsCloseToTime(other *LogEvent) bool {
|
||||
return math.Abs(float64(l.Timestamp-other.Timestamp)) < 10
|
||||
}
|
||||
|
||||
func (l *LogEvent) MessageId() int64 {
|
||||
return l.Timestamp
|
||||
}
|
||||
Reference in New Issue
Block a user