detached 73d027b

feat: implement container log collection with rotation

Re-implement the container log collection feature that was lost during
clean architecture refactoring. Container stdout/stderr is now streamed
to per-container log files with rotation support.

Architecture:
- New ContainerLogWriter boundary interface in boundaries/out/
- LogWriter adapter using lumberjack for rotation (100MB, 3 backups, 28 days)
- Integration with container lifecycle (Deploy starts logging, Stop/Remove/Shutdown stops it)

Features:
- Per-container log files at ~/.local/share/gordon/logs/containers/{domain}.log
- Non-blocking: log collection failures don't fail deployment
- Handles Docker multiplex header format (stdcopy.StdCopy)
- Domain-based filenames (sanitized: app.example.com -> app_example_com.log)

Configuration (logging.container_logs):
- enabled: true (default)
- dir: "" (defaults to {data_dir}/logs/containers)
- max_size: 100 (MB)
- max_backups: 3
- max_age: 28 (days)

Files:
- boundaries/out/logwriter.go: ContainerLogWriter interface
- adapters/out/logwriter/: Implementation with 12 unit tests
- app/run.go: Config and wiring
- usecase/container/service.go: Lifecycle integration
commit
73d027bd53c89ac1ca441d46002f7f2e5d171b36
author
brice · · 2026-01-12 20:56 UTC
committer
brice · · 2026-01-12 21:03 UTC
parent
314c423
signature
unsigned
8 files · +802 -17
M .mockery.yaml +1 -0
@@ -18,6 +18,7 @@ packages:
1818 EventHandler:
1919 SecretProvider:
2020 EnvLoader:
21+ ContainerLogWriter:
2122 gordon/internal/boundaries/in:
2223 interfaces:
2324 ContainerService:
@@ -18,6 +18,7 @@ packages:
18 EventHandler:18 EventHandler:
19 SecretProvider:19 SecretProvider:
20 EnvLoader:20 EnvLoader:
21+ ContainerLogWriter:
21 gordon/internal/boundaries/in:22 gordon/internal/boundaries/in:
22 interfaces:23 interfaces:
23 ContainerService:24 ContainerService:
A internal/adapters/out/logwriter/logwriter.go +179 -0
@@ -0,0 +1,179 @@
1+// Package logwriter provides container log collection with file rotation.
2+package logwriter
3+
4+import (
5+ "context"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/docker/docker/pkg/stdcopy"
14+ "gopkg.in/natefinch/lumberjack.v2"
15+)
16+
17+// Config holds the configuration for the log writer.
18+type Config struct {
19+ // Dir is the directory where container logs are stored.
20+ Dir string
21+ // MaxSize is the maximum size in megabytes before rotation.
22+ MaxSize int
23+ // MaxBackups is the number of old log files to retain.
24+ MaxBackups int
25+ // MaxAge is the maximum number of days to retain old log files.
26+ MaxAge int
27+}
28+
29+// LogWriter implements the ContainerLogWriter interface.
30+type LogWriter struct {
31+ config Config
32+ streams map[string]*streamInfo
33+ mu sync.RWMutex
34+}
35+
36+// streamInfo tracks an active log stream for a container.
37+type streamInfo struct {
38+ containerID string
39+ domain string
40+ logStream io.ReadCloser
41+ logger *lumberjack.Logger
42+ cancel context.CancelFunc
43+ done chan struct{}
44+}
45+
46+// New creates a new LogWriter.
47+func New(config Config) (*LogWriter, error) {
48+ // Ensure the log directory exists
49+ if err := os.MkdirAll(config.Dir, 0700); err != nil {
50+ return nil, err
51+ }
52+
53+ return &LogWriter{
54+ config: config,
55+ streams: make(map[string]*streamInfo),
56+ }, nil
57+}
58+
59+// StartLogging begins collecting logs for a container.
60+func (w *LogWriter) StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error {
61+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
62+ zerowrap.FieldLayer: "adapter",
63+ zerowrap.FieldAdapter: "logwriter",
64+ zerowrap.FieldAction: "StartLogging",
65+ zerowrap.FieldEntityID: containerID,
66+ "domain": domain,
67+ })
68+ log := zerowrap.FromCtx(ctx)
69+
70+ w.mu.Lock()
71+ defer w.mu.Unlock()
72+
73+ // Stop existing logging for this container if any
74+ if existing, ok := w.streams[containerID]; ok {
75+ log.Debug().Msg("stopping existing log stream before starting new one")
76+ w.stopStreamLocked(existing)
77+ }
78+
79+ // Sanitize domain for filename
80+ filename := sanitizeDomain(domain) + ".log"
81+ logPath := filepath.Join(w.config.Dir, filename)
82+
83+ // Create lumberjack logger for rotation
84+ logger := &lumberjack.Logger{
85+ Filename: logPath,
86+ MaxSize: w.config.MaxSize,
87+ MaxBackups: w.config.MaxBackups,
88+ MaxAge: w.config.MaxAge,
89+ Compress: true,
90+ }
91+
92+ // Create cancellable context for the streaming goroutine
93+ streamCtx, cancel := context.WithCancel(context.Background())
94+ done := make(chan struct{})
95+
96+ info := &streamInfo{
97+ containerID: containerID,
98+ domain: domain,
99+ logStream: logStream,
100+ logger: logger,
101+ cancel: cancel,
102+ done: done,
103+ }
104+
105+ w.streams[containerID] = info
106+
107+ // Start streaming goroutine
108+ go w.streamLogs(streamCtx, info, log)
109+
110+ log.Info().Str("path", logPath).Msg("started container log collection")
111+ return nil
112+}
113+
114+// StopLogging stops log collection for a container.
115+func (w *LogWriter) StopLogging(containerID string) error {
116+ w.mu.Lock()
117+ defer w.mu.Unlock()
118+
119+ if info, ok := w.streams[containerID]; ok {
120+ w.stopStreamLocked(info)
121+ delete(w.streams, containerID)
122+ }
123+ return nil
124+}
125+
126+// Close stops all logging and releases resources.
127+func (w *LogWriter) Close() error {
128+ w.mu.Lock()
129+ defer w.mu.Unlock()
130+
131+ for containerID, info := range w.streams {
132+ w.stopStreamLocked(info)
133+ delete(w.streams, containerID)
134+ }
135+ return nil
136+}
137+
138+// stopStreamLocked stops a stream (must be called with lock held).
139+func (w *LogWriter) stopStreamLocked(info *streamInfo) {
140+ // Signal the goroutine to stop
141+ info.cancel()
142+
143+ // Close the log stream to unblock reads
144+ info.logStream.Close()
145+
146+ // Wait for goroutine to finish
147+ <-info.done
148+
149+ // Close the lumberjack logger
150+ info.logger.Close()
151+}
152+
153+// streamLogs copies from the Docker log stream to the file.
154+func (w *LogWriter) streamLogs(ctx context.Context, info *streamInfo, log zerowrap.Logger) {
155+ defer close(info.done)
156+
157+ // Docker container logs are multiplexed with 8-byte headers.
158+ // stdcopy.StdCopy demultiplexes them to separate stdout/stderr writers.
159+ // We combine both to a single file.
160+ _, err := stdcopy.StdCopy(info.logger, info.logger, info.logStream)
161+
162+ if err != nil && ctx.Err() == nil {
163+ // Only log error if we weren't cancelled
164+ log.Warn().Err(err).
165+ Str("container_id", info.containerID).
166+ Str("domain", info.domain).
167+ Msg("container log streaming ended with error")
168+ }
169+}
170+
171+// sanitizeDomain converts a domain to a safe filename.
172+func sanitizeDomain(domain string) string {
173+ // Replace dots with underscores, remove other unsafe characters
174+ safe := strings.ReplaceAll(domain, ".", "_")
175+ safe = strings.ReplaceAll(safe, "/", "_")
176+ safe = strings.ReplaceAll(safe, ":", "_")
177+ safe = strings.ReplaceAll(safe, " ", "_")
178+ return safe
179+}
@@ -0,0 +1,179 @@
1+// Package logwriter provides container log collection with file rotation.
2+package logwriter
3+
4+import (
5+ "context"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/docker/docker/pkg/stdcopy"
14+ "gopkg.in/natefinch/lumberjack.v2"
15+)
16+
17+// Config holds the configuration for the log writer.
18+type Config struct {
19+ // Dir is the directory where container logs are stored.
20+ Dir string
21+ // MaxSize is the maximum size in megabytes before rotation.
22+ MaxSize int
23+ // MaxBackups is the number of old log files to retain.
24+ MaxBackups int
25+ // MaxAge is the maximum number of days to retain old log files.
26+ MaxAge int
27+}
28+
29+// LogWriter implements the ContainerLogWriter interface.
30+type LogWriter struct {
31+ config Config
32+ streams map[string]*streamInfo
33+ mu sync.RWMutex
34+}
35+
36+// streamInfo tracks an active log stream for a container.
37+type streamInfo struct {
38+ containerID string
39+ domain string
40+ logStream io.ReadCloser
41+ logger *lumberjack.Logger
42+ cancel context.CancelFunc
43+ done chan struct{}
44+}
45+
46+// New creates a new LogWriter.
47+func New(config Config) (*LogWriter, error) {
48+ // Ensure the log directory exists
49+ if err := os.MkdirAll(config.Dir, 0700); err != nil {
50+ return nil, err
51+ }
52+
53+ return &LogWriter{
54+ config: config,
55+ streams: make(map[string]*streamInfo),
56+ }, nil
57+}
58+
59+// StartLogging begins collecting logs for a container.
60+func (w *LogWriter) StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error {
61+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
62+ zerowrap.FieldLayer: "adapter",
63+ zerowrap.FieldAdapter: "logwriter",
64+ zerowrap.FieldAction: "StartLogging",
65+ zerowrap.FieldEntityID: containerID,
66+ "domain": domain,
67+ })
68+ log := zerowrap.FromCtx(ctx)
69+
70+ w.mu.Lock()
71+ defer w.mu.Unlock()
72+
73+ // Stop existing logging for this container if any
74+ if existing, ok := w.streams[containerID]; ok {
75+ log.Debug().Msg("stopping existing log stream before starting new one")
76+ w.stopStreamLocked(existing)
77+ }
78+
79+ // Sanitize domain for filename
80+ filename := sanitizeDomain(domain) + ".log"
81+ logPath := filepath.Join(w.config.Dir, filename)
82+
83+ // Create lumberjack logger for rotation
84+ logger := &lumberjack.Logger{
85+ Filename: logPath,
86+ MaxSize: w.config.MaxSize,
87+ MaxBackups: w.config.MaxBackups,
88+ MaxAge: w.config.MaxAge,
89+ Compress: true,
90+ }
91+
92+ // Create cancellable context for the streaming goroutine
93+ streamCtx, cancel := context.WithCancel(context.Background())
94+ done := make(chan struct{})
95+
96+ info := &streamInfo{
97+ containerID: containerID,
98+ domain: domain,
99+ logStream: logStream,
100+ logger: logger,
101+ cancel: cancel,
102+ done: done,
103+ }
104+
105+ w.streams[containerID] = info
106+
107+ // Start streaming goroutine
108+ go w.streamLogs(streamCtx, info, log)
109+
110+ log.Info().Str("path", logPath).Msg("started container log collection")
111+ return nil
112+}
113+
114+// StopLogging stops log collection for a container.
115+func (w *LogWriter) StopLogging(containerID string) error {
116+ w.mu.Lock()
117+ defer w.mu.Unlock()
118+
119+ if info, ok := w.streams[containerID]; ok {
120+ w.stopStreamLocked(info)
121+ delete(w.streams, containerID)
122+ }
123+ return nil
124+}
125+
126+// Close stops all logging and releases resources.
127+func (w *LogWriter) Close() error {
128+ w.mu.Lock()
129+ defer w.mu.Unlock()
130+
131+ for containerID, info := range w.streams {
132+ w.stopStreamLocked(info)
133+ delete(w.streams, containerID)
134+ }
135+ return nil
136+}
137+
138+// stopStreamLocked stops a stream (must be called with lock held).
139+func (w *LogWriter) stopStreamLocked(info *streamInfo) {
140+ // Signal the goroutine to stop
141+ info.cancel()
142+
143+ // Close the log stream to unblock reads
144+ info.logStream.Close()
145+
146+ // Wait for goroutine to finish
147+ <-info.done
148+
149+ // Close the lumberjack logger
150+ info.logger.Close()
151+}
152+
153+// streamLogs copies from the Docker log stream to the file.
154+func (w *LogWriter) streamLogs(ctx context.Context, info *streamInfo, log zerowrap.Logger) {
155+ defer close(info.done)
156+
157+ // Docker container logs are multiplexed with 8-byte headers.
158+ // stdcopy.StdCopy demultiplexes them to separate stdout/stderr writers.
159+ // We combine both to a single file.
160+ _, err := stdcopy.StdCopy(info.logger, info.logger, info.logStream)
161+
162+ if err != nil && ctx.Err() == nil {
163+ // Only log error if we weren't cancelled
164+ log.Warn().Err(err).
165+ Str("container_id", info.containerID).
166+ Str("domain", info.domain).
167+ Msg("container log streaming ended with error")
168+ }
169+}
170+
171+// sanitizeDomain converts a domain to a safe filename.
172+func sanitizeDomain(domain string) string {
173+ // Replace dots with underscores, remove other unsafe characters
174+ safe := strings.ReplaceAll(domain, ".", "_")
175+ safe = strings.ReplaceAll(safe, "/", "_")
176+ safe = strings.ReplaceAll(safe, ":", "_")
177+ safe = strings.ReplaceAll(safe, " ", "_")
178+ return safe
179+}
A internal/adapters/out/logwriter/logwriter_test.go +283 -0
@@ -0,0 +1,283 @@
1+package logwriter
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestNew(t *testing.T) {
18+ t.Run("creates log directory", func(t *testing.T) {
19+ dir := t.TempDir()
20+ logDir := filepath.Join(dir, "containers")
21+
22+ writer, err := New(Config{
23+ Dir: logDir,
24+ MaxSize: 100,
25+ MaxBackups: 3,
26+ MaxAge: 28,
27+ })
28+
29+ require.NoError(t, err)
30+ require.NotNil(t, writer)
31+ defer writer.Close()
32+
33+ // Verify directory was created
34+ info, err := os.Stat(logDir)
35+ require.NoError(t, err)
36+ assert.True(t, info.IsDir())
37+ })
38+
39+ t.Run("uses existing directory", func(t *testing.T) {
40+ logDir := t.TempDir()
41+
42+ writer, err := New(Config{
43+ Dir: logDir,
44+ MaxSize: 100,
45+ MaxBackups: 3,
46+ MaxAge: 28,
47+ })
48+
49+ require.NoError(t, err)
50+ require.NotNil(t, writer)
51+ defer writer.Close()
52+ })
53+}
54+
55+func TestStartLogging(t *testing.T) {
56+ t.Run("creates log file and writes data", func(t *testing.T) {
57+ logDir := t.TempDir()
58+ writer, err := New(Config{
59+ Dir: logDir,
60+ MaxSize: 100,
61+ MaxBackups: 3,
62+ MaxAge: 28,
63+ })
64+ require.NoError(t, err)
65+ defer writer.Close()
66+
67+ // Create a mock log stream with Docker multiplex format
68+ // Format: 1 byte stream type, 3 bytes reserved, 4 bytes size (big endian), then data
69+ logData := createDockerLogEntry(1, "Hello from container\n")
70+ logStream := io.NopCloser(bytes.NewReader(logData))
71+
72+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream)
73+ require.NoError(t, err)
74+
75+ // Wait for the goroutine to process
76+ time.Sleep(100 * time.Millisecond)
77+
78+ // Verify log file was created
79+ logFile := filepath.Join(logDir, "app_example_com.log")
80+ content, err := os.ReadFile(logFile)
81+ require.NoError(t, err)
82+ assert.Contains(t, string(content), "Hello from container")
83+ })
84+
85+ t.Run("replaces existing log stream for same container", func(t *testing.T) {
86+ logDir := t.TempDir()
87+ writer, err := New(Config{
88+ Dir: logDir,
89+ MaxSize: 100,
90+ MaxBackups: 3,
91+ MaxAge: 28,
92+ })
93+ require.NoError(t, err)
94+ defer writer.Close()
95+
96+ // Start first logging
97+ logData1 := createDockerLogEntry(1, "First log\n")
98+ logStream1 := io.NopCloser(bytes.NewReader(logData1))
99+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream1)
100+ require.NoError(t, err)
101+
102+ time.Sleep(50 * time.Millisecond)
103+
104+ // Start second logging for same container
105+ logData2 := createDockerLogEntry(1, "Second log\n")
106+ logStream2 := io.NopCloser(bytes.NewReader(logData2))
107+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream2)
108+ require.NoError(t, err)
109+
110+ time.Sleep(100 * time.Millisecond)
111+
112+ // Should have both logs (file is appended)
113+ logFile := filepath.Join(logDir, "app_example_com.log")
114+ content, err := os.ReadFile(logFile)
115+ require.NoError(t, err)
116+ assert.Contains(t, string(content), "Second log")
117+ })
118+}
119+
120+func TestStopLogging(t *testing.T) {
121+ t.Run("stops log collection", func(t *testing.T) {
122+ logDir := t.TempDir()
123+ writer, err := New(Config{
124+ Dir: logDir,
125+ MaxSize: 100,
126+ MaxBackups: 3,
127+ MaxAge: 28,
128+ })
129+ require.NoError(t, err)
130+ defer writer.Close()
131+
132+ // Use a pipe so we can control the stream
133+ pr, pw := io.Pipe()
134+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", pr)
135+ require.NoError(t, err)
136+
137+ // Write some data
138+ go func() {
139+ pw.Write(createDockerLogEntry(1, "Test log\n"))
140+ // Keep pipe open until stopped
141+ }()
142+
143+ time.Sleep(50 * time.Millisecond)
144+
145+ // Stop logging
146+ err = writer.StopLogging("container-123")
147+ require.NoError(t, err)
148+
149+ // Clean up pipe
150+ pw.Close()
151+ })
152+
153+ t.Run("noop for non-existent container", func(t *testing.T) {
154+ logDir := t.TempDir()
155+ writer, err := New(Config{
156+ Dir: logDir,
157+ MaxSize: 100,
158+ MaxBackups: 3,
159+ MaxAge: 28,
160+ })
161+ require.NoError(t, err)
162+ defer writer.Close()
163+
164+ // Should not error
165+ err = writer.StopLogging("non-existent")
166+ require.NoError(t, err)
167+ })
168+}
169+
170+func TestClose(t *testing.T) {
171+ t.Run("stops all log streams", func(t *testing.T) {
172+ logDir := t.TempDir()
173+ writer, err := New(Config{
174+ Dir: logDir,
175+ MaxSize: 100,
176+ MaxBackups: 3,
177+ MaxAge: 28,
178+ })
179+ require.NoError(t, err)
180+
181+ // Start multiple log streams
182+ for i := 0; i < 3; i++ {
183+ pr, pw := io.Pipe()
184+ containerID := "container-" + string(rune('a'+i))
185+ domain := "app" + string(rune('0'+i)) + ".example.com"
186+ err := writer.StartLogging(context.Background(), containerID, domain, pr)
187+ require.NoError(t, err)
188+
189+ // Keep pipe alive briefly
190+ go func(pw *io.PipeWriter) {
191+ time.Sleep(50 * time.Millisecond)
192+ pw.Close()
193+ }(pw)
194+ }
195+
196+ time.Sleep(25 * time.Millisecond)
197+
198+ // Close should stop all
199+ err = writer.Close()
200+ require.NoError(t, err)
201+
202+ // Verify all streams are gone
203+ assert.Empty(t, writer.streams)
204+ })
205+}
206+
207+func TestSanitizeDomain(t *testing.T) {
208+ tests := []struct {
209+ input string
210+ expected string
211+ }{
212+ {"app.example.com", "app_example_com"},
213+ {"api.v1.example.com", "api_v1_example_com"},
214+ {"localhost:8080", "localhost_8080"},
215+ {"app/service", "app_service"},
216+ {"app with spaces", "app_with_spaces"},
217+ {"simple", "simple"},
218+ }
219+
220+ for _, tc := range tests {
221+ t.Run(tc.input, func(t *testing.T) {
222+ result := sanitizeDomain(tc.input)
223+ assert.Equal(t, tc.expected, result)
224+ })
225+ }
226+}
227+
228+func TestMultipleContainers(t *testing.T) {
229+ logDir := t.TempDir()
230+ writer, err := New(Config{
231+ Dir: logDir,
232+ MaxSize: 100,
233+ MaxBackups: 3,
234+ MaxAge: 28,
235+ })
236+ require.NoError(t, err)
237+ defer writer.Close()
238+
239+ // Start logging for multiple containers
240+ containers := []struct {
241+ id string
242+ domain string
243+ msg string
244+ }{
245+ {"container-1", "app1.example.com", "Log from app1"},
246+ {"container-2", "app2.example.com", "Log from app2"},
247+ {"container-3", "api.example.com", "Log from api"},
248+ }
249+
250+ for _, c := range containers {
251+ logData := createDockerLogEntry(1, c.msg+"\n")
252+ logStream := io.NopCloser(bytes.NewReader(logData))
253+ err := writer.StartLogging(context.Background(), c.id, c.domain, logStream)
254+ require.NoError(t, err)
255+ }
256+
257+ // Wait for processing
258+ time.Sleep(100 * time.Millisecond)
259+
260+ // Verify each container has its own log file
261+ for _, c := range containers {
262+ filename := strings.ReplaceAll(c.domain, ".", "_") + ".log"
263+ logFile := filepath.Join(logDir, filename)
264+ content, err := os.ReadFile(logFile)
265+ require.NoError(t, err, "Log file should exist for %s", c.domain)
266+ assert.Contains(t, string(content), c.msg)
267+ }
268+}
269+
270+// createDockerLogEntry creates a Docker multiplexed log entry.
271+// streamType: 0=stdin, 1=stdout, 2=stderr
272+func createDockerLogEntry(streamType byte, data string) []byte {
273+ size := len(data)
274+ header := make([]byte, 8)
275+ header[0] = streamType
276+ // bytes 1-3 are reserved (zeros)
277+ header[4] = byte(size >> 24)
278+ header[5] = byte(size >> 16)
279+ header[6] = byte(size >> 8)
280+ header[7] = byte(size)
281+
282+ return append(header, []byte(data)...)
283+}
@@ -0,0 +1,283 @@
1+package logwriter
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestNew(t *testing.T) {
18+ t.Run("creates log directory", func(t *testing.T) {
19+ dir := t.TempDir()
20+ logDir := filepath.Join(dir, "containers")
21+
22+ writer, err := New(Config{
23+ Dir: logDir,
24+ MaxSize: 100,
25+ MaxBackups: 3,
26+ MaxAge: 28,
27+ })
28+
29+ require.NoError(t, err)
30+ require.NotNil(t, writer)
31+ defer writer.Close()
32+
33+ // Verify directory was created
34+ info, err := os.Stat(logDir)
35+ require.NoError(t, err)
36+ assert.True(t, info.IsDir())
37+ })
38+
39+ t.Run("uses existing directory", func(t *testing.T) {
40+ logDir := t.TempDir()
41+
42+ writer, err := New(Config{
43+ Dir: logDir,
44+ MaxSize: 100,
45+ MaxBackups: 3,
46+ MaxAge: 28,
47+ })
48+
49+ require.NoError(t, err)
50+ require.NotNil(t, writer)
51+ defer writer.Close()
52+ })
53+}
54+
55+func TestStartLogging(t *testing.T) {
56+ t.Run("creates log file and writes data", func(t *testing.T) {
57+ logDir := t.TempDir()
58+ writer, err := New(Config{
59+ Dir: logDir,
60+ MaxSize: 100,
61+ MaxBackups: 3,
62+ MaxAge: 28,
63+ })
64+ require.NoError(t, err)
65+ defer writer.Close()
66+
67+ // Create a mock log stream with Docker multiplex format
68+ // Format: 1 byte stream type, 3 bytes reserved, 4 bytes size (big endian), then data
69+ logData := createDockerLogEntry(1, "Hello from container\n")
70+ logStream := io.NopCloser(bytes.NewReader(logData))
71+
72+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream)
73+ require.NoError(t, err)
74+
75+ // Wait for the goroutine to process
76+ time.Sleep(100 * time.Millisecond)
77+
78+ // Verify log file was created
79+ logFile := filepath.Join(logDir, "app_example_com.log")
80+ content, err := os.ReadFile(logFile)
81+ require.NoError(t, err)
82+ assert.Contains(t, string(content), "Hello from container")
83+ })
84+
85+ t.Run("replaces existing log stream for same container", func(t *testing.T) {
86+ logDir := t.TempDir()
87+ writer, err := New(Config{
88+ Dir: logDir,
89+ MaxSize: 100,
90+ MaxBackups: 3,
91+ MaxAge: 28,
92+ })
93+ require.NoError(t, err)
94+ defer writer.Close()
95+
96+ // Start first logging
97+ logData1 := createDockerLogEntry(1, "First log\n")
98+ logStream1 := io.NopCloser(bytes.NewReader(logData1))
99+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream1)
100+ require.NoError(t, err)
101+
102+ time.Sleep(50 * time.Millisecond)
103+
104+ // Start second logging for same container
105+ logData2 := createDockerLogEntry(1, "Second log\n")
106+ logStream2 := io.NopCloser(bytes.NewReader(logData2))
107+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream2)
108+ require.NoError(t, err)
109+
110+ time.Sleep(100 * time.Millisecond)
111+
112+ // Should have both logs (file is appended)
113+ logFile := filepath.Join(logDir, "app_example_com.log")
114+ content, err := os.ReadFile(logFile)
115+ require.NoError(t, err)
116+ assert.Contains(t, string(content), "Second log")
117+ })
118+}
119+
120+func TestStopLogging(t *testing.T) {
121+ t.Run("stops log collection", func(t *testing.T) {
122+ logDir := t.TempDir()
123+ writer, err := New(Config{
124+ Dir: logDir,
125+ MaxSize: 100,
126+ MaxBackups: 3,
127+ MaxAge: 28,
128+ })
129+ require.NoError(t, err)
130+ defer writer.Close()
131+
132+ // Use a pipe so we can control the stream
133+ pr, pw := io.Pipe()
134+ err = writer.StartLogging(context.Background(), "container-123", "app.example.com", pr)
135+ require.NoError(t, err)
136+
137+ // Write some data
138+ go func() {
139+ pw.Write(createDockerLogEntry(1, "Test log\n"))
140+ // Keep pipe open until stopped
141+ }()
142+
143+ time.Sleep(50 * time.Millisecond)
144+
145+ // Stop logging
146+ err = writer.StopLogging("container-123")
147+ require.NoError(t, err)
148+
149+ // Clean up pipe
150+ pw.Close()
151+ })
152+
153+ t.Run("noop for non-existent container", func(t *testing.T) {
154+ logDir := t.TempDir()
155+ writer, err := New(Config{
156+ Dir: logDir,
157+ MaxSize: 100,
158+ MaxBackups: 3,
159+ MaxAge: 28,
160+ })
161+ require.NoError(t, err)
162+ defer writer.Close()
163+
164+ // Should not error
165+ err = writer.StopLogging("non-existent")
166+ require.NoError(t, err)
167+ })
168+}
169+
170+func TestClose(t *testing.T) {
171+ t.Run("stops all log streams", func(t *testing.T) {
172+ logDir := t.TempDir()
173+ writer, err := New(Config{
174+ Dir: logDir,
175+ MaxSize: 100,
176+ MaxBackups: 3,
177+ MaxAge: 28,
178+ })
179+ require.NoError(t, err)
180+
181+ // Start multiple log streams
182+ for i := 0; i < 3; i++ {
183+ pr, pw := io.Pipe()
184+ containerID := "container-" + string(rune('a'+i))
185+ domain := "app" + string(rune('0'+i)) + ".example.com"
186+ err := writer.StartLogging(context.Background(), containerID, domain, pr)
187+ require.NoError(t, err)
188+
189+ // Keep pipe alive briefly
190+ go func(pw *io.PipeWriter) {
191+ time.Sleep(50 * time.Millisecond)
192+ pw.Close()
193+ }(pw)
194+ }
195+
196+ time.Sleep(25 * time.Millisecond)
197+
198+ // Close should stop all
199+ err = writer.Close()
200+ require.NoError(t, err)
201+
202+ // Verify all streams are gone
203+ assert.Empty(t, writer.streams)
204+ })
205+}
206+
207+func TestSanitizeDomain(t *testing.T) {
208+ tests := []struct {
209+ input string
210+ expected string
211+ }{
212+ {"app.example.com", "app_example_com"},
213+ {"api.v1.example.com", "api_v1_example_com"},
214+ {"localhost:8080", "localhost_8080"},
215+ {"app/service", "app_service"},
216+ {"app with spaces", "app_with_spaces"},
217+ {"simple", "simple"},
218+ }
219+
220+ for _, tc := range tests {
221+ t.Run(tc.input, func(t *testing.T) {
222+ result := sanitizeDomain(tc.input)
223+ assert.Equal(t, tc.expected, result)
224+ })
225+ }
226+}
227+
228+func TestMultipleContainers(t *testing.T) {
229+ logDir := t.TempDir()
230+ writer, err := New(Config{
231+ Dir: logDir,
232+ MaxSize: 100,
233+ MaxBackups: 3,
234+ MaxAge: 28,
235+ })
236+ require.NoError(t, err)
237+ defer writer.Close()
238+
239+ // Start logging for multiple containers
240+ containers := []struct {
241+ id string
242+ domain string
243+ msg string
244+ }{
245+ {"container-1", "app1.example.com", "Log from app1"},
246+ {"container-2", "app2.example.com", "Log from app2"},
247+ {"container-3", "api.example.com", "Log from api"},
248+ }
249+
250+ for _, c := range containers {
251+ logData := createDockerLogEntry(1, c.msg+"\n")
252+ logStream := io.NopCloser(bytes.NewReader(logData))
253+ err := writer.StartLogging(context.Background(), c.id, c.domain, logStream)
254+ require.NoError(t, err)
255+ }
256+
257+ // Wait for processing
258+ time.Sleep(100 * time.Millisecond)
259+
260+ // Verify each container has its own log file
261+ for _, c := range containers {
262+ filename := strings.ReplaceAll(c.domain, ".", "_") + ".log"
263+ logFile := filepath.Join(logDir, filename)
264+ content, err := os.ReadFile(logFile)
265+ require.NoError(t, err, "Log file should exist for %s", c.domain)
266+ assert.Contains(t, string(content), c.msg)
267+ }
268+}
269+
270+// createDockerLogEntry creates a Docker multiplexed log entry.
271+// streamType: 0=stdin, 1=stdout, 2=stderr
272+func createDockerLogEntry(streamType byte, data string) []byte {
273+ size := len(data)
274+ header := make([]byte, 8)
275+ header[0] = streamType
276+ // bytes 1-3 are reserved (zeros)
277+ header[4] = byte(size >> 24)
278+ header[5] = byte(size >> 16)
279+ header[6] = byte(size >> 8)
280+ header[7] = byte(size)
281+
282+ return append(header, []byte(data)...)
283+}
M internal/app/run.go +51 -1
@@ -20,6 +20,7 @@ import (
2020 "gordon/internal/adapters/out/envloader"
2121 "gordon/internal/adapters/out/eventbus"
2222 "gordon/internal/adapters/out/filesystem"
23+ "gordon/internal/adapters/out/logwriter"
2324 "gordon/internal/adapters/out/secrets"
2425
2526 // Adapters - Input
@@ -52,6 +53,13 @@ type Config struct {
5253 MaxBackups int `mapstructure:"max_backups"`
5354 MaxAge int `mapstructure:"max_age"`
5455 } `mapstructure:"file"`
56+ ContainerLogs struct {
57+ Enabled bool `mapstructure:"enabled"`
58+ Dir string `mapstructure:"dir"`
59+ MaxSize int `mapstructure:"max_size"`
60+ MaxBackups int `mapstructure:"max_backups"`
61+ MaxAge int `mapstructure:"max_age"`
62+ } `mapstructure:"container_logs"`
5563 } `mapstructure:"logging"`
5664
5765 Env struct {
@@ -72,6 +80,7 @@ type services struct {
7280 blobStorage *filesystem.BlobStorage
7381 manifestStorage *filesystem.ManifestStorage
7482 envLoader *envloader.FileLoader
83+ logWriter *logwriter.LogWriter
7584 configSvc *config.Service
7685 containerSvc *container.Service
7786 registrySvc *registrySvc.Service
@@ -194,6 +203,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
194203 return nil, err
195204 }
196205
206+ // Create log writer
207+ if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
208+ return nil, err
209+ }
210+
197211 // Create use case services
198212 svc.configSvc = config.NewService(v, svc.eventBus)
199213 if err := svc.configSvc.Load(ctx); err != nil {
@@ -284,6 +298,37 @@ func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, er
284298 return envLoader, nil
285299 }
286300
301+// createLogWriter creates the container log writer.
302+func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
303+ if !cfg.Logging.ContainerLogs.Enabled {
304+ log.Debug().Msg("container log collection disabled")
305+ return nil, nil
306+ }
307+
308+ // Determine log directory
309+ logDir := cfg.Logging.ContainerLogs.Dir
310+ if logDir == "" {
311+ dataDir := cfg.Server.DataDir
312+ if dataDir == "" {
313+ dataDir = "/var/lib/gordon"
314+ }
315+ logDir = filepath.Join(dataDir, "logs", "containers")
316+ }
317+
318+ writer, err := logwriter.New(logwriter.Config{
319+ Dir: logDir,
320+ MaxSize: cfg.Logging.ContainerLogs.MaxSize,
321+ MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
322+ MaxAge: cfg.Logging.ContainerLogs.MaxAge,
323+ })
324+ if err != nil {
325+ return nil, log.WrapErr(err, "failed to create container log writer")
326+ }
327+
328+ log.Info().Str("dir", logDir).Msg("container log collection enabled")
329+ return writer, nil
330+}
331+
287332 // createContainerService creates the container service with configuration.
288333 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
289334 containerConfig := container.Config{
@@ -300,7 +345,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
300345 NetworkGroups: svc.configSvc.GetNetworkGroups(),
301346 Attachments: svc.configSvc.GetAttachments(),
302347 }
303- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, containerConfig)
348+ return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
304349 }
305350
306351 // registerEventHandlers registers all event handlers.
@@ -521,6 +566,11 @@ func loadConfig(v *viper.Viper, configPath string) error {
521566 v.SetDefault("logging.file.max_size", 100)
522567 v.SetDefault("logging.file.max_backups", 3)
523568 v.SetDefault("logging.file.max_age", 28)
569+ v.SetDefault("logging.container_logs.enabled", true)
570+ v.SetDefault("logging.container_logs.dir", "")
571+ v.SetDefault("logging.container_logs.max_size", 100)
572+ v.SetDefault("logging.container_logs.max_backups", 3)
573+ v.SetDefault("logging.container_logs.max_age", 28)
524574 v.SetDefault("env.dir", "/var/lib/gordon/env")
525575 v.SetDefault("registry_auth.enabled", false)
526576
@@ -20,6 +20,7 @@ import (
20 "gordon/internal/adapters/out/envloader"20 "gordon/internal/adapters/out/envloader"
21 "gordon/internal/adapters/out/eventbus"21 "gordon/internal/adapters/out/eventbus"
22 "gordon/internal/adapters/out/filesystem"22 "gordon/internal/adapters/out/filesystem"
23+ "gordon/internal/adapters/out/logwriter"
23 "gordon/internal/adapters/out/secrets"24 "gordon/internal/adapters/out/secrets"
24 25
25 // Adapters - Input26 // Adapters - Input
@@ -52,6 +53,13 @@ type Config struct {
52 MaxBackups int `mapstructure:"max_backups"`53 MaxBackups int `mapstructure:"max_backups"`
53 MaxAge int `mapstructure:"max_age"`54 MaxAge int `mapstructure:"max_age"`
54 } `mapstructure:"file"`55 } `mapstructure:"file"`
56+ ContainerLogs struct {
57+ Enabled bool `mapstructure:"enabled"`
58+ Dir string `mapstructure:"dir"`
59+ MaxSize int `mapstructure:"max_size"`
60+ MaxBackups int `mapstructure:"max_backups"`
61+ MaxAge int `mapstructure:"max_age"`
62+ } `mapstructure:"container_logs"`
55 } `mapstructure:"logging"`63 } `mapstructure:"logging"`
56 64
57 Env struct {65 Env struct {
@@ -72,6 +80,7 @@ type services struct {
72 blobStorage *filesystem.BlobStorage80 blobStorage *filesystem.BlobStorage
73 manifestStorage *filesystem.ManifestStorage81 manifestStorage *filesystem.ManifestStorage
74 envLoader *envloader.FileLoader82 envLoader *envloader.FileLoader
83+ logWriter *logwriter.LogWriter
75 configSvc *config.Service84 configSvc *config.Service
76 containerSvc *container.Service85 containerSvc *container.Service
77 registrySvc *registrySvc.Service86 registrySvc *registrySvc.Service
@@ -194,6 +203,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
194 return nil, err203 return nil, err
195 }204 }
196 205
206+ // Create log writer
207+ if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
208+ return nil, err
209+ }
210+
197 // Create use case services211 // Create use case services
198 svc.configSvc = config.NewService(v, svc.eventBus)212 svc.configSvc = config.NewService(v, svc.eventBus)
199 if err := svc.configSvc.Load(ctx); err != nil {213 if err := svc.configSvc.Load(ctx); err != nil {
@@ -284,6 +298,37 @@ func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, er
284 return envLoader, nil298 return envLoader, nil
285 }299 }
286 300
301+// createLogWriter creates the container log writer.
302+func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
303+ if !cfg.Logging.ContainerLogs.Enabled {
304+ log.Debug().Msg("container log collection disabled")
305+ return nil, nil
306+ }
307+
308+ // Determine log directory
309+ logDir := cfg.Logging.ContainerLogs.Dir
310+ if logDir == "" {
311+ dataDir := cfg.Server.DataDir
312+ if dataDir == "" {
313+ dataDir = "/var/lib/gordon"
314+ }
315+ logDir = filepath.Join(dataDir, "logs", "containers")
316+ }
317+
318+ writer, err := logwriter.New(logwriter.Config{
319+ Dir: logDir,
320+ MaxSize: cfg.Logging.ContainerLogs.MaxSize,
321+ MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
322+ MaxAge: cfg.Logging.ContainerLogs.MaxAge,
323+ })
324+ if err != nil {
325+ return nil, log.WrapErr(err, "failed to create container log writer")
326+ }
327+
328+ log.Info().Str("dir", logDir).Msg("container log collection enabled")
329+ return writer, nil
330+}
331+
287 // createContainerService creates the container service with configuration.332 // createContainerService creates the container service with configuration.
288 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {333 func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
289 containerConfig := container.Config{334 containerConfig := container.Config{
@@ -300,7 +345,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
300 NetworkGroups: svc.configSvc.GetNetworkGroups(),345 NetworkGroups: svc.configSvc.GetNetworkGroups(),
301 Attachments: svc.configSvc.GetAttachments(),346 Attachments: svc.configSvc.GetAttachments(),
302 }347 }
303- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, containerConfig)348+ return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
304 }349 }
305 350
306 // registerEventHandlers registers all event handlers.351 // registerEventHandlers registers all event handlers.
@@ -521,6 +566,11 @@ func loadConfig(v *viper.Viper, configPath string) error {
521 v.SetDefault("logging.file.max_size", 100)566 v.SetDefault("logging.file.max_size", 100)
522 v.SetDefault("logging.file.max_backups", 3)567 v.SetDefault("logging.file.max_backups", 3)
523 v.SetDefault("logging.file.max_age", 28)568 v.SetDefault("logging.file.max_age", 28)
569+ v.SetDefault("logging.container_logs.enabled", true)
570+ v.SetDefault("logging.container_logs.dir", "")
571+ v.SetDefault("logging.container_logs.max_size", 100)
572+ v.SetDefault("logging.container_logs.max_backups", 3)
573+ v.SetDefault("logging.container_logs.max_age", 28)
524 v.SetDefault("env.dir", "/var/lib/gordon/env")574 v.SetDefault("env.dir", "/var/lib/gordon/env")
525 v.SetDefault("registry_auth.enabled", false)575 v.SetDefault("registry_auth.enabled", false)
526 576
A internal/boundaries/out/logwriter.go +21 -0
@@ -0,0 +1,21 @@
1+package out
2+
3+import (
4+ "context"
5+ "io"
6+)
7+
8+// ContainerLogWriter defines the contract for container log collection.
9+// This interface abstracts the storage of container stdout/stderr streams.
10+type ContainerLogWriter interface {
11+ // StartLogging begins collecting logs for a container.
12+ // The logStream should be from ContainerRuntime.GetContainerLogs with follow=true.
13+ // Domain is used for the log filename.
14+ StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error
15+
16+ // StopLogging stops log collection for a container.
17+ StopLogging(containerID string) error
18+
19+ // Close stops all logging and releases resources.
20+ Close() error
21+}
@@ -0,0 +1,21 @@
1+package out
2+
3+import (
4+ "context"
5+ "io"
6+)
7+
8+// ContainerLogWriter defines the contract for container log collection.
9+// This interface abstracts the storage of container stdout/stderr streams.
10+type ContainerLogWriter interface {
11+ // StartLogging begins collecting logs for a container.
12+ // The logStream should be from ContainerRuntime.GetContainerLogs with follow=true.
13+ // Domain is used for the log filename.
14+ StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error
15+
16+ // StopLogging stops log collection for a container.
17+ StopLogging(containerID string) error
18+
19+ // Close stops all logging and releases resources.
20+ Close() error
21+}
A internal/boundaries/out/mocks/mock_container_log_writer.go +203 -0
@@ -0,0 +1,203 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockContainerLogWriter creates a new instance of MockContainerLogWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockContainerLogWriter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockContainerLogWriter {
20+ mock := &MockContainerLogWriter{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockContainerLogWriter is an autogenerated mock type for the ContainerLogWriter type
29+type MockContainerLogWriter struct {
30+ mock.Mock
31+}
32+
33+type MockContainerLogWriter_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockContainerLogWriter) EXPECT() *MockContainerLogWriter_Expecter {
38+ return &MockContainerLogWriter_Expecter{mock: &_m.Mock}
39+}
40+
41+// Close provides a mock function for the type MockContainerLogWriter
42+func (_mock *MockContainerLogWriter) Close() error {
43+ ret := _mock.Called()
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Close")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func() error); ok {
51+ r0 = returnFunc()
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockContainerLogWriter_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
59+type MockContainerLogWriter_Close_Call struct {
60+ *mock.Call
61+}
62+
63+// Close is a helper method to define mock.On call
64+func (_e *MockContainerLogWriter_Expecter) Close() *MockContainerLogWriter_Close_Call {
65+ return &MockContainerLogWriter_Close_Call{Call: _e.mock.On("Close")}
66+}
67+
68+func (_c *MockContainerLogWriter_Close_Call) Run(run func()) *MockContainerLogWriter_Close_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ run()
71+ })
72+ return _c
73+}
74+
75+func (_c *MockContainerLogWriter_Close_Call) Return(err error) *MockContainerLogWriter_Close_Call {
76+ _c.Call.Return(err)
77+ return _c
78+}
79+
80+func (_c *MockContainerLogWriter_Close_Call) RunAndReturn(run func() error) *MockContainerLogWriter_Close_Call {
81+ _c.Call.Return(run)
82+ return _c
83+}
84+
85+// StartLogging provides a mock function for the type MockContainerLogWriter
86+func (_mock *MockContainerLogWriter) StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error {
87+ ret := _mock.Called(ctx, containerID, domain, logStream)
88+
89+ if len(ret) == 0 {
90+ panic("no return value specified for StartLogging")
91+ }
92+
93+ var r0 error
94+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.ReadCloser) error); ok {
95+ r0 = returnFunc(ctx, containerID, domain, logStream)
96+ } else {
97+ r0 = ret.Error(0)
98+ }
99+ return r0
100+}
101+
102+// MockContainerLogWriter_StartLogging_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartLogging'
103+type MockContainerLogWriter_StartLogging_Call struct {
104+ *mock.Call
105+}
106+
107+// StartLogging is a helper method to define mock.On call
108+// - ctx context.Context
109+// - containerID string
110+// - domain string
111+// - logStream io.ReadCloser
112+func (_e *MockContainerLogWriter_Expecter) StartLogging(ctx interface{}, containerID interface{}, domain interface{}, logStream interface{}) *MockContainerLogWriter_StartLogging_Call {
113+ return &MockContainerLogWriter_StartLogging_Call{Call: _e.mock.On("StartLogging", ctx, containerID, domain, logStream)}
114+}
115+
116+func (_c *MockContainerLogWriter_StartLogging_Call) Run(run func(ctx context.Context, containerID string, domain string, logStream io.ReadCloser)) *MockContainerLogWriter_StartLogging_Call {
117+ _c.Call.Run(func(args mock.Arguments) {
118+ var arg0 context.Context
119+ if args[0] != nil {
120+ arg0 = args[0].(context.Context)
121+ }
122+ var arg1 string
123+ if args[1] != nil {
124+ arg1 = args[1].(string)
125+ }
126+ var arg2 string
127+ if args[2] != nil {
128+ arg2 = args[2].(string)
129+ }
130+ var arg3 io.ReadCloser
131+ if args[3] != nil {
132+ arg3 = args[3].(io.ReadCloser)
133+ }
134+ run(
135+ arg0,
136+ arg1,
137+ arg2,
138+ arg3,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *MockContainerLogWriter_StartLogging_Call) Return(err error) *MockContainerLogWriter_StartLogging_Call {
145+ _c.Call.Return(err)
146+ return _c
147+}
148+
149+func (_c *MockContainerLogWriter_StartLogging_Call) RunAndReturn(run func(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error) *MockContainerLogWriter_StartLogging_Call {
150+ _c.Call.Return(run)
151+ return _c
152+}
153+
154+// StopLogging provides a mock function for the type MockContainerLogWriter
155+func (_mock *MockContainerLogWriter) StopLogging(containerID string) error {
156+ ret := _mock.Called(containerID)
157+
158+ if len(ret) == 0 {
159+ panic("no return value specified for StopLogging")
160+ }
161+
162+ var r0 error
163+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
164+ r0 = returnFunc(containerID)
165+ } else {
166+ r0 = ret.Error(0)
167+ }
168+ return r0
169+}
170+
171+// MockContainerLogWriter_StopLogging_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopLogging'
172+type MockContainerLogWriter_StopLogging_Call struct {
173+ *mock.Call
174+}
175+
176+// StopLogging is a helper method to define mock.On call
177+// - containerID string
178+func (_e *MockContainerLogWriter_Expecter) StopLogging(containerID interface{}) *MockContainerLogWriter_StopLogging_Call {
179+ return &MockContainerLogWriter_StopLogging_Call{Call: _e.mock.On("StopLogging", containerID)}
180+}
181+
182+func (_c *MockContainerLogWriter_StopLogging_Call) Run(run func(containerID string)) *MockContainerLogWriter_StopLogging_Call {
183+ _c.Call.Run(func(args mock.Arguments) {
184+ var arg0 string
185+ if args[0] != nil {
186+ arg0 = args[0].(string)
187+ }
188+ run(
189+ arg0,
190+ )
191+ })
192+ return _c
193+}
194+
195+func (_c *MockContainerLogWriter_StopLogging_Call) Return(err error) *MockContainerLogWriter_StopLogging_Call {
196+ _c.Call.Return(err)
197+ return _c
198+}
199+
200+func (_c *MockContainerLogWriter_StopLogging_Call) RunAndReturn(run func(containerID string) error) *MockContainerLogWriter_StopLogging_Call {
201+ _c.Call.Return(run)
202+ return _c
203+}
@@ -0,0 +1,203 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "io"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockContainerLogWriter creates a new instance of MockContainerLogWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockContainerLogWriter(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockContainerLogWriter {
20+ mock := &MockContainerLogWriter{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockContainerLogWriter is an autogenerated mock type for the ContainerLogWriter type
29+type MockContainerLogWriter struct {
30+ mock.Mock
31+}
32+
33+type MockContainerLogWriter_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockContainerLogWriter) EXPECT() *MockContainerLogWriter_Expecter {
38+ return &MockContainerLogWriter_Expecter{mock: &_m.Mock}
39+}
40+
41+// Close provides a mock function for the type MockContainerLogWriter
42+func (_mock *MockContainerLogWriter) Close() error {
43+ ret := _mock.Called()
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Close")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func() error); ok {
51+ r0 = returnFunc()
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockContainerLogWriter_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
59+type MockContainerLogWriter_Close_Call struct {
60+ *mock.Call
61+}
62+
63+// Close is a helper method to define mock.On call
64+func (_e *MockContainerLogWriter_Expecter) Close() *MockContainerLogWriter_Close_Call {
65+ return &MockContainerLogWriter_Close_Call{Call: _e.mock.On("Close")}
66+}
67+
68+func (_c *MockContainerLogWriter_Close_Call) Run(run func()) *MockContainerLogWriter_Close_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ run()
71+ })
72+ return _c
73+}
74+
75+func (_c *MockContainerLogWriter_Close_Call) Return(err error) *MockContainerLogWriter_Close_Call {
76+ _c.Call.Return(err)
77+ return _c
78+}
79+
80+func (_c *MockContainerLogWriter_Close_Call) RunAndReturn(run func() error) *MockContainerLogWriter_Close_Call {
81+ _c.Call.Return(run)
82+ return _c
83+}
84+
85+// StartLogging provides a mock function for the type MockContainerLogWriter
86+func (_mock *MockContainerLogWriter) StartLogging(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error {
87+ ret := _mock.Called(ctx, containerID, domain, logStream)
88+
89+ if len(ret) == 0 {
90+ panic("no return value specified for StartLogging")
91+ }
92+
93+ var r0 error
94+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.ReadCloser) error); ok {
95+ r0 = returnFunc(ctx, containerID, domain, logStream)
96+ } else {
97+ r0 = ret.Error(0)
98+ }
99+ return r0
100+}
101+
102+// MockContainerLogWriter_StartLogging_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartLogging'
103+type MockContainerLogWriter_StartLogging_Call struct {
104+ *mock.Call
105+}
106+
107+// StartLogging is a helper method to define mock.On call
108+// - ctx context.Context
109+// - containerID string
110+// - domain string
111+// - logStream io.ReadCloser
112+func (_e *MockContainerLogWriter_Expecter) StartLogging(ctx interface{}, containerID interface{}, domain interface{}, logStream interface{}) *MockContainerLogWriter_StartLogging_Call {
113+ return &MockContainerLogWriter_StartLogging_Call{Call: _e.mock.On("StartLogging", ctx, containerID, domain, logStream)}
114+}
115+
116+func (_c *MockContainerLogWriter_StartLogging_Call) Run(run func(ctx context.Context, containerID string, domain string, logStream io.ReadCloser)) *MockContainerLogWriter_StartLogging_Call {
117+ _c.Call.Run(func(args mock.Arguments) {
118+ var arg0 context.Context
119+ if args[0] != nil {
120+ arg0 = args[0].(context.Context)
121+ }
122+ var arg1 string
123+ if args[1] != nil {
124+ arg1 = args[1].(string)
125+ }
126+ var arg2 string
127+ if args[2] != nil {
128+ arg2 = args[2].(string)
129+ }
130+ var arg3 io.ReadCloser
131+ if args[3] != nil {
132+ arg3 = args[3].(io.ReadCloser)
133+ }
134+ run(
135+ arg0,
136+ arg1,
137+ arg2,
138+ arg3,
139+ )
140+ })
141+ return _c
142+}
143+
144+func (_c *MockContainerLogWriter_StartLogging_Call) Return(err error) *MockContainerLogWriter_StartLogging_Call {
145+ _c.Call.Return(err)
146+ return _c
147+}
148+
149+func (_c *MockContainerLogWriter_StartLogging_Call) RunAndReturn(run func(ctx context.Context, containerID string, domain string, logStream io.ReadCloser) error) *MockContainerLogWriter_StartLogging_Call {
150+ _c.Call.Return(run)
151+ return _c
152+}
153+
154+// StopLogging provides a mock function for the type MockContainerLogWriter
155+func (_mock *MockContainerLogWriter) StopLogging(containerID string) error {
156+ ret := _mock.Called(containerID)
157+
158+ if len(ret) == 0 {
159+ panic("no return value specified for StopLogging")
160+ }
161+
162+ var r0 error
163+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
164+ r0 = returnFunc(containerID)
165+ } else {
166+ r0 = ret.Error(0)
167+ }
168+ return r0
169+}
170+
171+// MockContainerLogWriter_StopLogging_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StopLogging'
172+type MockContainerLogWriter_StopLogging_Call struct {
173+ *mock.Call
174+}
175+
176+// StopLogging is a helper method to define mock.On call
177+// - containerID string
178+func (_e *MockContainerLogWriter_Expecter) StopLogging(containerID interface{}) *MockContainerLogWriter_StopLogging_Call {
179+ return &MockContainerLogWriter_StopLogging_Call{Call: _e.mock.On("StopLogging", containerID)}
180+}
181+
182+func (_c *MockContainerLogWriter_StopLogging_Call) Run(run func(containerID string)) *MockContainerLogWriter_StopLogging_Call {
183+ _c.Call.Run(func(args mock.Arguments) {
184+ var arg0 string
185+ if args[0] != nil {
186+ arg0 = args[0].(string)
187+ }
188+ run(
189+ arg0,
190+ )
191+ })
192+ return _c
193+}
194+
195+func (_c *MockContainerLogWriter_StopLogging_Call) Return(err error) *MockContainerLogWriter_StopLogging_Call {
196+ _c.Call.Return(err)
197+ return _c
198+}
199+
200+func (_c *MockContainerLogWriter_StopLogging_Call) RunAndReturn(run func(containerID string) error) *MockContainerLogWriter_StopLogging_Call {
201+ _c.Call.Return(run)
202+ return _c
203+}
M internal/usecase/container/service.go +48 -0
@@ -36,6 +36,7 @@ type Service struct {
3636 runtime out.ContainerRuntime
3737 envLoader out.EnvLoader
3838 eventBus out.EventPublisher
39+ logWriter out.ContainerLogWriter
3940 config Config
4041 containers map[string]*domain.Container
4142 mu sync.RWMutex
@@ -46,12 +47,14 @@ func NewService(
4647 runtime out.ContainerRuntime,
4748 envLoader out.EnvLoader,
4849 eventBus out.EventPublisher,
50+ logWriter out.ContainerLogWriter,
4951 config Config,
5052 ) *Service {
5153 return &Service{
5254 runtime: runtime,
5355 envLoader: envLoader,
5456 eventBus: eventBus,
57+ logWriter: logWriter,
5558 config: config,
5659 containers: make(map[string]*domain.Container),
5760 }
@@ -156,6 +159,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
156159 return nil, log.WrapErr(err, "failed to inspect started container")
157160 }
158161
162+ // Start container log collection (non-blocking, errors don't fail deployment)
163+ s.startLogCollection(ctx, container.ID, route.Domain)
164+
159165 s.containers[route.Domain] = container
160166
161167 log.Info().
@@ -182,6 +188,13 @@ func (s *Service) Stop(ctx context.Context, containerID string) error {
182188 })
183189 log := zerowrap.FromCtx(ctx)
184190
191+ // Stop log collection before stopping container
192+ if s.logWriter != nil {
193+ if err := s.logWriter.StopLogging(containerID); err != nil {
194+ log.Warn().Err(err).Msg("failed to stop container log collection")
195+ }
196+ }
197+
185198 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
186199 return log.WrapErr(err, "failed to stop container")
187200 }
@@ -199,6 +212,13 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
199212 })
200213 log := zerowrap.FromCtx(ctx)
201214
215+ // Stop log collection before removing container
216+ if s.logWriter != nil {
217+ if err := s.logWriter.StopLogging(containerID); err != nil {
218+ log.Warn().Err(err).Msg("failed to stop container log collection")
219+ }
220+ }
221+
202222 // Find domain for cleanup
203223 var containerDomain string
204224 s.mu.RLock()
@@ -335,6 +355,13 @@ func (s *Service) Shutdown(ctx context.Context) error {
335355 log.WrapErr(err, "failed to stop all containers during shutdown")
336356 }
337357
358+ // Close log writer to stop all log collection
359+ if s.logWriter != nil {
360+ if err := s.logWriter.Close(); err != nil {
361+ log.Warn().Err(err).Msg("failed to close container log writer")
362+ }
363+ }
364+
338365 log.Info().Msg("container manager shutdown complete")
339366 return nil
340367 }
@@ -346,6 +373,27 @@ func (s *Service) UpdateConfig(config Config) {
346373
347374 // Helper methods
348375
376+// startLogCollection starts log collection for a container in the background.
377+// Errors are logged but don't fail the calling operation.
378+func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
379+ if s.logWriter == nil {
380+ return
381+ }
382+
383+ log := zerowrap.FromCtx(ctx)
384+
385+ logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
386+ if err != nil {
387+ log.Warn().Err(err).Msg("failed to get container logs for collection")
388+ return
389+ }
390+
391+ if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
392+ log.Warn().Err(err).Msg("failed to start container log collection")
393+ logStream.Close()
394+ }
395+}
396+
349397 func (s *Service) buildImageRef(image string) string {
350398 if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
351399 return image
@@ -36,6 +36,7 @@ type Service struct {
36 runtime out.ContainerRuntime36 runtime out.ContainerRuntime
37 envLoader out.EnvLoader37 envLoader out.EnvLoader
38 eventBus out.EventPublisher38 eventBus out.EventPublisher
39+ logWriter out.ContainerLogWriter
39 config Config40 config Config
40 containers map[string]*domain.Container41 containers map[string]*domain.Container
41 mu sync.RWMutex42 mu sync.RWMutex
@@ -46,12 +47,14 @@ func NewService(
46 runtime out.ContainerRuntime,47 runtime out.ContainerRuntime,
47 envLoader out.EnvLoader,48 envLoader out.EnvLoader,
48 eventBus out.EventPublisher,49 eventBus out.EventPublisher,
50+ logWriter out.ContainerLogWriter,
49 config Config,51 config Config,
50 ) *Service {52 ) *Service {
51 return &Service{53 return &Service{
52 runtime: runtime,54 runtime: runtime,
53 envLoader: envLoader,55 envLoader: envLoader,
54 eventBus: eventBus,56 eventBus: eventBus,
57+ logWriter: logWriter,
55 config: config,58 config: config,
56 containers: make(map[string]*domain.Container),59 containers: make(map[string]*domain.Container),
57 }60 }
@@ -156,6 +159,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
156 return nil, log.WrapErr(err, "failed to inspect started container")159 return nil, log.WrapErr(err, "failed to inspect started container")
157 }160 }
158 161
162+ // Start container log collection (non-blocking, errors don't fail deployment)
163+ s.startLogCollection(ctx, container.ID, route.Domain)
164+
159 s.containers[route.Domain] = container165 s.containers[route.Domain] = container
160 166
161 log.Info().167 log.Info().
@@ -182,6 +188,13 @@ func (s *Service) Stop(ctx context.Context, containerID string) error {
182 })188 })
183 log := zerowrap.FromCtx(ctx)189 log := zerowrap.FromCtx(ctx)
184 190
191+ // Stop log collection before stopping container
192+ if s.logWriter != nil {
193+ if err := s.logWriter.StopLogging(containerID); err != nil {
194+ log.Warn().Err(err).Msg("failed to stop container log collection")
195+ }
196+ }
197+
185 if err := s.runtime.StopContainer(ctx, containerID); err != nil {198 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
186 return log.WrapErr(err, "failed to stop container")199 return log.WrapErr(err, "failed to stop container")
187 }200 }
@@ -199,6 +212,13 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
199 })212 })
200 log := zerowrap.FromCtx(ctx)213 log := zerowrap.FromCtx(ctx)
201 214
215+ // Stop log collection before removing container
216+ if s.logWriter != nil {
217+ if err := s.logWriter.StopLogging(containerID); err != nil {
218+ log.Warn().Err(err).Msg("failed to stop container log collection")
219+ }
220+ }
221+
202 // Find domain for cleanup222 // Find domain for cleanup
203 var containerDomain string223 var containerDomain string
204 s.mu.RLock()224 s.mu.RLock()
@@ -335,6 +355,13 @@ func (s *Service) Shutdown(ctx context.Context) error {
335 log.WrapErr(err, "failed to stop all containers during shutdown")355 log.WrapErr(err, "failed to stop all containers during shutdown")
336 }356 }
337 357
358+ // Close log writer to stop all log collection
359+ if s.logWriter != nil {
360+ if err := s.logWriter.Close(); err != nil {
361+ log.Warn().Err(err).Msg("failed to close container log writer")
362+ }
363+ }
364+
338 log.Info().Msg("container manager shutdown complete")365 log.Info().Msg("container manager shutdown complete")
339 return nil366 return nil
340 }367 }
@@ -346,6 +373,27 @@ func (s *Service) UpdateConfig(config Config) {
346 373
347 // Helper methods374 // Helper methods
348 375
376+// startLogCollection starts log collection for a container in the background.
377+// Errors are logged but don't fail the calling operation.
378+func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
379+ if s.logWriter == nil {
380+ return
381+ }
382+
383+ log := zerowrap.FromCtx(ctx)
384+
385+ logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
386+ if err != nil {
387+ log.Warn().Err(err).Msg("failed to get container logs for collection")
388+ return
389+ }
390+
391+ if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
392+ log.Warn().Err(err).Msg("failed to start container log collection")
393+ logStream.Close()
394+ }
395+}
396+
349 func (s *Service) buildImageRef(image string) string {397 func (s *Service) buildImageRef(image string) string {
350 if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {398 if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
351 return image399 return image
M internal/usecase/container/service_test.go +16 -16
@@ -27,7 +27,7 @@ func TestService_Deploy_Success(t *testing.T) {
2727 VolumeAutoCreate: false,
2828 }
2929
30- svc := NewService(runtime, envLoader, eventBus, config)
30+ svc := NewService(runtime, envLoader, eventBus, nil, config)
3131 ctx := testContext()
3232
3333 route := domain.Route{
@@ -92,7 +92,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
9292 eventBus := mocks.NewMockEventPublisher(t)
9393
9494 config := Config{}
95- svc := NewService(runtime, envLoader, eventBus, config)
95+ svc := NewService(runtime, envLoader, eventBus, nil, config)
9696 ctx := testContext()
9797
9898 route := domain.Route{
@@ -117,7 +117,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
117117 eventBus := mocks.NewMockEventPublisher(t)
118118
119119 config := Config{}
120- svc := NewService(runtime, envLoader, eventBus, config)
120+ svc := NewService(runtime, envLoader, eventBus, nil, config)
121121 ctx := testContext()
122122
123123 // Pre-populate with existing container
@@ -178,7 +178,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
178178 NetworkIsolation: true,
179179 NetworkPrefix: "gordon",
180180 }
181- svc := NewService(runtime, envLoader, eventBus, config)
181+ svc := NewService(runtime, envLoader, eventBus, nil, config)
182182 ctx := testContext()
183183
184184 route := domain.Route{
@@ -221,7 +221,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
221221 VolumeAutoCreate: true,
222222 VolumePrefix: "gordon",
223223 }
224- svc := NewService(runtime, envLoader, eventBus, config)
224+ svc := NewService(runtime, envLoader, eventBus, nil, config)
225225 ctx := testContext()
226226
227227 route := domain.Route{
@@ -262,7 +262,7 @@ func TestService_Stop(t *testing.T) {
262262 envLoader := mocks.NewMockEnvLoader(t)
263263 eventBus := mocks.NewMockEventPublisher(t)
264264
265- svc := NewService(runtime, envLoader, eventBus, Config{})
265+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
266266 ctx := testContext()
267267
268268 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
@@ -277,7 +277,7 @@ func TestService_Stop_Error(t *testing.T) {
277277 envLoader := mocks.NewMockEnvLoader(t)
278278 eventBus := mocks.NewMockEventPublisher(t)
279279
280- svc := NewService(runtime, envLoader, eventBus, Config{})
280+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
281281 ctx := testContext()
282282
283283 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
@@ -293,7 +293,7 @@ func TestService_Remove(t *testing.T) {
293293 envLoader := mocks.NewMockEnvLoader(t)
294294 eventBus := mocks.NewMockEventPublisher(t)
295295
296- svc := NewService(runtime, envLoader, eventBus, Config{})
296+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
297297 ctx := testContext()
298298
299299 // Add tracked container
@@ -322,7 +322,7 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
322322 NetworkIsolation: true,
323323 NetworkPrefix: "gordon",
324324 }
325- svc := NewService(runtime, envLoader, eventBus, config)
325+ svc := NewService(runtime, envLoader, eventBus, nil, config)
326326 ctx := testContext()
327327
328328 svc.containers["test.example.com"] = &domain.Container{
@@ -346,7 +346,7 @@ func TestService_Get(t *testing.T) {
346346 envLoader := mocks.NewMockEnvLoader(t)
347347 eventBus := mocks.NewMockEventPublisher(t)
348348
349- svc := NewService(runtime, envLoader, eventBus, Config{})
349+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
350350 ctx := testContext()
351351
352352 container := &domain.Container{ID: "container-123"}
@@ -363,7 +363,7 @@ func TestService_Get_NotFound(t *testing.T) {
363363 envLoader := mocks.NewMockEnvLoader(t)
364364 eventBus := mocks.NewMockEventPublisher(t)
365365
366- svc := NewService(runtime, envLoader, eventBus, Config{})
366+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
367367 ctx := testContext()
368368
369369 result, exists := svc.Get(ctx, "nonexistent.example.com")
@@ -377,7 +377,7 @@ func TestService_List(t *testing.T) {
377377 envLoader := mocks.NewMockEnvLoader(t)
378378 eventBus := mocks.NewMockEventPublisher(t)
379379
380- svc := NewService(runtime, envLoader, eventBus, Config{})
380+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
381381 ctx := testContext()
382382
383383 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -395,7 +395,7 @@ func TestService_HealthCheck(t *testing.T) {
395395 envLoader := mocks.NewMockEnvLoader(t)
396396 eventBus := mocks.NewMockEventPublisher(t)
397397
398- svc := NewService(runtime, envLoader, eventBus, Config{})
398+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
399399 ctx := testContext()
400400
401401 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
@@ -415,7 +415,7 @@ func TestService_SyncContainers(t *testing.T) {
415415 envLoader := mocks.NewMockEnvLoader(t)
416416 eventBus := mocks.NewMockEventPublisher(t)
417417
418- svc := NewService(runtime, envLoader, eventBus, Config{})
418+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
419419 ctx := testContext()
420420
421421 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
@@ -455,7 +455,7 @@ func TestService_Shutdown(t *testing.T) {
455455 envLoader := mocks.NewMockEnvLoader(t)
456456 eventBus := mocks.NewMockEventPublisher(t)
457457
458- svc := NewService(runtime, envLoader, eventBus, Config{})
458+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
459459 ctx := testContext()
460460
461461 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -475,7 +475,7 @@ func TestService_Shutdown_PartialFailure(t *testing.T) {
475475 envLoader := mocks.NewMockEnvLoader(t)
476476 eventBus := mocks.NewMockEventPublisher(t)
477477
478- svc := NewService(runtime, envLoader, eventBus, Config{})
478+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
479479 ctx := testContext()
480480
481481 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -27,7 +27,7 @@ func TestService_Deploy_Success(t *testing.T) {
27 VolumeAutoCreate: false,27 VolumeAutoCreate: false,
28 }28 }
29 29
30- svc := NewService(runtime, envLoader, eventBus, config)30+ svc := NewService(runtime, envLoader, eventBus, nil, config)
31 ctx := testContext()31 ctx := testContext()
32 32
33 route := domain.Route{33 route := domain.Route{
@@ -92,7 +92,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
92 eventBus := mocks.NewMockEventPublisher(t)92 eventBus := mocks.NewMockEventPublisher(t)
93 93
94 config := Config{}94 config := Config{}
95- svc := NewService(runtime, envLoader, eventBus, config)95+ svc := NewService(runtime, envLoader, eventBus, nil, config)
96 ctx := testContext()96 ctx := testContext()
97 97
98 route := domain.Route{98 route := domain.Route{
@@ -117,7 +117,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
117 eventBus := mocks.NewMockEventPublisher(t)117 eventBus := mocks.NewMockEventPublisher(t)
118 118
119 config := Config{}119 config := Config{}
120- svc := NewService(runtime, envLoader, eventBus, config)120+ svc := NewService(runtime, envLoader, eventBus, nil, config)
121 ctx := testContext()121 ctx := testContext()
122 122
123 // Pre-populate with existing container123 // Pre-populate with existing container
@@ -178,7 +178,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
178 NetworkIsolation: true,178 NetworkIsolation: true,
179 NetworkPrefix: "gordon",179 NetworkPrefix: "gordon",
180 }180 }
181- svc := NewService(runtime, envLoader, eventBus, config)181+ svc := NewService(runtime, envLoader, eventBus, nil, config)
182 ctx := testContext()182 ctx := testContext()
183 183
184 route := domain.Route{184 route := domain.Route{
@@ -221,7 +221,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
221 VolumeAutoCreate: true,221 VolumeAutoCreate: true,
222 VolumePrefix: "gordon",222 VolumePrefix: "gordon",
223 }223 }
224- svc := NewService(runtime, envLoader, eventBus, config)224+ svc := NewService(runtime, envLoader, eventBus, nil, config)
225 ctx := testContext()225 ctx := testContext()
226 226
227 route := domain.Route{227 route := domain.Route{
@@ -262,7 +262,7 @@ func TestService_Stop(t *testing.T) {
262 envLoader := mocks.NewMockEnvLoader(t)262 envLoader := mocks.NewMockEnvLoader(t)
263 eventBus := mocks.NewMockEventPublisher(t)263 eventBus := mocks.NewMockEventPublisher(t)
264 264
265- svc := NewService(runtime, envLoader, eventBus, Config{})265+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
266 ctx := testContext()266 ctx := testContext()
267 267
268 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)268 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
@@ -277,7 +277,7 @@ func TestService_Stop_Error(t *testing.T) {
277 envLoader := mocks.NewMockEnvLoader(t)277 envLoader := mocks.NewMockEnvLoader(t)
278 eventBus := mocks.NewMockEventPublisher(t)278 eventBus := mocks.NewMockEventPublisher(t)
279 279
280- svc := NewService(runtime, envLoader, eventBus, Config{})280+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
281 ctx := testContext()281 ctx := testContext()
282 282
283 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))283 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
@@ -293,7 +293,7 @@ func TestService_Remove(t *testing.T) {
293 envLoader := mocks.NewMockEnvLoader(t)293 envLoader := mocks.NewMockEnvLoader(t)
294 eventBus := mocks.NewMockEventPublisher(t)294 eventBus := mocks.NewMockEventPublisher(t)
295 295
296- svc := NewService(runtime, envLoader, eventBus, Config{})296+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
297 ctx := testContext()297 ctx := testContext()
298 298
299 // Add tracked container299 // Add tracked container
@@ -322,7 +322,7 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
322 NetworkIsolation: true,322 NetworkIsolation: true,
323 NetworkPrefix: "gordon",323 NetworkPrefix: "gordon",
324 }324 }
325- svc := NewService(runtime, envLoader, eventBus, config)325+ svc := NewService(runtime, envLoader, eventBus, nil, config)
326 ctx := testContext()326 ctx := testContext()
327 327
328 svc.containers["test.example.com"] = &domain.Container{328 svc.containers["test.example.com"] = &domain.Container{
@@ -346,7 +346,7 @@ func TestService_Get(t *testing.T) {
346 envLoader := mocks.NewMockEnvLoader(t)346 envLoader := mocks.NewMockEnvLoader(t)
347 eventBus := mocks.NewMockEventPublisher(t)347 eventBus := mocks.NewMockEventPublisher(t)
348 348
349- svc := NewService(runtime, envLoader, eventBus, Config{})349+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
350 ctx := testContext()350 ctx := testContext()
351 351
352 container := &domain.Container{ID: "container-123"}352 container := &domain.Container{ID: "container-123"}
@@ -363,7 +363,7 @@ func TestService_Get_NotFound(t *testing.T) {
363 envLoader := mocks.NewMockEnvLoader(t)363 envLoader := mocks.NewMockEnvLoader(t)
364 eventBus := mocks.NewMockEventPublisher(t)364 eventBus := mocks.NewMockEventPublisher(t)
365 365
366- svc := NewService(runtime, envLoader, eventBus, Config{})366+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
367 ctx := testContext()367 ctx := testContext()
368 368
369 result, exists := svc.Get(ctx, "nonexistent.example.com")369 result, exists := svc.Get(ctx, "nonexistent.example.com")
@@ -377,7 +377,7 @@ func TestService_List(t *testing.T) {
377 envLoader := mocks.NewMockEnvLoader(t)377 envLoader := mocks.NewMockEnvLoader(t)
378 eventBus := mocks.NewMockEventPublisher(t)378 eventBus := mocks.NewMockEventPublisher(t)
379 379
380- svc := NewService(runtime, envLoader, eventBus, Config{})380+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
381 ctx := testContext()381 ctx := testContext()
382 382
383 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}383 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -395,7 +395,7 @@ func TestService_HealthCheck(t *testing.T) {
395 envLoader := mocks.NewMockEnvLoader(t)395 envLoader := mocks.NewMockEnvLoader(t)
396 eventBus := mocks.NewMockEventPublisher(t)396 eventBus := mocks.NewMockEventPublisher(t)
397 397
398- svc := NewService(runtime, envLoader, eventBus, Config{})398+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
399 ctx := testContext()399 ctx := testContext()
400 400
401 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}401 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
@@ -415,7 +415,7 @@ func TestService_SyncContainers(t *testing.T) {
415 envLoader := mocks.NewMockEnvLoader(t)415 envLoader := mocks.NewMockEnvLoader(t)
416 eventBus := mocks.NewMockEventPublisher(t)416 eventBus := mocks.NewMockEventPublisher(t)
417 417
418- svc := NewService(runtime, envLoader, eventBus, Config{})418+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
419 ctx := testContext()419 ctx := testContext()
420 420
421 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{421 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
@@ -455,7 +455,7 @@ func TestService_Shutdown(t *testing.T) {
455 envLoader := mocks.NewMockEnvLoader(t)455 envLoader := mocks.NewMockEnvLoader(t)
456 eventBus := mocks.NewMockEventPublisher(t)456 eventBus := mocks.NewMockEventPublisher(t)
457 457
458- svc := NewService(runtime, envLoader, eventBus, Config{})458+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
459 ctx := testContext()459 ctx := testContext()
460 460
461 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}461 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -475,7 +475,7 @@ func TestService_Shutdown_PartialFailure(t *testing.T) {
475 envLoader := mocks.NewMockEnvLoader(t)475 envLoader := mocks.NewMockEnvLoader(t)
476 eventBus := mocks.NewMockEventPublisher(t)476 eventBus := mocks.NewMockEventPublisher(t)
477 477
478- svc := NewService(runtime, envLoader, eventBus, Config{})478+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
479 ctx := testContext()479 ctx := testContext()
480 480
481 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}481 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close