From bba5f802941f992d5fe9c0feca5367e6fd540db9 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 16 Apr 2026 15:09:26 +0600 Subject: [PATCH] Add production file logging with logrotate support Both control plane and host agent now write structured slog output to $WRENN_DIR/logs/ in addition to stderr. Log level is configurable via LOG_LEVEL env var (default: info). SIGHUP reopens the log file so logrotate can rotate without copytruncate. --- .env.example | 5 +- cmd/host-agent/main.go | 8 +-- deploy/logrotate/wrenn | 19 ++++++ pkg/config/config.go | 2 + pkg/cpserver/run.go | 8 +-- pkg/logging/logging.go | 135 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 deploy/logrotate/wrenn create mode 100644 pkg/logging/logging.go diff --git a/.env.example b/.env.example index b9075b2..7446591 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ +# Shared (applies to both control plane and host agent) +WRENN_DIR=/var/lib/wrenn +LOG_LEVEL=info + # Database DATABASE_URL=postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable @@ -9,7 +13,6 @@ WRENN_CP_LISTEN_ADDR=:9725 # Host Agent WRENN_HOST_LISTEN_ADDR=:50051 -WRENN_DIR=/var/lib/wrenn WRENN_HOST_INTERFACE=eth0 WRENN_CP_URL=http://localhost:9725 WRENN_DEFAULT_ROOTFS_SIZE=5Gi diff --git a/cmd/host-agent/main.go b/cmd/host-agent/main.go index 047d726..d3bda1b 100644 --- a/cmd/host-agent/main.go +++ b/cmd/host-agent/main.go @@ -21,6 +21,7 @@ import ( "git.omukk.dev/wrenn/wrenn/internal/network" "git.omukk.dev/wrenn/wrenn/internal/sandbox" "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/logging" "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect" ) @@ -38,9 +39,9 @@ func main() { advertiseAddr := flag.String("address", "", "Externally-reachable address (ip:port) for this host agent") flag.Parse() - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }))) + rootDir := envOrDefault("WRENN_DIR", "/var/lib/wrenn") + cleanupLog := logging.Setup(filepath.Join(rootDir, "logs"), "host-agent") + defer cleanupLog() if os.Geteuid() != 0 { slog.Error("host agent must run as root") @@ -57,7 +58,6 @@ func main() { network.CleanupStaleNamespaces() listenAddr := envOrDefault("WRENN_HOST_LISTEN_ADDR", ":50051") - rootDir := envOrDefault("WRENN_DIR", "/var/lib/wrenn") cpURL := os.Getenv("WRENN_CP_URL") credsFile := filepath.Join(rootDir, "host-credentials.json") diff --git a/deploy/logrotate/wrenn b/deploy/logrotate/wrenn new file mode 100644 index 0000000..f05a606 --- /dev/null +++ b/deploy/logrotate/wrenn @@ -0,0 +1,19 @@ +/var/lib/wrenn/logs/control-plane.log +/var/lib/wrenn/logs/host-agent.log +{ + daily + rotate 3 + missingok + notifempty + dateext + dateformat -%Y-%m-%d + compress + delaycompress + sharedscripts + postrotate + # Signal the processes to reopen their log files. + # Use SIGHUP — both binaries handle it gracefully. + pkill -HUP -f wrenn-cp || true + pkill -HUP -f wrenn-agent || true + endscript +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 2274bb2..a695392 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -14,6 +14,7 @@ type Config struct { RedisURL string ListenAddr string JWTSecret string + WrennDir string // WRENN_DIR — base directory for wrenn data (logs, etc.) // mTLS — CP→Agent channel. Both must be set to enable mTLS; omitting either // disables cert issuance and leaves agent connections on plain HTTP (dev mode). @@ -48,6 +49,7 @@ func Load() Config { RedisURL: envOrDefault("REDIS_URL", "redis://localhost:6379/0"), ListenAddr: envOrDefault("WRENN_CP_LISTEN_ADDR", ":8080"), JWTSecret: os.Getenv("JWT_SECRET"), + WrennDir: envOrDefault("WRENN_DIR", "/var/lib/wrenn"), CACert: os.Getenv("WRENN_CA_CERT"), CAKey: os.Getenv("WRENN_CA_KEY"), diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go index aeb21ac..497cc64 100644 --- a/pkg/cpserver/run.go +++ b/pkg/cpserver/run.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -22,6 +23,7 @@ import ( "git.omukk.dev/wrenn/wrenn/pkg/config" "git.omukk.dev/wrenn/wrenn/pkg/db" "git.omukk.dev/wrenn/wrenn/pkg/lifecycle" + "git.omukk.dev/wrenn/wrenn/pkg/logging" "git.omukk.dev/wrenn/wrenn/pkg/scheduler" ) @@ -39,11 +41,9 @@ func Run(opts ...Option) { opt(o) } - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }))) - cfg := config.Load() + cleanupLog := logging.Setup(filepath.Join(cfg.WrennDir, "logs"), "control-plane") + defer cleanupLog() if len(cfg.JWTSecret) < 32 { slog.Error("JWT_SECRET must be at least 32 characters") diff --git a/pkg/logging/logging.go b/pkg/logging/logging.go new file mode 100644 index 0000000..6159a9c --- /dev/null +++ b/pkg/logging/logging.go @@ -0,0 +1,135 @@ +package logging + +import ( + "io" + "log/slog" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" +) + +// Setup configures the global slog logger with dual output (stderr + rotating +// log file). logsDir is the directory where log files are written. binaryName +// is used as the log filename (e.g. "control-plane" → "control-plane.log"). +// +// If logsDir is empty or the directory cannot be created, Setup falls back to +// stderr-only logging and returns a no-op cleanup function. +// +// The returned cleanup function closes the log file and must be deferred. +// Setup also installs a SIGHUP handler that reopens the log file, allowing +// external log rotation tools (e.g. logrotate) to rotate files in place. +func Setup(logsDir, binaryName string) func() { + level := parseLevel(os.Getenv("LOG_LEVEL")) + + if logsDir == "" { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + }))) + return func() {} + } + + if err := os.MkdirAll(logsDir, 0750); err != nil { + // Fall back to stderr-only; log the error so operators notice. + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + }))) + slog.Warn("file logging unavailable: failed to create log directory", "dir", logsDir, "error", err) + return func() {} + } + + logPath := filepath.Join(logsDir, binaryName+".log") + rf, err := newReopenableFile(logPath) + if err != nil { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + }))) + slog.Warn("file logging unavailable: failed to open log file", "path", logPath, "error", err) + return func() {} + } + + mw := io.MultiWriter(os.Stderr, rf) + slog.SetDefault(slog.New(slog.NewTextHandler(mw, &slog.HandlerOptions{ + Level: level, + }))) + + // SIGHUP reopens the log file so logrotate can rotate in place. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP) + go func() { + for range sigCh { + if err := rf.Reopen(); err != nil { + slog.Error("failed to reopen log file on SIGHUP", "path", logPath, "error", err) + } else { + slog.Info("log file reopened", "path", logPath) + } + } + }() + + return func() { + signal.Stop(sigCh) + close(sigCh) + rf.Close() + } +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// reopenableFile is an io.Writer backed by an *os.File that can be atomically +// reopened (for log rotation via SIGHUP). All operations are goroutine-safe. +type reopenableFile struct { + path string + mu sync.Mutex + f *os.File +} + +func newReopenableFile(path string) (*reopenableFile, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0640) + if err != nil { + return nil, err + } + return &reopenableFile{path: path, f: f}, nil +} + +func (r *reopenableFile) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.f.Write(p) +} + +// Reopen closes the current file and opens a new one at the same path. +// This is the mechanism that makes logrotate's copytruncate-free rotation work: +// logrotate renames the old file, then sends SIGHUP, and the process opens a +// fresh file at the original path. +func (r *reopenableFile) Reopen() error { + r.mu.Lock() + defer r.mu.Unlock() + // Open the new file before closing the old one so a failed open doesn't + // leave the writer in a broken state with a closed fd. + f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0640) + if err != nil { + return err + } + r.f.Close() + r.f = f + return nil +} + +func (r *reopenableFile) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.f.Close() +}