Skip to content

Commit 5a2d3b7

Browse files
committed
Log rotation
1 parent 29a0c36 commit 5a2d3b7

File tree

4 files changed

+104
-0
lines changed

4 files changed

+104
-0
lines changed

cmd/api/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ type Config struct {
1818
DNSServer string
1919
MaxConcurrentBuilds int
2020
MaxOverlaySize string
21+
LogMaxSize string
22+
LogMaxFiles int
2123
}
2224

2325
// Load loads configuration from environment variables
@@ -37,6 +39,8 @@ func Load() *Config {
3739
DNSServer: getEnv("DNS_SERVER", "1.1.1.1"),
3840
MaxConcurrentBuilds: getEnvInt("MAX_CONCURRENT_BUILDS", 1),
3941
MaxOverlaySize: getEnv("MAX_OVERLAY_SIZE", "100GB"),
42+
LogMaxSize: getEnv("LOG_MAX_SIZE", "50MB"),
43+
LogMaxFiles: getEnvInt("LOG_MAX_FILES", 1),
4044
}
4145

4246
return cfg

cmd/api/main.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"syscall"
1313
"time"
1414

15+
"github.com/c2h5oh/datasize"
1516
"github.com/getkin/kin-openapi/openapi3filter"
1617
"github.com/ghodss/yaml"
1718
"github.com/go-chi/chi/v5"
@@ -177,6 +178,29 @@ func run() error {
177178
return nil
178179
})
179180

181+
// Log rotation scheduler
182+
grp.Go(func() error {
183+
var logMaxSize datasize.ByteSize
184+
if err := logMaxSize.UnmarshalText([]byte(app.Config.LogMaxSize)); err != nil {
185+
logger.Error("invalid LOG_MAX_SIZE config", "value", app.Config.LogMaxSize, "error", err)
186+
return nil // Don't crash server, just skip rotation
187+
}
188+
189+
ticker := time.NewTicker(5 * time.Minute)
190+
defer ticker.Stop()
191+
192+
for {
193+
select {
194+
case <-gctx.Done():
195+
return nil
196+
case <-ticker.C:
197+
if err := app.InstanceManager.RotateLogs(gctx, int64(logMaxSize), app.Config.LogMaxFiles); err != nil {
198+
logger.Error("log rotation failed", "error", err)
199+
}
200+
}
201+
}
202+
})
203+
180204
return grp.Wait()
181205
}
182206

lib/instances/logs.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bufio"
55
"context"
66
"fmt"
7+
"io"
8+
"os"
79
"os/exec"
810
"strconv"
911

@@ -74,3 +76,59 @@ func (m *manager) streamInstanceLogs(ctx context.Context, id string, tail int, f
7476

7577
return out, nil
7678
}
79+
80+
// rotateLogIfNeeded performs copytruncate rotation if file exceeds maxBytes
81+
// Keeps up to maxFiles old backups (.1, .2, etc.)
82+
func rotateLogIfNeeded(path string, maxBytes int64, maxFiles int) error {
83+
info, err := os.Stat(path)
84+
if err != nil {
85+
if os.IsNotExist(err) {
86+
return nil // Nothing to rotate
87+
}
88+
return fmt.Errorf("stat log file: %w", err)
89+
}
90+
91+
if info.Size() < maxBytes {
92+
return nil // Under limit, nothing to do
93+
}
94+
95+
// Shift old backups (.1 -> .2, .2 -> .3, etc.)
96+
for i := maxFiles; i >= 1; i-- {
97+
oldPath := fmt.Sprintf("%s.%d", path, i)
98+
newPath := fmt.Sprintf("%s.%d", path, i+1)
99+
100+
if i == maxFiles {
101+
// Delete the oldest backup
102+
os.Remove(oldPath)
103+
} else {
104+
// Shift to next number
105+
os.Rename(oldPath, newPath)
106+
}
107+
}
108+
109+
// Copy current log to .1
110+
src, err := os.Open(path)
111+
if err != nil {
112+
return fmt.Errorf("open log for rotation: %w", err)
113+
}
114+
115+
dst, err := os.Create(path + ".1")
116+
if err != nil {
117+
src.Close()
118+
return fmt.Errorf("create backup: %w", err)
119+
}
120+
121+
_, err = io.Copy(dst, src)
122+
src.Close()
123+
dst.Close()
124+
if err != nil {
125+
return fmt.Errorf("copy to backup: %w", err)
126+
}
127+
128+
// Truncate original (keeps file descriptor valid for writers)
129+
if err := os.Truncate(path, 0); err != nil {
130+
return fmt.Errorf("truncate log: %w", err)
131+
}
132+
133+
return nil
134+
}

lib/instances/manager.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Manager interface {
1919
StandbyInstance(ctx context.Context, id string) (*Instance, error)
2020
RestoreInstance(ctx context.Context, id string) (*Instance, error)
2121
StreamInstanceLogs(ctx context.Context, id string, tail int, follow bool) (<-chan string, error)
22+
RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) error
2223
AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error)
2324
DetachVolume(ctx context.Context, id string, volumeId string) (*Instance, error)
2425
}
@@ -115,6 +116,23 @@ func (m *manager) StreamInstanceLogs(ctx context.Context, id string, tail int, f
115116
return m.streamInstanceLogs(ctx, id, tail, follow)
116117
}
117118

119+
// RotateLogs rotates console logs for all instances that exceed maxBytes
120+
func (m *manager) RotateLogs(ctx context.Context, maxBytes int64, maxFiles int) error {
121+
instances, err := m.listInstances(ctx)
122+
if err != nil {
123+
return fmt.Errorf("list instances for rotation: %w", err)
124+
}
125+
126+
var lastErr error
127+
for _, inst := range instances {
128+
logPath := m.paths.InstanceConsoleLog(inst.Id)
129+
if err := rotateLogIfNeeded(logPath, maxBytes, maxFiles); err != nil {
130+
lastErr = err // Continue with other instances, but track error
131+
}
132+
}
133+
return lastErr
134+
}
135+
118136
// AttachVolume attaches a volume to an instance (not yet implemented)
119137
func (m *manager) AttachVolume(ctx context.Context, id string, volumeId string, req AttachVolumeRequest) (*Instance, error) {
120138
return nil, fmt.Errorf("attach volume not yet implemented")

0 commit comments

Comments
 (0)