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: | |||
| 18 | 18 | EventHandler: | |
| 19 | 19 | SecretProvider: | |
| 20 | 20 | EnvLoader: | |
| 21 | + | ContainerLogWriter: | |
| 21 | 22 | gordon/internal/boundaries/in: | |
| 22 | 23 | interfaces: | |
| 23 | 24 | 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 ( | |||
| 20 | 20 | "gordon/internal/adapters/out/envloader" | |
| 21 | 21 | "gordon/internal/adapters/out/eventbus" | |
| 22 | 22 | "gordon/internal/adapters/out/filesystem" | |
| 23 | + | "gordon/internal/adapters/out/logwriter" | |
| 23 | 24 | "gordon/internal/adapters/out/secrets" | |
| 24 | 25 | ||
| 25 | 26 | // Adapters - Input | |
| @@ -52,6 +53,13 @@ type Config struct { | |||
| 52 | 53 | MaxBackups int `mapstructure:"max_backups"` | |
| 53 | 54 | MaxAge int `mapstructure:"max_age"` | |
| 54 | 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 | 63 | } `mapstructure:"logging"` | |
| 56 | 64 | ||
| 57 | 65 | Env struct { | |
| @@ -72,6 +80,7 @@ type services struct { | |||
| 72 | 80 | blobStorage *filesystem.BlobStorage | |
| 73 | 81 | manifestStorage *filesystem.ManifestStorage | |
| 74 | 82 | envLoader *envloader.FileLoader | |
| 83 | + | logWriter *logwriter.LogWriter | |
| 75 | 84 | configSvc *config.Service | |
| 76 | 85 | containerSvc *container.Service | |
| 77 | 86 | registrySvc *registrySvc.Service | |
| @@ -194,6 +203,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 194 | 203 | 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 | 211 | // Create use case services | |
| 198 | 212 | svc.configSvc = config.NewService(v, svc.eventBus) | |
| 199 | 213 | if err := svc.configSvc.Load(ctx); err != nil { | |
| @@ -284,6 +298,37 @@ func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, er | |||
| 284 | 298 | 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 | 332 | // createContainerService creates the container service with configuration. | |
| 288 | 333 | func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service { | |
| 289 | 334 | containerConfig := container.Config{ | |
| @@ -300,7 +345,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe | |||
| 300 | 345 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 301 | 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 | 351 | // registerEventHandlers registers all event handlers. | |
| @@ -521,6 +566,11 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 521 | 566 | v.SetDefault("logging.file.max_size", 100) | |
| 522 | 567 | v.SetDefault("logging.file.max_backups", 3) | |
| 523 | 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 | 574 | v.SetDefault("env.dir", "/var/lib/gordon/env") | |
| 525 | 575 | v.SetDefault("registry_auth.enabled", false) | |
| 526 | 576 | ||
| @@ -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 - Input | 26 | // 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.BlobStorage | 80 | blobStorage *filesystem.BlobStorage | ||
| 73 | manifestStorage *filesystem.ManifestStorage | 81 | manifestStorage *filesystem.ManifestStorage | ||
| 74 | envLoader *envloader.FileLoader | 82 | envLoader *envloader.FileLoader | ||
| 83 | + | logWriter *logwriter.LogWriter | |||
| 75 | configSvc *config.Service | 84 | configSvc *config.Service | ||
| 76 | containerSvc *container.Service | 85 | containerSvc *container.Service | ||
| 77 | registrySvc *registrySvc.Service | 86 | registrySvc *registrySvc.Service | ||
| @@ -194,6 +203,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 194 | return nil, err | 203 | 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 services | 211 | // 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, nil | 298 | 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 { | |||
| 36 | 36 | runtime out.ContainerRuntime | |
| 37 | 37 | envLoader out.EnvLoader | |
| 38 | 38 | eventBus out.EventPublisher | |
| 39 | + | logWriter out.ContainerLogWriter | |
| 39 | 40 | config Config | |
| 40 | 41 | containers map[string]*domain.Container | |
| 41 | 42 | mu sync.RWMutex | |
| @@ -46,12 +47,14 @@ func NewService( | |||
| 46 | 47 | runtime out.ContainerRuntime, | |
| 47 | 48 | envLoader out.EnvLoader, | |
| 48 | 49 | eventBus out.EventPublisher, | |
| 50 | + | logWriter out.ContainerLogWriter, | |
| 49 | 51 | config Config, | |
| 50 | 52 | ) *Service { | |
| 51 | 53 | return &Service{ | |
| 52 | 54 | runtime: runtime, | |
| 53 | 55 | envLoader: envLoader, | |
| 54 | 56 | eventBus: eventBus, | |
| 57 | + | logWriter: logWriter, | |
| 55 | 58 | config: config, | |
| 56 | 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 | 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 | 165 | s.containers[route.Domain] = container | |
| 160 | 166 | ||
| 161 | 167 | log.Info(). | |
| @@ -182,6 +188,13 @@ func (s *Service) Stop(ctx context.Context, containerID string) error { | |||
| 182 | 188 | }) | |
| 183 | 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 | 198 | if err := s.runtime.StopContainer(ctx, containerID); err != nil { | |
| 186 | 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 | 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 | 222 | // Find domain for cleanup | |
| 203 | 223 | var containerDomain string | |
| 204 | 224 | s.mu.RLock() | |
| @@ -335,6 +355,13 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||
| 335 | 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 | 365 | log.Info().Msg("container manager shutdown complete") | |
| 339 | 366 | return nil | |
| 340 | 367 | } | |
| @@ -346,6 +373,27 @@ func (s *Service) UpdateConfig(config Config) { | |||
| 346 | 373 | ||
| 347 | 374 | // 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 | 397 | func (s *Service) buildImageRef(image string) string { | |
| 350 | 398 | if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" { | |
| 351 | 399 | return image | |
| @@ -36,6 +36,7 @@ type Service struct { | |||||
| 36 | runtime out.ContainerRuntime | 36 | runtime out.ContainerRuntime | ||
| 37 | envLoader out.EnvLoader | 37 | envLoader out.EnvLoader | ||
| 38 | eventBus out.EventPublisher | 38 | eventBus out.EventPublisher | ||
| 39 | + | logWriter out.ContainerLogWriter | |||
| 39 | config Config | 40 | config Config | ||
| 40 | containers map[string]*domain.Container | 41 | containers map[string]*domain.Container | ||
| 41 | mu sync.RWMutex | 42 | 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] = container | 165 | 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 cleanup | 222 | // Find domain for cleanup | ||
| 203 | var containerDomain string | 223 | 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 nil | 366 | return nil | ||
| 340 | } | 367 | } | ||
| @@ -346,6 +373,27 @@ func (s *Service) UpdateConfig(config Config) { | |||||
| 346 | 373 | ||||
| 347 | // Helper methods | 374 | // 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 image | 399 | return image | ||
M internal/usecase/container/service_test.go +16 -16
| @@ -27,7 +27,7 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 27 | 27 | VolumeAutoCreate: false, | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | svc := NewService(runtime, envLoader, eventBus, config) | |
| 30 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 31 | 31 | ctx := testContext() | |
| 32 | 32 | ||
| 33 | 33 | route := domain.Route{ | |
| @@ -92,7 +92,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||
| 92 | 92 | eventBus := mocks.NewMockEventPublisher(t) | |
| 93 | 93 | ||
| 94 | 94 | config := Config{} | |
| 95 | - | svc := NewService(runtime, envLoader, eventBus, config) | |
| 95 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 96 | 96 | ctx := testContext() | |
| 97 | 97 | ||
| 98 | 98 | route := domain.Route{ | |
| @@ -117,7 +117,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 117 | 117 | eventBus := mocks.NewMockEventPublisher(t) | |
| 118 | 118 | ||
| 119 | 119 | config := Config{} | |
| 120 | - | svc := NewService(runtime, envLoader, eventBus, config) | |
| 120 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 121 | 121 | ctx := testContext() | |
| 122 | 122 | ||
| 123 | 123 | // Pre-populate with existing container | |
| @@ -178,7 +178,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 178 | 178 | NetworkIsolation: true, | |
| 179 | 179 | NetworkPrefix: "gordon", | |
| 180 | 180 | } | |
| 181 | - | svc := NewService(runtime, envLoader, eventBus, config) | |
| 181 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 182 | 182 | ctx := testContext() | |
| 183 | 183 | ||
| 184 | 184 | route := domain.Route{ | |
| @@ -221,7 +221,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |||
| 221 | 221 | VolumeAutoCreate: true, | |
| 222 | 222 | VolumePrefix: "gordon", | |
| 223 | 223 | } | |
| 224 | - | svc := NewService(runtime, envLoader, eventBus, config) | |
| 224 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 225 | 225 | ctx := testContext() | |
| 226 | 226 | ||
| 227 | 227 | route := domain.Route{ | |
| @@ -262,7 +262,7 @@ func TestService_Stop(t *testing.T) { | |||
| 262 | 262 | envLoader := mocks.NewMockEnvLoader(t) | |
| 263 | 263 | eventBus := mocks.NewMockEventPublisher(t) | |
| 264 | 264 | ||
| 265 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 265 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 266 | 266 | ctx := testContext() | |
| 267 | 267 | ||
| 268 | 268 | runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil) | |
| @@ -277,7 +277,7 @@ func TestService_Stop_Error(t *testing.T) { | |||
| 277 | 277 | envLoader := mocks.NewMockEnvLoader(t) | |
| 278 | 278 | eventBus := mocks.NewMockEventPublisher(t) | |
| 279 | 279 | ||
| 280 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 280 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 281 | 281 | ctx := testContext() | |
| 282 | 282 | ||
| 283 | 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 | 293 | envLoader := mocks.NewMockEnvLoader(t) | |
| 294 | 294 | eventBus := mocks.NewMockEventPublisher(t) | |
| 295 | 295 | ||
| 296 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 296 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 297 | 297 | ctx := testContext() | |
| 298 | 298 | ||
| 299 | 299 | // Add tracked container | |
| @@ -322,7 +322,7 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) { | |||
| 322 | 322 | NetworkIsolation: true, | |
| 323 | 323 | NetworkPrefix: "gordon", | |
| 324 | 324 | } | |
| 325 | - | svc := NewService(runtime, envLoader, eventBus, config) | |
| 325 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 326 | 326 | ctx := testContext() | |
| 327 | 327 | ||
| 328 | 328 | svc.containers["test.example.com"] = &domain.Container{ | |
| @@ -346,7 +346,7 @@ func TestService_Get(t *testing.T) { | |||
| 346 | 346 | envLoader := mocks.NewMockEnvLoader(t) | |
| 347 | 347 | eventBus := mocks.NewMockEventPublisher(t) | |
| 348 | 348 | ||
| 349 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 349 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 350 | 350 | ctx := testContext() | |
| 351 | 351 | ||
| 352 | 352 | container := &domain.Container{ID: "container-123"} | |
| @@ -363,7 +363,7 @@ func TestService_Get_NotFound(t *testing.T) { | |||
| 363 | 363 | envLoader := mocks.NewMockEnvLoader(t) | |
| 364 | 364 | eventBus := mocks.NewMockEventPublisher(t) | |
| 365 | 365 | ||
| 366 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 366 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 367 | 367 | ctx := testContext() | |
| 368 | 368 | ||
| 369 | 369 | result, exists := svc.Get(ctx, "nonexistent.example.com") | |
| @@ -377,7 +377,7 @@ func TestService_List(t *testing.T) { | |||
| 377 | 377 | envLoader := mocks.NewMockEnvLoader(t) | |
| 378 | 378 | eventBus := mocks.NewMockEventPublisher(t) | |
| 379 | 379 | ||
| 380 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 380 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 381 | 381 | ctx := testContext() | |
| 382 | 382 | ||
| 383 | 383 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | |
| @@ -395,7 +395,7 @@ func TestService_HealthCheck(t *testing.T) { | |||
| 395 | 395 | envLoader := mocks.NewMockEnvLoader(t) | |
| 396 | 396 | eventBus := mocks.NewMockEventPublisher(t) | |
| 397 | 397 | ||
| 398 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 398 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 399 | 399 | ctx := testContext() | |
| 400 | 400 | ||
| 401 | 401 | svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"} | |
| @@ -415,7 +415,7 @@ func TestService_SyncContainers(t *testing.T) { | |||
| 415 | 415 | envLoader := mocks.NewMockEnvLoader(t) | |
| 416 | 416 | eventBus := mocks.NewMockEventPublisher(t) | |
| 417 | 417 | ||
| 418 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 418 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 419 | 419 | ctx := testContext() | |
| 420 | 420 | ||
| 421 | 421 | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| @@ -455,7 +455,7 @@ func TestService_Shutdown(t *testing.T) { | |||
| 455 | 455 | envLoader := mocks.NewMockEnvLoader(t) | |
| 456 | 456 | eventBus := mocks.NewMockEventPublisher(t) | |
| 457 | 457 | ||
| 458 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 458 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 459 | 459 | ctx := testContext() | |
| 460 | 460 | ||
| 461 | 461 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | |
| @@ -475,7 +475,7 @@ func TestService_Shutdown_PartialFailure(t *testing.T) { | |||
| 475 | 475 | envLoader := mocks.NewMockEnvLoader(t) | |
| 476 | 476 | eventBus := mocks.NewMockEventPublisher(t) | |
| 477 | 477 | ||
| 478 | - | svc := NewService(runtime, envLoader, eventBus, Config{}) | |
| 478 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 479 | 479 | ctx := testContext() | |
| 480 | 480 | ||
| 481 | 481 | 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 container | 123 | // 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 container | 299 | // 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"} | ||