1
0
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:
Amir Raminfar
2025-02-03 12:42:09 -08:00
committed by GitHub
parent 9f7b17f4ec
commit 5f73b41c57
45 changed files with 504 additions and 503 deletions

View 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
}

View 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
}
}
}

View 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")
}

View 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))
}
}

View 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
}
}

View 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)
}
}

View 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
}

View 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"
}

View 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)
}
})
}
}

View 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
}

View 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))
}
})
}
}

View 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
})
}
}
}

View 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")
}

View 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
View 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
}